Moja SwitchDocs Sandbox live

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

http
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
json
{
  "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_id and completed_at appear only when they have a value.
  • created_at is 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

text
Moja-Switch-Signature: t=<unix seconds>,v1=<hex>
v1 = hex( HMAC-SHA256( key = secret, message = t + "." + raw_body ) )
  1. Use the raw body bytes, before any JSON parsing. Re-serialised JSON will not match.
  2. The key is the full secret string, including whsec_, as UTF-8 bytes. Do not hex-decode it.
  3. Compare in constant time.
  4. Reject a t more than 5 minutes from your clock. Every attempt, including every retry, is signed with a fresh t, 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:

bash
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)= c0b0f2d74c04a0b85745fc53701fba6577c0f84b8e91c4435e56db79e0bf3a9e

Node.js:

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:

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 body id).
  • 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, read GET /payments/{payment_id} and apply the result as you would a webhook. GET /payments lists your payments newest first, filterable by status and 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.