Signed, retried, replay-safe.
Every state change emits an event. Register an endpoint with your secret key (or the merchant portal); we sign each delivery with HMAC-SHA256 and retry for up to 12 hours with exponential backoff. Bank-side webhooks for A2A follow the same shape.
Registering an endpoint
Register with your secret key — no portal login needed — or from the merchant portal (Developers → Webhooks). Creation returns the signing_secret once — save it. The endpoint accepts an sk_ API key or a portal session.
curl -X POST https://api.merashub.com/v1/merchants/me/webhook-endpoints \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://yoursite.example/api/webhooks/meras",
"environment": "live",
"events": ["payment_intent.succeeded","payment_intent.payment_failed","refund.succeeded"]
}'
# → 201 {
# "id": "wh_...",
# "signing_secret": "whsec_...",
# "events_subscribed": ["payment_intent.succeeded", ...],
# "warning": "Save the signing_secret now — it won't be shown again."
# }Omit events to receive everything (["*"]). List with GET the same path; remove with DELETE /v1/merchants/me/webhook-endpoints/:id.
Event payload & mapping
Every delivery is a JSON envelope: id, type, created, and a data object holding the full resource (the PaymentIntent for payment_intent.*). Map the event back to your order using data.metadata — every key you set when you created the checkout session (or PaymentIntent) is echoed here verbatim, so a reference you attach at creation comes straight back on the webhook.
{
"id": "evt_1a2b3c...",
"type": "payment_intent.succeeded",
"api_version": "2026-05-23",
"created": 1754640000,
"merchant_id": "mch_...",
"data": {
"id": "pi_...",
"status": "succeeded",
"amount_minor": 26000, // DJF is zero-decimal: 26000 = 26,000 DJF
"currency": "DJF",
"metadata": {
"reference": "tkt_9K3F" // ← your key, echoed back. Map on this.
}
}
}For a hosted-checkout redirect, the customer also lands back on your success_url with ?session_id=cs_… appended — but the webhook is the source of truth for fulfilment; the redirect can be lost if the customer closes the tab.
Verifying the signature
Each delivery carries a MerasPay-Signature header of the form t=<timestamp>,v1=<hex hmac>. Compute HMAC-SHA256(secret, <timestamp>.<event.id>.<body>) and compare.
import crypto from "crypto";
function verifyMeraspay(headers, body, secret) {
const sig = headers["meraspay-signature"];
const parts = Object.fromEntries(sig.split(",").map(s => s.split("=")));
const t = parts.t, v1 = parts.v1;
// Reject deliveries older than 5 minutes — replay protection.
if (Math.abs(Date.now()/1000 - Number(t)) > 300) return false;
const evt = JSON.parse(body);
const computed = crypto
.createHmac("sha256", secret)
.update(`${t}.${evt.id}.${body}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(computed));
}Idempotency on your side
event.id.Event catalog
Subscribe to any subset by filtering on the event type at the merchant portal.
payment_intent.createdNew PaymentIntent registered.
payment_intent.requires_actionAwaiting OTP, redirect, or external confirmation.
payment_intent.succeededSettled. Source of truth for fulfilment.
payment_intent.payment_failedProvider declined or timed out. Includes reason code.
payment_intent.canceledYou or the customer cancelled before settlement.
charge.refundedRefund applied. Includes refund amount + reason.
refund.succeededRefund settled on the originating rail.
dispute.createdDispute filed against a charge.
payout.paidB2C payout reached the recipient wallet/account.
transfer.createdInter-merchant transfer recorded.
a2a.transfer.acceptedA2A: pacs.008 accepted, ledger pending.
a2a.transfer.succeededA2A: settlement complete, both nostros adjusted.
a2a.transfer.heldA2A: ACWP — held for compliance review or 4-eyes.
a2a.transfer.failedA2A: RJCT — see reason code.
a2a.transfer.returnedA2A: pacs.004 reversal applied.
Retry schedule
- 1
Attempt 1 — immediate
We POST your endpoint right after the event lands. Read 2xx within 8 seconds → done. - 2
Attempt 2 — 1 minute
If we get non-2xx or timeout, we wait 1 minute and retry. - 3
Attempt 3 — 5 minutes
Then 5 minutes. - 4
Attempt 4 — 30 minutes
Then 30 minutes. - 5
Attempt 5 — 2 hours
Then 2 hours. - 6
Final — 12 hours
Last attempt at 12 hours. After this, the delivery lands in the dead-letter queue for manual replay from the merchant portal.
Replay from the portal