This chapter is the canonical reference for Payment Gateway callbacks (also called webhooks or notifications). Callbacks are how the gateway tells your server about state changes that do not surface in the synchronous response to a POST /deposit or POST /withdrawal call: an order matching, completing, expiring, or being rejected upstream. If you do not implement a callback handler, you must poll the query endpoints to learn the final state of every order.
To integrate with callbacks you must:
- Register a per-order
callback_urlwhen you create the order. - Implement an HTTPS endpoint on that URL that accepts
POSTwith a JSON body. - Verify the
X-Signatureheader on every request before trusting the body. - Return
2xxto acknowledge, or5xx(or time out) to request a retry. - Make the handler idempotent by
(order_no, status).
The signing protocol is identical to the one used on inbound merchant requests. See Authentication & Signing for the algorithm and reference implementations.
Concepts and terminology
| Term | Meaning |
|---|---|
| Callback | A signed POST from Payment Gateway to a merchant-controlled URL, carrying a JSON event body. Server-to-server only. |
callback_url | The merchant-supplied target URL, set per order when calling POST /deposit or POST /withdrawal. Must be HTTPS in production. |
| NotificationLog | The gateway-side row that tracks one callback dispatch attempt sequence. Stores URL, request body, response status, response body, attempt count, and current NotificationStatus. |
NotificationType | The event identifier that drives which payload shape is sent. See the event table below. |
| Ack | A 2xx response from the merchant endpoint. Marks the NotificationLog as SUCCESS and stops further dispatch. |
| Retry queue | The Celery task send_webhook_notification plus the periodic retry_failed_notifications sweep. Together they implement the fixed-interval retry policy described in Retry semantics. |
| Idempotency | Your handler may receive the same callback more than once. Deduplicate on (order_no, status) before applying side effects. |
| Reconciliation | When all dispatch attempts fail, your local state diverges from the gateway. You recover by polling the /query/* endpoints. |
When does a callback fire?
A callback is enqueued whenever an order or platform event reaches a state listed below. The exact notification_type values are stable and come from the NotificationType enum in the backend.
| Trigger | notification_type | Carrying event field | Order type |
|---|---|---|---|
| Deposit matched to a bank transaction (channel-bound account orders) | deposit_matched | deposit.matched | deposit |
| Deposit fully completed and credited to merchant balance | deposit_completed | deposit.completed | deposit |
| Deposit expired before payment was matched | deposit_expired | deposit.expired | deposit |
| Deposit cancelled by operator action | deposit_cancelled | deposit.cancelled (status cancelled) | deposit |
| Withdrawal approved by operator and queued for upstream processing | withdrawal_approved | withdrawal.approved | withdrawal |
| Withdrawal settled successfully by upstream channel | withdrawal_completed | withdrawal.completed | withdrawal |
| Withdrawal rejected by operator before dispatch | withdrawal_rejected | withdrawal.rejected | withdrawal |
| Withdrawal failed at upstream channel after dispatch | withdrawal_failed | withdrawal.failed | withdrawal |
| Tenant owner invite link issued | tenant_owner_invite | platform-level, not order-bound | n/a |
| Billing statement issued for the merchant | billing_statement_issued | platform-level, not order-bound | n/a |
The merchant API surface mostly cares about the deposit and withdrawal events. tenant_owner_invite and billing_statement_issued are sent to platform-managed addresses, not to merchant callback_urls.
A callback fires only when the order has a non-null callback_url. If you create a deposit or withdrawal without callback_url, no NotificationLog row is created and you are responsible for polling state yourself.
Callback request shape
All callbacks are HTTP POST to your callback_url with the following:
| Aspect | Value |
|---|---|
| Method | POST |
Content-Type | application/json |
X-API-Key | Your merchant API key. Same key issued to you for inbound calls. |
X-Timestamp | Unix epoch seconds at dispatch time. |
X-Signature | Lowercase-hex MD5 over the sorted JSON body parameters plus your API key. |
| Body | A JSON object whose fields depend on notification_type. See examples below. |
| Connection timeout | 10 seconds by default (Merchant.config.callback_timeout_seconds). Slow handlers count as a failure and trigger retry. |
| TLS | Gateway verifies the merchant TLS certificate. Self-signed certificates are rejected in production. |
Headers
The headers carry the same auth semantics as inbound requests, with the direction reversed: you (the merchant) are now the receiver, but the API key used for signing is still yours.
POST /your/callback/path HTTP/1.1
Host: callbacks.your-domain.tld
Content-Type: application/json
X-API-Key: mk_live_abc123...
X-Timestamp: 1747756800
X-Signature: 7f3c2b1a0d9e8f4c6a5b...The signature is computed over the body fields (the same JSON the gateway is sending you), sorted by key, joined as k=v&..., then concatenated with the API key. There is no separate header set to sign — every value the gateway needs you to trust is in the body. See Verifying the signature below.
Body — deposit completed
{
"event": "deposit.completed",
"order_no": "D202605200000001",
"merchant_order_no": "MO-20260520-0001",
"amount": "1000.00",
"actual_amount": "1000.00",
"currency": "THB",
"status": "completed",
"matched_at": "2026-05-20T08:14:32.123456+00:00",
"completed_at": "2026-05-20T08:14:33.456789+00:00"
}| Field | Type | Notes |
|---|---|---|
event | string | Dotted form of notification_type. Use this for branching, not message text. |
order_no | string | Gateway-assigned order number. Stable identifier. |
merchant_order_no | string | The identifier you supplied at creation. Use for idempotency lookup. |
amount | string | Requested amount as a decimal string. |
actual_amount | string | Settled amount. May differ from amount for float-matched bank transfers. |
currency | string | ISO 4217 currency code (for example, THB). |
status | string | Order status at dispatch time. For deposit.completed this is "completed". |
matched_at | ISO 8601 string | null | When the gateway matched the order. |
completed_at | ISO 8601 string | null | When the order finished settling. |
Body — deposit matched
Same shape as deposit.completed, with status: "matched" and completed_at not yet set. Sent only when the order routes through a channel that reports MATCHED before COMPLETED (typically channel-bound account orders).
Body — deposit expired
{
"event": "deposit.expired",
"order_no": "D202605200000002",
"merchant_order_no": "MO-20260520-0002",
"amount": "1000.00",
"actual_amount": "0.0000",
"currency": "THB",
"status": "expired",
"expired_at": "2026-05-20T08:30:00.000000+00:00"
}actual_amount is always present as a string. For expired deposits it is "0.0000" because no funds were settled.
Body — deposit cancelled
{
"event": "deposit.cancelled",
"order_no": "D202605200000003",
"merchant_order_no": "MO-20260520-0003",
"status": "cancelled",
"amount": "1000.00",
"actual_amount": "0.0000",
"currency": "THB",
"currency_code": "THB",
"cancelled_at": "2026-05-20T09:00:00.000000+00:00"
}actual_amount is always present as a string. For cancelled deposits it is "0.0000" because no funds were settled.
Note: currency_code is kept as a deprecated compatibility alias on deposit.cancelled. Prefer the canonical currency field, which matches the other deposit callbacks.
Body — withdrawal events
Withdrawal callbacks follow the same envelope conventions as deposits. The exact field set varies by notification_type; always branch on the event field and treat unknown extra fields as forward-compatible additions.
The fields you can rely on for every withdrawal callback are: order_no, merchant_order_no, status, amount, currency (or currency_code), and a timestamp matching the event (approved_at, completed_at, rejected_at, or failed_at). Event-specific fields are documented in the Withdrawals chapter.
Flow
The diagram below traces a single callback from dispatch to terminal state. The retry loop is fixed-interval, not exponential.
Two practical notes:
- The
Celery workerandRetry sweepare separate tasks. The worker handles the inline retry viaself.retry(countdown=30). The sweep is a safety net that picks up rows the worker dropped (process restart, crash, orSENDINGrows stuck for more than 5 minutes). - The HTTP body and signature are byte-identical across retries. The
X-Timestampheader is recomputed at each dispatch.
Verifying the signature
Use the same reference samples that ship with the Authentication chapter. They are byte-for-byte parity-tested against the backend's verify_md5_signature implementation. CI fails if any of the four samples diverges.
#!/usr/bin/env bash
# Verify MD5 signature for Payment Gateway merchant callbacks (bash reference).
#
# Usage:
# ./verify.sh --api-key <key> --params 'k1=v1&k2=v2&...' --signature <sig>
#
# Output:
# stdout: "valid" or "invalid"
# exit code: 0 if valid, 1 if invalid
set -euo pipefail
API_KEY=""
PARAMS=""
SIGNATURE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--api-key) API_KEY="$2"; shift 2 ;;
--params) PARAMS="$2"; shift 2 ;;
--signature) SIGNATURE="$2"; shift 2 ;;
*) echo "Unknown flag: $1" >&2; exit 2 ;;
esac
done
# Recompute the expected signature using the same algorithm as sign.sh
SORTED=$(printf '%s' "$PARAMS" | tr '&' '\n' | LC_ALL=C sort | grep -v '^$' | tr '\n' '&' | sed 's/&$//')
SIGN_STRING="${SORTED}${API_KEY}"
if command -v md5sum >/dev/null 2>&1; then
EXPECTED=$(printf '%s' "$SIGN_STRING" | md5sum | cut -d' ' -f1)
else
EXPECTED=$(printf '%s' "$SIGN_STRING" | md5)
fi
# Lowercase normalisation for case-insensitive comparison
EXPECTED_LC=$(printf '%s' "$EXPECTED" | tr '[:upper:]' '[:lower:]')
PROVIDED_LC=$(printf '%s' "$SIGNATURE" | tr '[:upper:]' '[:lower:]')
if [[ "$EXPECTED_LC" == "$PROVIDED_LC" ]]; then
echo "valid"
exit 0
else
echo "invalid"
exit 1
fi#!/usr/bin/env python3
"""Reference implementation: verify MD5 signature for Payment Gateway merchant callbacks.
Usage:
python verify.py --api-key <key> --params 'k1=v1&k2=v2&...' --signature <sig>
Output:
stdout: "valid" or "invalid"
exit code: 0 if valid, 1 if invalid
Algorithm:
1. Recompute the expected signature using the same algorithm as sign.py.
2. Compare against the provided signature using a constant-time comparison.
"""
import argparse
import hashlib
import hmac
import sys
from urllib.parse import parse_qsl
def create_signature(params: dict[str, str], api_key: str) -> str:
sorted_items = sorted(params.items())
query_string = "&".join(f"{k}={v}" for k, v in sorted_items if v is not None)
return hashlib.md5((query_string + api_key).encode("utf-8")).hexdigest()
def verify(params: dict[str, str], api_key: str, signature: str) -> bool:
expected = create_signature(params, api_key)
return hmac.compare_digest(expected.lower(), signature.lower())
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--api-key", required=True)
parser.add_argument("--params", required=True)
parser.add_argument("--signature", required=True)
args = parser.parse_args()
params = dict(parse_qsl(args.params, keep_blank_values=True))
if verify(params, args.api_key, args.signature):
print("valid")
return 0
print("invalid")
return 1
if __name__ == "__main__":
sys.exit(main())<?php
/**
* Verify MD5 signature for Payment Gateway merchant callbacks (PHP reference).
*
* Usage:
* php verify.php --api-key=<key> --params='k=v&...' --signature=<sig>
*
* Output:
* stdout: "valid" or "invalid"
* exit code: 0 if valid, 1 if invalid
*
* Note: getopt() requires `--flag=value` form (no space between flag and value).
*/
$opts = getopt('', ['api-key:', 'params:', 'signature:']);
if (!isset($opts['api-key'], $opts['params'], $opts['signature'])) {
fwrite(STDERR, "Usage: php verify.php --api-key=<key> --params='k=v&...' --signature=<sig>\n");
exit(2);
}
parse_str($opts['params'], $params);
ksort($params, SORT_STRING);
$parts = [];
foreach ($params as $k => $v) {
if ($v === null) continue;
$parts[] = "$k=$v";
}
$expected = md5(implode('&', $parts) . $opts['api-key']);
if (hash_equals(strtolower($expected), strtolower($opts['signature']))) {
echo "valid";
exit(0);
}
echo "invalid";
exit(1);#!/usr/bin/env node
/**
* Verify MD5 signature for Payment Gateway merchant callbacks (Node.js / TypeScript reference).
*
* Usage:
* tsx verify.ts --api-key <key> --params 'k=v&...' --signature <sig>
*
* Output:
* stdout: "valid" or "invalid"
* exit code: 0 if valid, 1 if invalid
*/
import { createHash, timingSafeEqual } from "crypto";
function parseArgs(argv: string[]): { apiKey: string; params: string; signature: string } {
let apiKey = "", params = "", signature = "";
for (let i = 2; i < argv.length; i++) {
if (argv[i] === "--api-key") apiKey = argv[++i];
else if (argv[i] === "--params") params = argv[++i];
else if (argv[i] === "--signature") signature = argv[++i];
}
if (!apiKey || !params || !signature) {
process.stderr.write("Usage: verify.ts --api-key <key> --params 'k=v&...' --signature <sig>\n");
process.exit(2);
}
return { apiKey, params, signature };
}
function expectedSignature(paramsQuery: string, apiKey: string): string {
const parsed = new Map<string, string>();
for (const pair of paramsQuery.split("&")) {
if (!pair) continue;
const eq = pair.indexOf("=");
const k = eq >= 0 ? pair.slice(0, eq) : pair;
const v = eq >= 0 ? pair.slice(eq + 1) : "";
parsed.set(k, v);
}
const sorted = [...parsed.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const query = sorted.map(([k, v]) => `${k}=${v}`).join("&");
return createHash("md5").update(query + apiKey, "utf8").digest("hex");
}
function constantTimeEquals(a: string, b: string): boolean {
const aBuf = Buffer.from(a.toLowerCase(), "utf8");
const bBuf = Buffer.from(b.toLowerCase(), "utf8");
if (aBuf.length !== bBuf.length) return false;
return timingSafeEqual(aBuf, bBuf);
}
const { apiKey, params, signature } = parseArgs(process.argv);
const expected = expectedSignature(params, apiKey);
if (constantTimeEquals(expected, signature)) {
process.stdout.write("valid");
process.exit(0);
}
process.stdout.write("invalid");
process.exit(1);In prose, the verify protocol is:
- Parse the JSON body into a flat map of string keys to string values. Skip keys whose value is
null. - Sort keys alphabetically (ASCII order).
- Build a query string
k1=v1&k2=v2&.... - Append your API key (no
&separator). - Compute MD5 of the UTF-8 bytes, output lowercase hex.
- Compare against
X-Signatureusing a constant-time comparison (hmac.compare_digestin Python,hash_equalsin PHP,crypto.timingSafeEqualin Node.js).
If verification fails, return 401 immediately and do not apply any side effects. A failed verification almost always means: wrong API key, body was modified by a proxy, or you sorted the wrong key set (for example, you included headers in the signing set).
Worked examples
Example 1 — Complete callback handler in Python (FastAPI)
from datetime import UTC, datetime
import hashlib
import hmac
import json
from fastapi import FastAPI, HTTPException, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .db import async_session
from .models import ProcessedCallback # (order_no, status) primary key
API_KEY = "mk_live_abc123..." # never hardcode in prod; read from secrets manager
TIMESTAMP_TOLERANCE_SECONDS = 300 # ±5 minutes
app = FastAPI()
def expected_signature(params: dict, api_key: str) -> str:
items = sorted((k, v) for k, v in params.items() if v is not None)
qs = "&".join(f"{k}={v}" for k, v in items)
return hashlib.md5((qs + api_key).encode("utf-8")).hexdigest()
@app.post("/callbacks/payment-gateway")
async def receive_callback(request: Request) -> Response:
# 1. Read raw body; parse JSON once for both signing and routing.
raw = await request.body()
try:
body = json.loads(raw)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="invalid json")
# 2. Verify signature.
signature = request.headers.get("x-signature", "")
expected = expected_signature(body, API_KEY)
if not hmac.compare_digest(expected, signature.lower()):
raise HTTPException(status_code=401, detail="bad signature")
# 3. Verify timestamp window.
try:
ts = int(request.headers.get("x-timestamp", "0"))
except ValueError:
raise HTTPException(status_code=401, detail="bad timestamp")
now = int(datetime.now(UTC).timestamp())
if abs(now - ts) > TIMESTAMP_TOLERANCE_SECONDS:
raise HTTPException(status_code=401, detail="timestamp out of window")
# 4. Idempotency check.
order_no = body["order_no"]
status = body["status"]
async with async_session() as db: # type: AsyncSession
existing = await db.execute(
select(ProcessedCallback)
.where(ProcessedCallback.order_no == order_no)
.where(ProcessedCallback.status == status)
)
if existing.scalar_one_or_none() is not None:
return Response(status_code=200) # already processed; ack
# 5. Apply side effects (mark order paid, release goods, etc.).
await apply_order_state(db, body)
# 6. Record the (order_no, status) pair so a retry would be a no-op.
db.add(ProcessedCallback(order_no=order_no, status=status))
await db.commit()
return Response(status_code=200)
async def apply_order_state(db: AsyncSession, body: dict) -> None:
# Business logic specific to your application.
...This is roughly the smallest correct handler. Verify, deduplicate, then commit.
Example 2 — Idempotency by (order_no, status)
The simplest deduplication table:
CREATE TABLE processed_callbacks (
order_no varchar(64) NOT NULL,
status varchar(32) NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (order_no, status)
);Insert on every successful callback application. A duplicate insert fails on the primary key and your handler short-circuits to 200.
Why (order_no, status) and not notification_id or X-Timestamp?
- Payment Gateway re-sends the same
NotificationLogrow across retries; there is no per-attempt event id you can rely on. X-Timestampchanges between retries. Deduplicating on it would let the same event apply 5 times.- An order can transition through several states (for example,
MATCHEDthenCOMPLETED); each transition is a legitimate, distinct callback that you do want to process.(order_no, status)admits bothMATCHEDandCOMPLETEDwhile still blocking duplicates within a state.
Example 3 — 5xx response triggers retry
Return any 5xx (or close the connection, or take longer than the configured timeout) and the gateway will retry. The dispatch timeline:
| Attempt | Time offset | NotificationStatus after attempt |
|---|---|---|
| 1 | T+0 | WAITING_RETRY |
| 2 | T+30s | WAITING_RETRY |
| 3 | T+60s | WAITING_RETRY |
| 4 | T+90s | WAITING_RETRY |
| 5 | T+120s | FAILED |
You can observe these attempts in two places:
- The merchant-portal Notifications log (operator-facing view of
NotificationLog). - Your own request logs. Each retry arrives with the same body and a fresh
X-Timestamp/X-Signature.
Total wall-clock time from first attempt to final FAILED is approximately 2 minutes. If your endpoint is briefly down, all five attempts may hit the same outage and exhaust the retries.
Example 4 — Pulling missed callbacks via /query/*
When the retry budget is exhausted, the gateway gives up. To recover, run a scheduled reconciler on your side:
# Pseudocode: every 5 minutes, reconcile orders that should have been finalized.
async def reconcile() -> None:
# Find local orders we created in the last 24h that are still pending
# without a corresponding completed callback in processed_callbacks.
stale = await find_stale_pending_orders(older_than_seconds=600)
for order in stale:
# Use merchant_order_no, the identifier you control.
remote = await gateway.query_deposit(merchant_order_no=order.merchant_order_no)
if remote["status"] in ("completed", "expired", "cancelled"):
await apply_terminal_state(order, remote)GET /api/v1/merchant/query/deposit/{order_no} (or GET /api/v1/merchant/query/deposit?merchant_order_no=...) and the withdrawal equivalents (/api/v1/merchant/query/withdrawal/{order_no}, /api/v1/merchant/query/withdrawal?merchant_order_no=...) are documented in Deposits and Withdrawals. Always query by merchant_order_no (yours) when the order may not have a gateway order_no in your records yet.
A safe default schedule is: reconcile every 5 minutes for orders older than 10 minutes that have not reached a terminal state. This catches both exhausted retries and rare callback losses.
Retry semantics
The exact policy as implemented in backend/app/tasks/notification.py:
max_attempts: 5. After the 5th unsuccessful attempt,NotificationStatusis set toFAILEDand no further dispatch occurs.RETRY_DELAYS:[30, 30, 30, 30, 30]— a fixed 30-second interval between attempts. This is not exponential backoff.default_retry_delay: 30 seconds, applied by Celery if the task is rescheduled.- Per-attempt timeout: 10 seconds (
Merchant.config.callback_timeout_seconds, configurable per merchant). Slow responses are treated as failures. - 4xx behavior: A
4xxfrom your handler is a terminal failure. The gateway does not retry. The intent is: if the merchant is rejecting the callback shape, retrying with the same shape is pointless. - 5xx / timeout / network error behavior: Counts as a transient failure.
NotificationLog.statusbecomesWAITING_RETRYandnext_attempt_at = now + 30s. The Celery worker reschedules viaself.retry(countdown=30). - Stuck
SENDINGrecovery: A secondary periodic task (retry_failed_notifications) sweeps every minute and rescuesSENDINGrows that have not transitioned within 5 minutes. They are reset toWAITING_RETRYand re-dispatched. - Total dispatch window: From the first attempt to the terminal
FAILEDdecision is approximately 2 minutes (first attempt + four 30-second gaps).
Two NotificationLog rows for the same order are never created automatically. Operator-initiated retries via the merchant portal reset attempt_count to 0 and re-arm the same row. They do not create a second row.
Pitfalls
- Callback URL must be HTTPS in production. The gateway requires a valid TLS certificate. Self-signed certificates are rejected (
webhook_ssl_verify = Trueby default). Use a real cert from any public CA. - Slow responses count as failure. The default per-attempt timeout is 10 seconds. Move any heavy work (settling goods, sending emails, writing to slow downstream systems) into a background queue on your side. Acknowledge with
2xxfirst; do the work after. - Verify the signature server-side. Never accept a callback that originated in a browser. Never embed your API key in client code. The signing key is the same one you use for outbound requests.
- Idempotency must be by
(order_no, status). The gateway re-uses the sameNotificationLogrow across all 5 retries; there is no per-attempt event id. Deduplicating onX-Timestampis wrong because timestamps differ between retries. 4xxis terminal — and the retry budget is gone. Once you4xx, the gateway marksFAILEDand stops. If you4xxby accident (for example, a transient bug in your verifier), you must reconcile via the query endpoints. There is no "ask for retry" API.- Out-of-order callbacks happen. During recovery from outages,
deposit.matchedcan arrive afterdeposit.completed. Your idempotency table prevents double-applying state, but your business logic should accept terminal states regardless of intermediate-state delivery. - Do not log the raw body before verification. Treating the body as trusted before checking
X-Signatureis a privilege-escalation vector. Verify, then log. - Branch on
eventornotification_type, never onmessage.messageis a human-readable label; it is not part of the contract and may change. datakeys vary by event type.deposit.completedcarriesmatched_atandcompleted_at;deposit.expiredcarriesexpired_at;deposit.cancelledusescurrency_codewhile others usecurrency. Parse defensively.- Field-name drift exists between events. Some payloads use
currency(the ISO code), anddeposit.cancelledusescurrency_codefor historical reasons. Treat both keys as equivalent. - Do not hard-code the 30-second retry interval into your monitoring. The interval is a backend constant. If it changes in a future release, monitors that hardcode it will alert spuriously. Read
NotificationLog.next_attempt_atif you need the actual scheduled time. - When retries exhaust, your state diverges. No further callbacks will arrive for that event. You must poll
/query/*to converge. Build the poller before going live.
For the full list of error codes you might surface back to the gateway (in body, not as HTTP semantics), see Errors.
Going live checklist
Before pointing production traffic at your callback handler, confirm each item:
- [ ] Callback URL is HTTPS with a valid certificate chain. No self-signed certs.
- [ ] The URL is reachable from the gateway egress IPs (see Sandbox for the test environment IPs; ask the platform operator for production IPs).
- [ ] Median response time is below 5 seconds under expected concurrency. The hard timeout is 10s; leave headroom.
- [ ] Signature verification runs on every request. There is no "internal callback" exception.
- [ ] An idempotency table keyed on
(order_no, status)is in place and indexed. - [ ] A reconciler runs on a schedule (suggested: every 5 minutes for orders older than 10 minutes) that calls the deposit and withdrawal query endpoints.
- [ ] Logs include
X-Timestamp,order_no,status, and signature-verify result. The raw body is logged only after verification, with PII redacted. - [ ] Alerts fire when
NotificationLog.status = FAILEDrate exceeds a threshold, or when reconciliation flips an order to a terminal state without a successful callback. - [ ] A runbook exists for: "an outage caused 100 callbacks to FAIL — how do we recover?". The answer is the reconciler; verify it works.
Cross-references
- Authentication & Signing — the signing algorithm and the verify samples imported in this chapter.
- Errors — error envelope, codes, and retry guidance for inbound requests.
- API Reference — full endpoint reference, including
/query/*. - Sandbox — how to drive deposits and withdrawals through end-to-end states in the test environment, including the mock channel.
- Deposits — deposit state machine and the events that trigger each deposit callback.
- Withdrawals — withdrawal state machine and event-specific fields for withdrawal callbacks.
- Quickstart — the 15-minute path from first request to first verified callback.