Webhooks

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.

POST /v1/merchants/me/webhook-endpointsbash
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.metadataevery 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.

payment_intent.succeeded delivery bodyjson
{
  "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.

Node verifierts
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

We may retry deliveries — duplicates are possible if your endpoint returns 2xx then fails before you finish processing. Always upsert on event.id.

Event catalog

Subscribe to any subset by filtering on the event type at the merchant portal.

  • payment_intent.created

    New PaymentIntent registered.

  • payment_intent.requires_action

    Awaiting OTP, redirect, or external confirmation.

  • payment_intent.succeeded

    Settled. Source of truth for fulfilment.

  • payment_intent.payment_failed

    Provider declined or timed out. Includes reason code.

  • payment_intent.canceled

    You or the customer cancelled before settlement.

  • charge.refunded

    Refund applied. Includes refund amount + reason.

  • refund.succeeded

    Refund settled on the originating rail.

  • dispute.created

    Dispute filed against a charge.

  • payout.paid

    B2C payout reached the recipient wallet/account.

  • transfer.created

    Inter-merchant transfer recorded.

  • a2a.transfer.accepted

    A2A: pacs.008 accepted, ledger pending.

  • a2a.transfer.succeeded

    A2A: settlement complete, both nostros adjusted.

  • a2a.transfer.held

    A2A: ACWP — held for compliance review or 4-eyes.

  • a2a.transfer.failed

    A2A: RJCT — see reason code.

  • a2a.transfer.returned

    A2A: pacs.004 reversal applied.

Retry schedule

  1. 1

    Attempt 1 — immediate

    We POST your endpoint right after the event lands. Read 2xx within 8 seconds → done.
  2. 2

    Attempt 2 — 1 minute

    If we get non-2xx or timeout, we wait 1 minute and retry.
  3. 3

    Attempt 3 — 5 minutes

    Then 5 minutes.
  4. 4

    Attempt 4 — 30 minutes

    Then 30 minutes.
  5. 5

    Attempt 5 — 2 hours

    Then 2 hours.
  6. 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

Every webhook delivery — successful or not — is listed in the merchant portal with the full request/response. You can manually replay any past 30 days of events to your endpoint without re-running upstream operations.