Skip to main content
You can register a partner webhook URL so the request-money API delivers a signed HTTP callback every time one of your operations is created, updated, or fails. Webhooks are persisted, retried on failure, and signed with HMAC-SHA256 so you can verify authenticity before reacting.

When events fire

EventTrigger
operation_createdA successful POST /v1/request-money that persists a remittance for your partner.
operation_updatedA successful internal status update (PATCH /v1/internal/remittances/status) for one of your remittances.
operation_errorAny error raised while handling POST /v1/request-money or the internal status update for your partner — validation failures, OFAC screening blocks, downstream identity-validator outages, missing remittances, etc.
operation_error events are emitted in addition to whatever HTTP error your client receives synchronously, so background systems that only consume webhooks can still react to failed flows.

Configuring the endpoint

1

Pick an HTTPS URL

The endpoint must speak HTTPS and accept POST with a JSON body. Plain http:// values are rejected by the partner portal.
2

Open the partner portal

Sign in and go to Settings → Webhooks.
3

Save the URL and a signing secret

Paste your endpoint into Webhook URL. Generate a high-entropy secret (at least 8 characters) and paste it into Webhook secret. The portal stores the secret encrypted; rotating it later is a matter of overwriting the value or clearing it.
4

Confirm by triggering an event

Run a Create request-money call from the playground or your own backend. You should see an operation_created delivery hit your endpoint within seconds.
If you leave Webhook URL empty, no events are queued for your partner. Existing pending events still attempt delivery against the URL that was active when they were enqueued.

Payload envelope

Every event uses the same envelope. The data object’s shape depends on event — see Events catalog.
{
  "event": "operation_created",
  "data": {
    "partnerId": "65f8...",
    "referenceId": "Ab3xY9mK",
    "id": "Ab3xY9mK",
    "userReferenceId": "user-8842",
    "amount": 150.5,
    "currency": "GTQ",
    "status": "pending",
    "statusDetails": "The operation is pending because the sender has not yet started the process.",
    "waLink": "https://wa.me/...",
    "landingLink": "https://landing.example.com/...",
    "requestData": { "...": "..." },
    "createdAt": "2026-05-06T10:00:00.000Z",
    "updatedAt": "2026-05-06T10:00:00.000Z"
  },
  "timestamp": "2026-05-06T10:00:00.000Z"
}
FieldTypeDescription
eventstringOne of operation_created, operation_updated, operation_error.
dataobjectEvent-specific payload. See Events catalog.
timestampstringISO 8601 UTC instant the event was enqueued. Frozen at enqueue time, not updated on retries.

Headers

Every delivery sets these headers:
HeaderPurpose
Content-TypeAlways application/json.
X-Webhook-EventMirror of payload.event so you can route on a header before parsing JSON.
X-Webhook-Delivery-IdStable UUID per event. Identical across retries — use it for idempotency.
X-Webhook-SignatureHMAC-SHA256 signature in the form t=<unix>,v1=<hex>. Present whenever a webhook secret is configured.

Verifying the signature

The X-Webhook-Signature header is computed as:
v1 = hex(HMAC_SHA256(secret, "{t}.{rawBody}"))
Where t is the Unix timestamp (seconds) the dispatcher signed the request, secret is the value you configured in Settings → Webhooks, and rawBody is the exact bytes of the JSON payload as received (do not pretty-print or re-serialize before hashing).
import crypto from "node:crypto";

function verifyWebhook(rawBody, headerValue, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    headerValue.split(",").map((kv) => kv.split("=", 2)),
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!Number.isFinite(t) || !v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Tolerate a clock skew of about five minutes when comparing t with your local time. Outside that window, treat the request as a replay.

Delivery, retries, and dead-lettering

  • The API persists every event in an outbox before the source request returns to the client, so you do not miss callbacks if your endpoint is briefly unavailable.
  • A worker drains the outbox every 30 seconds and POSTs each due event with a 10-second timeout.
  • Any non-2xx response, network failure, or timeout schedules a retry with exponential backoff: 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours.
  • After 5 failed attempts the event moves to a dead-letter state and is no longer retried automatically. Operators can replay it from the database; partners receive no further deliveries for that event.
  • Successful deliveries (any 2xx) finalize the event and remove it from the active queue.

Idempotency

Use X-Webhook-Delivery-Id to deduplicate. The same deliveryId can be retried up to five times — your handler must be safe to invoke more than once for the same id (for example, by storing processed ids and short-circuiting).

Configuration freezing

When an event is enqueued, the dispatcher captures the partner’s webhookUrl and webhookSecret at that exact moment. Changing the configuration afterward only affects events enqueued after the change; in-flight retries continue to target the URL and signature key that were active when the source operation occurred. This keeps replays consistent and avoids signing old payloads with new secrets.

Troubleshooting

SymptomLikely cause
No events arrivewebhookUrl is empty in Settings → Webhooks, or the URL is not HTTPS.
Receive 401/403 from your endpoint then no more retries after a few hoursThe dispatcher exhausted the 5 retries; the event is in dead_letter.
Signature does not matchBody was re-serialized before hashing. Always verify against the raw bytes received.
Duplicate processing on your sideX-Webhook-Delivery-Id was not used for deduplication; retries reuse the same id.