Browse documentation

APIs

Webhooks

Let Payer come to you. Register a signed HTTPS endpoint and get a POST the moment a checkout session or a mandate changes state.

View as Markdown

Rather than poll Retrieve a session for a status change, register an endpoint and let Payer come to you. When a checkout session or a mandate reaches a lifecycle event, Payer sends a signed POST to your HTTPS URL. Each delivery carries a Payer-Signature header you verify with the endpoint's signing secret.

Endpoints are managed from the Developers page in your Payer dashboard (admin role), where you get one set per environment, not with the checkout API keys. Test endpoints receive events from your _test_ sessions, live endpoints from _live_ sessions. The envelope, headers and retry contract are also modelled in the machine-readable OpenAPI spec.

Events

Subscribe each endpoint to one or more of these events:

EventSent when
checkout.session.createdA checkout session was created.
checkout.session.completedPayment settled.
checkout.session.failedA confirmation attempt failed terminally.
checkout.session.cancelledThe session was cancelled.
checkout.session.expiredThe session's 24-hour window elapsed unpaid.
mandate.activatedYour customer approved a mandate. You may start charging it.
mandate.declinedYour customer refused the mandate's terms. It is terminal.
mandate.revokedA mandate was ended, by your customer, by you, or by Payer. Stop charging it.
mandate.charge.succeededA charge against a mandate settled.
mandate.charge.failedA charge against a mandate was refused. Nothing moved.

The delivery payload

Every delivery is a JSON envelope. The data.object is a snapshot of the checkout session at the moment the event fired:

checkout.session.completed
{
  "id": "evt_2f8c1a9b-7d6e-4f0a-b1c2-3d4e5f6a7b8c",
  "type": "checkout.session.completed",
  "api_version": "2026-07-01",
  "created": 1753600000,
  "data": {
    "object": {
      "id": "9b1c8f2e-3d4a-4f11-a0c2-1e2d3c4b5a69",
      "reference": "order_10482",
      "amount": 2490,
      "currency": "MVR",
      "status": "COMPLETED",
      "title": "Order #10482",
      "description": null,
      "payment": {
        "id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
        "completed_at": 1753600000,
        "fee": 62,
        "net_amount": 2428
      },
      "merchant": {
        "id": "42",
        "name": "Acme Store"
      }
    }
  }
}

Envelope fields

FieldTypeDescription
idstringEvent id, prefixed evt_. Stable across every endpoint that receives it, so use it to de-duplicate.
typestringThe event name from the table above.
api_versionstringThe payload schema version (2026-07-01).
createdintegerUnix timestamp of the event.
data.objectobjectThe snapshot the event is about: a checkout session, a mandate, or a mandate charge.

The session object

FieldTypeNotes
amountintegerIn laari (minor units) here, unlike the GraphQL query.
currencystringAlways MVR.
statusstringINITIATED · COMPLETED · CANCELLED · EXPIRED.
paymentobjectPresent only once the session is COMPLETED. Carries the payment id, completed_at, the fee charged (laari) and the net_amount settled to you (amount minus fee). fee and net_amount are null on sessions settled before fee reporting shipped.
merchantobjectThe receiving merchant's id (stringified) and name.

Note

A payload never contains tokens, secrets, wallet ids or card data. Treat it as a notification: fetch anything sensitive yourself, authenticated, using the ids it carries.

Mandate payloads

The Mandates API sends three shapes through the same envelope.

The mandate object

mandate.activated, mandate.declined and mandate.revoked carry the mandate as it stands at the moment the event fired:

mandate.activated
{
  "id": "evt_2f8c1a9b-7d6e-4f0a-b1c2-3d4e5f6a7b8c",
  "type": "mandate.activated",
  "api_version": "2026-07-01",
  "created": 1786694400,
  "data": {
    "object": {
      "id": "4a2f7c10-9e3b-4d55-8b21-7c0f5e6d4a33",
      "reference": "customer_10482",
      "status": "ACTIVE",
      "title": "Monthly gym membership",
      "description": "Billed on the 1st of each month",
      "currency": "MVR",
      "per_charge_cap": 45000,
      "period_cap": 45000,
      "period": "MONTH",
      "period_key": null,
      "period_charged": 0,
      "setup_expires_at": 1786752000,
      "expires_at": 1818288000,
      "approved_at": 1786694400,
      "revoked_at": null,
      "created_at": 1786694280,
      "merchant": {
        "id": "42",
        "name": "Acme Fitness"
      }
    }
  }
}
FieldTypeNotes
referencestringYour own reference for the mandate.
statusstringINITIATED · ACTIVE · DECLINED · REVOKED · EXPIRED · SUSPENDED.
per_charge_capintegerThe most you may take in one charge, in laari.
period_cap · periodinteger · stringThe most you may take across one period, and the calendar period it is measured over. Both null when the mandate is uncapped per period.
period_key · period_chargedstring · integerThe period being counted (2026-08) and the laari already taken in it. period_key is null until the first charge.
setup_expires_at · expires_atintegerUnix timestamps: when the approval link stops working, and when the mandate stops being chargeable.
approved_at · revoked_atintegerUnix timestamps, null until each happens.
created_atintegerUnix timestamp of when the mandate was created.
merchantobjectThe collecting merchant's id (stringified) and name.

The charge object

mandate.charge.succeeded carries the settled charge, plus the mandate's id and reference:

mandate.charge.succeeded
{
  "id": "evt_5b1c9d2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e",
  "type": "mandate.charge.succeeded",
  "api_version": "2026-07-01",
  "created": 1786694700,
  "data": {
    "object": {
      "id": "9c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
      "mandate_id": "4a2f7c10-9e3b-4d55-8b21-7c0f5e6d4a33",
      "amount": 45000,
      "currency": "MVR",
      "status": "SUCCEEDED",
      "description": "August membership",
      "reference": "invoice_2026_08",
      "fee": 900,
      "net_amount": 44100,
      "occurred_at": 1786694700,
      "mandate": {
        "id": "4a2f7c10-9e3b-4d55-8b21-7c0f5e6d4a33",
        "reference": "customer_10482"
      }
    }
  }
}

id is the payment id, the same id the charge shows under in your customer's wallet history. amount, fee and net_amount are in laari. The charge's own Unix timestamp is occurred_at, the same key the failed-charge object uses; the envelope's created is when the event fired, not when the charge did.

The failed-charge object

mandate.charge.failed carries the whole record of the attempt. No money moved, nothing is held, and nothing will be retried on our side:

mandate.charge.failed
{
  "id": "evt_7d3e5f0a-1b2c-4d5e-8f9a-0b1c2d3e4f5a",
  "type": "mandate.charge.failed",
  "api_version": "2026-07-01",
  "created": 1786694760,
  "data": {
    "object": {
      "mandate_id": "4a2f7c10-9e3b-4d55-8b21-7c0f5e6d4a33",
      "amount": 45000,
      "currency": "MVR",
      "code": "INSUFFICIENT_FUNDS",
      "message": "The customer does not have enough balance for this charge.",
      "reference": "invoice_2026_08",
      "occurred_at": 1786694760,
      "mandate": {
        "id": "4a2f7c10-9e3b-4d55-8b21-7c0f5e6d4a33",
        "reference": "customer_10482",
        "status": "ACTIVE"
      }
    }
  }
}

code is one of MANDATE_INACTIVE, MANDATE_EXPIRED, PER_CHARGE_CAP_EXCEEDED, PERIOD_CAP_EXCEEDED, MANDATE_CHARGE_AMOUNT_INVALID, INSUFFICIENT_FUNDS, WALLET_LIMIT_EXCEEDED, SERVICE_UNAVAILABLE or MANDATE_CHARGE_FAILED, and message is its human-readable twin. The nested mandate.status is what tells you whether retrying is worth anything: a REVOKED or EXPIRED mandate will never accept another charge. See When a charge is refused for what each code means.

Delivery headers

HeaderExampleDescription
Payer-Signaturet=1753600000, v1=8f4e2c1a…Timestamped HMAC. See below.
Payer-Event-Idevt_2f8c1a9b-…Same id as id in the body.
Payer-Event-Typecheckout.session.completedSame as type in the body.
User-AgentPayer-Webhooks/1.0Fixed.

Verifying signatures

Each endpoint has its own signing secret: a whsec_ value shown once when you register the endpoint or rotate the secret, and never retrievable again. Payer signs every delivery with it and sets the Payer-Signature header:

Payer-Signature header
Payer-Signature: t=1753600000, v1=8f4e2c1a9b7d6e5f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f

The header carries a Unix timestamp t and a signature v1, the hex HMAC-SHA256 of ${t}.${raw_body} keyed with your signing secret. To verify:

  1. 1.Recompute the HMAC over the raw request body, before any JSON parsing, over the exact bytes Payer sent.
  2. 2.Compare it to v1 in constant time.
  3. 3.Reject deliveries whose t is more than five minutes from now, to blunt replays.

During a secret rotation the header may carry more than one v1 value, and a match against any one is valid.

Verify a delivery (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300; // reject deliveries older than 5 minutes

/**
 * Verify a Payer-Signature header against the raw request body.
 * Header shape: t=<unix>, v1=<hex>[, v1=<hex>...].
 * Verify before JSON.parse, over the exact bytes Payer sent.
 */
export function verifyPayerSignature(rawBody, header, secret) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const [key, value] = part.trim().split("=");
    if (key === "t") timestamp = Number(value);
    else if (key === "v1" && value) signatures.push(value);
  }
  if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected);

  // Accept a match against any v1 entry (constant-time comparison).
  return signatures.some((sig) => {
    const b = Buffer.from(sig);
    return a.length === b.length && timingSafeEqual(a, b);
  });
}

Responding, retries and replay

Return any 2xx status to acknowledge a delivery. Anything else, or a timeout past 10 seconds, is treated as a failure and retried.

  • Payer retries with a growing backoff: after 1, 5 and 15 minutes, then 1, 3, 6 and 12 hours, up to 8 attempts spanning roughly a day.
  • Because retries and de-duplication are real, your handler must be idempotent: key on the evt_ id and ignore one you've already processed.
  • Every attempt is recorded in the delivery log on the Developers page, with the response status and body snippet. You can resend any delivery from there; it's re-signed and re-sent verbatim.

Tip

Acknowledge fast, work later. Verify the signature, enqueue the event id for background processing, and return 200 immediately. Don't do slow work inside the request Payer is timing.

Managing endpoints

From the dashboard you can register an endpoint, choose its events, enable or disable it without losing its configuration, rotate its signing secret, and delete it.

SettingTypeNotes
urlstring (https)Must start with https://. Max 2048 characters.
eventsarrayOne or more event types from the catalogue.
descriptionstringA label to recognise the endpoint.
environmentlive · testWhich environment's events it receives. Set once.

Rotating a secret issues a new whsec_ immediately; update your endpoint before the old one stops verifying. Deleting an endpoint revokes its secret at once.