WebhooksOverview

Webhooks

Webhooks are how Soxara tells you a payment settled — asynchronously, server to server, so you never trust the client or sit on a polling loop.

You register an endpoint URL with us. We POST a signed JSON payload to it. You verify it and return 200.

When to use webhooks

  • Your customer paid via the hosted checkout — mark their order paid when payment.completed arrives, not when the browser says so.
  • A recurring mandate charge settled (or failed) — roll the subscription forward, or start dunning.
  • A pending MoMo payment finally cleared minutes later — react the moment it does.

If you’re polling GET /v1/api/payments/{id} every few seconds, you want a webhook instead. (That read endpoint is for reconciliation / a missed delivery, not a polling loop.)

Registering an endpoint

Register with the same API key you pay with — payments:create scope, no dashboard needed:

curl -X POST $SOXARA_BASE/v1/api/webhook-endpoints \
  -H "Authorization: Bearer $SOXARA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/webhooks/soxara",
    "events": ["payment.completed", "payment.failed"]
  }'

The response includes the signing secret for this endpoint — returned once, at creation:

{
  "success": true,
  "data": {
    "endpoint": {
      "id": "…",
      "url": "https://your-app.example.com/webhooks/soxara",
      "events": ["payment.completed", "payment.failed"],
      "secret_hint": "…AbCd",
      "created_at": "2026-07-04T14:00:00Z"
    },
    "secret": "KciRV7ED9…"        // ← store this now; never shown again
  }
}

Save secret immediately. You verify every delivery with it — see Signature verification. Later reads (GET /v1/api/webhook-endpoints) only return secret_hint, never the secret.

Prefer clicking to curling? The same thing lives in the Soxara dashboard under Developers → Webhooks. You can register multiple endpoints (e.g. a test URL and a prod URL) — each gets its own secret. Revoke with DELETE /v1/api/webhook-endpoints/{id}.

Allowed events today: payment.completed, payment.failed (see Event reference).

Payload

Every delivery has the same shape:

{
  "type": "payment.completed",
  "createdAt": "2026-07-04T14:05:33Z",
  "data": {
    "payment": {
      "id": "…", "amount": 1200, "currency": "USD", "status": "completed",
      "payment_method": "card", "environment": "live",
      "mandate_id": "…", "payment_link_id": null,
      "idempotency_key": "sub_42:2026-07", "created_at": "…"
    },
    "link": {  }                  // present only for a link payment
  }
}
FieldPurpose
typeEvent name — payment.completed or payment.failed.
createdAtISO-8601 UTC when the event was generated (not delivery time).
data.paymentThe payment — same shape as GET /v1/api/payments/{id}.
data.linkA snapshot of the payment link, when the payment is link-bound.

Alongside the body, each request carries three headers:

HeaderPurpose
Soxara-Signaturet=<unix>,v1=<hmac>verify this.
Soxara-EventThe event type (matches type).
Soxara-DeliveryA stable id for this delivery — dedupe on it (it’s the same across retries of one event).

Delivery semantics

  • At-least-once. The same event may arrive more than once (a retry after your server was slow, say). Dedupe on the Soxara-Delivery header — or make your handler idempotent on data.payment.idempotency_key.
  • Respond quickly. Return 2xx within ~10 seconds. Anything else is treated as a failure and retried.
  • Retry schedule: up to 7 attempts with exponential backoff — 1s, 5s, 25s, 2m, 10m, 1h, 6h (~10 hours total). After that the delivery is dead-lettered; you can force a redeliver from the dashboard, or re-pull with GET /v1/api/payments/{id}.

What your handler should do

1. Read the request body as raw bytes (don't parse JSON yet).
2. Verify the `Soxara-Signature` header against the secret you stored at
   registration. Reject anything that doesn't verify. (See /webhooks/signature.)
3. Parse the JSON.
4. Check `Soxara-Delivery` — if you've seen it, return 200 and stop.
5. Do the minimum to acknowledge (enqueue a job / write a row), return 200.
6. Do the heavy lifting asynchronously, outside the handler.

See the Handle a webhook guide for a working Node + Python pattern.