Browse documentation

APIs

Webhooks

Let Payer come to you. Register a signed HTTPS endpoint and get a POST the moment a checkout session, a mandate or a subscription 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, a mandate or a subscription 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.
subscription.activatedA subscription's first payment settled. It is ACTIVE.
invoice.paidA subscription billing cycle settled, the first one included.
invoice.payment_failedA charge attempt on a cycle missed. Payer will retry it.
subscription.past_dueA subscription entered its retry window. Sent once per window.
subscription.canceledA subscription ended. cancellation_initiator says who ended it.

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,
      "mode": "PAYMENT",
      "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, a mandate charge, a subscription, or a subscription invoice.

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.
modestringPAYMENT or SUBSCRIPTION.
subscriptionobjectPresent only on a paid SUBSCRIPTION session: the id of the subscription the payment created.
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.

Subscription payloads

The Subscriptions API sends two shapes through the same envelope: the subscription itself, and one billing cycle of it.

The subscription object

subscription.activated, subscription.past_due and subscription.canceled carry the subscription as it stands at the moment the event fired:

subscription.activated
{
  "id": "evt_9a0b1c2d-3e4f-4a5b-8c9d-0e1f2a3b4c5d",
  "type": "subscription.activated",
  "api_version": "2026-07-01",
  "created": 1786694400,
  "data": {
    "object": {
      "id": "1f8a3c22-5e7d-4b90-9a11-6c2e0d4f7b35",
      "reference": "sub_customer_10482",
      "status": "ACTIVE",
      "plan_name": "Pro Monthly",
      "amount": 15000,
      "currency": "MVR",
      "interval": "MONTH",
      "interval_count": 1,
      "checkout_session_id": "9b1c8f2e-3d4a-4f11-a0c2-1e2d3c4b5a69",
      "billing_cycle_anchor": 1786694400,
      "current_period_start": 1786694400,
      "current_period_end": 1789372800,
      "next_billing_at": 1789372800,
      "cancel_at_period_end": false,
      "cancelled_at": null,
      "cancellation_initiator": null,
      "metadata": null,
      "created_at": 1786694400,
      "merchant": {
        "id": "42",
        "name": "Acme Software"
      }
    }
  }
}
FieldTypeNotes
referencestringYour own reference, inherited from the checkout session that created it.
statusstringACTIVE · PAST_DUE · CANCELLED.
plan_name · amount · interval · interval_countThe plan as consented, frozen. amount is in laari, charged every cycle.
checkout_session_idstring (uuid)The session whose first payment created the subscription.
billing_cycle_anchorintegerUnix timestamp of consent. Every period derives from it.
current_period_start · current_period_endintegerUnix timestamps of the cycle being served.
next_billing_atintegerUnix timestamp of the next renewal, null once the subscription ends.
cancel_at_period_endbooleantrue when it is set to end at current_period_end with no further charge.
cancelled_at · cancellation_initiatorinteger · stringWhen it ended, and who ended it: PAYER, MERCHANT, DUNNING or OPS.
merchantobjectThe collecting merchant's id (stringified) and name.

cancellation_initiator is the field to branch on when subscription.canceled arrives. PAYER means your customer ended it in the Payer app, DUNNING means every retry on a cycle missed, and MERCHANT means your own cancel call.

The invoice object

invoice.paid and invoice.payment_failed carry one billing cycle, with its subscription's id and reference embedded so you can correlate without a second fetch:

invoice.payment_failed
{
  "id": "evt_1c2d3e4f-5a6b-4c7d-9e8f-0a1b2c3d4e5f",
  "type": "invoice.payment_failed",
  "api_version": "2026-07-01",
  "created": 1789372860,
  "data": {
    "object": {
      "id": "b3d9e1f4-8c27-4a56-90bd-2f1a7c6e5d40",
      "subscription_id": "1f8a3c22-5e7d-4b90-9a11-6c2e0d4f7b35",
      "subscription_reference": "sub_customer_10482",
      "period_key": "2026-09-14T18:30:00+05:00",
      "period_start": 1789372800,
      "period_end": 1792051200,
      "amount": 15000,
      "currency": "MVR",
      "status": "PENDING",
      "attempt_count": 1,
      "next_retry_at": 1789416000,
      "failure_reason": "insufficient-balance",
      "payment_intent_id": null,
      "paid_at": null,
      "created_at": 1789372800
    }
  }
}
FieldTypeNotes
subscription_id · subscription_referencestringThe parent subscription and your own reference for it.
period_keystringStable identifier of the cycle, derived from its start in Maldives time. A cycle is charged exactly once against it, so it is a good de-duplication key on your side.
period_start · period_endintegerUnix timestamps of the cycle this invoice covers.
amountintegerIn laari.
statusstringPENDING · CHARGING · PAID · FAILED · VOID.
attempt_countintegerAttempts taken, including the successful one.
next_retry_atintegerUnix timestamp of the next automatic retry. null outside the retry window, which also tells you the retries are done.
failure_reasonstringWhy the last attempt missed: insufficient-balance, blocked-by-rules, wallet-frozen or executor-error.
payment_intent_id · paid_atstring (uuid) · integerThe payment that settled the cycle and when, once paid.

A PENDING invoice carrying a next_retry_at is still being retried, so nothing is decided yet. A FAILED one is terminal, and a subscription.canceled with cancellation_initiator: "DUNNING" follows it. See When a renewal fails.

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.