Guide
Webhooks
Signed event notifications, signature verification with a worked example, retries and recovery.
Moja Switch sends an HTTP POST to your endpoint when a payment or a tenant
payout changes state. A webhook is a notification. GET /payments/{payment_id}
and GET /payouts/{payout_id} are the source of truth.
sequenceDiagram
autonumber
participant MS as Moja Switch
participant EP as Your endpoint
participant API as Moja Switch API
MS->>EP: POST event (Moja-Switch-Signature, Moja-Switch-Event-Id)
alt 2xx within 30 s
EP-->>MS: 200
else non-2xx, timeout or connection error
MS->>EP: Retry: same event id and body, fresh signature
end
Note over EP: Verify, deduplicate on event id, store, then process
EP->>API: GET /payments/{payment_id} (authoritative state)Your endpoint
- One endpoint per environment, each with its own signing secret. A sandbox secret never signs a production delivery.
- The URL must be
https, on a fully qualified public host. Hosts that resolve to private, loopback, link-local or other internal addresses are refused, at registration and again at connection time. - Registration is done by Moja Switch today. Send us your URL. Self-service sandbox registration in the Partner Console is Planned.
- The signing secret (
whsec_+ 64 hex characters) is generated by Moja Switch and shown once. Registering again replaces the URL and rotates the secret immediately; there is no overlap window. To rotate without dropping deliveries, make your receiver accept either of two secrets during the change.
The request
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: MojaSwitch-Webhook/1.0
Moja-Switch-Event-Id: evt_3f9a1c2b7d4e5f60718293a4b5c6d7e8
Moja-Switch-Event-Type: payment.completed
Moja-Switch-Signature: t=1794139205,v1=c0b0f2d74c04a0b85745fc53701fba6577c0f84b8e91c4435e56db79e0bf3a9e{
"id": "evt_3f9a1c2b7d4e5f60718293a4b5c6d7e8",
"type": "payment.completed",
"created_at": "2026-11-08T12:00:05Z",
"data": {
"object": "payment",
"id": "pay_example123",
"status": "COMPLETED",
"corridor": "CA-TZ",
"reference": "partner-tx-12345",
"send_amount": { "value": "100.00", "currency": "CAD" },
"completed_at": "2026-11-08T12:00:05Z"
}
}receive_amount,fx_rate_idandcompleted_atappear only when they have a value.created_atis when the notification was built, not when the payment changed state. Use the payment's own timestamps.
Event types
| Payments (transfers and collections) | Tenant payouts (collections, sandbox) |
|---|---|
payment.created |
payout.created |
payment.compliance_passed |
payout.sent |
payment.collected |
payout.paid |
payment.payout_sent |
payout.failed |
payment.completed |
payout.returned |
payment.failed |
payout.outcome_unknown (reserved) |
payment.cancelled |
You receive every type; per-event subscriptions are Planned.
payout.* events carry data.object: "payout" with id, status, currency,
net_amount, cycle and attempt; member detail is on GET /payouts/{payout_id}.
Webhook data.status can carry collection statuses, such as COLLECTED, that
GET /payments/{payment_id} does not emit yet (it reads PROCESSING). See
Collections. Treat an unrecognised status as "in progress".
Verify the signature
Moja-Switch-Signature: t=<unix seconds>,v1=<hex>
v1 = hex( HMAC-SHA256( key = secret, message = t + "." + raw_body ) )- Use the raw body bytes, before any JSON parsing. Re-serialised JSON will not match.
- The key is the full secret string, including
whsec_, as UTF-8 bytes. Do not hex-decode it. - Compare in constant time.
- Reject a
tmore than 5 minutes from your clock. Every attempt, including every retry, is signed with a fresht, so a genuine retry always passes.
Worked example
The values below were produced by the platform's own signing function. The secret is a test value.
| Input | Value |
|---|---|
| secret | whsec_test_do_not_use_in_production |
t |
1794139205 |
| raw body | the JSON above, minified (no spaces or newlines) |
expected v1 |
c0b0f2d74c04a0b85745fc53701fba6577c0f84b8e91c4435e56db79e0bf3a9e |
Check it from a shell:
printf '%s' '1794139205.{"id":"evt_3f9a1c2b7d4e5f60718293a4b5c6d7e8","type":"payment.completed","created_at":"2026-11-08T12:00:05Z","data":{"object":"payment","id":"pay_example123","status":"COMPLETED","corridor":"CA-TZ","reference":"partner-tx-12345","send_amount":{"value":"100.00","currency":"CAD"},"completed_at":"2026-11-08T12:00:05Z"}}' \
| openssl dgst -sha256 -hmac 'whsec_test_do_not_use_in_production'
# SHA2-256(stdin)= c0b0f2d74c04a0b85745fc53701fba6577c0f84b8e91c4435e56db79e0bf3a9eNode.js:
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(signatureHeader, rawBody, secret, nowSeconds = Date.now() / 1000) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2)).filter((kv) => kv.length === 2),
);
const { t, v1 } = parts;
if (!t || !v1 || !/^\d+$/.test(t)) return false;
if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(v1);
return a.length === b.length && timingSafeEqual(a, b);
}Python:
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify(signature_header: str, raw_body: bytes, secret: str, now: float | None = None) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or not t.isdigit():
return False
if abs((time.time() if now is None else now) - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Both functions return true for the worked example when the clock is set to t
(nowSeconds = 1794139205), and false if a single byte of the body changes.
Retries
A delivery succeeds when your endpoint returns any 2xx within 30 seconds.
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | About 1 minute after attempt 1 failed |
| 3 | About 15 minutes after attempt 2 |
| 4 | About 2 hours after attempt 3 |
| 5 | About 8 hours after attempt 4. Last attempt. |
After attempt 5 the event is not sent again. Redirects are not followed: a 3xx
counts as a failure. Return 2xx as soon as you have verified and stored the
event, and do the work afterwards.
Duplicates, ordering and recovery
- Delivery is at least once. Deduplicate on
Moja-Switch-Event-Id(equal to the bodyid). - Order is not guaranteed. Never move your record backwards because of a webhook.
- Webhooks can be missed. For every payment you have not seen reach a terminal
status (
COMPLETED,FAILED,CANCELLED) in your expected window, readGET /payments/{payment_id}and apply the result as you would a webhook.GET /paymentslists your payments newest first, filterable bystatusand creation time. - In the sandbox you can re-deliver an event to test your duplicate handling:
POST /sandbox/webhooks/{event_id}/redeliver. - A replay endpoint for production is Planned.
