Skip to content

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:

  1. Register a per-order callback_url when you create the order.
  2. Implement an HTTPS endpoint on that URL that accepts POST with a JSON body.
  3. Verify the X-Signature header on every request before trusting the body.
  4. Return 2xx to acknowledge, or 5xx (or time out) to request a retry.
  5. 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

TermMeaning
CallbackA signed POST from Payment Gateway to a merchant-controlled URL, carrying a JSON event body. Server-to-server only.
callback_urlThe merchant-supplied target URL, set per order when calling POST /deposit or POST /withdrawal. Must be HTTPS in production.
NotificationLogThe gateway-side row that tracks one callback dispatch attempt sequence. Stores URL, request body, response status, response body, attempt count, and current NotificationStatus.
NotificationTypeThe event identifier that drives which payload shape is sent. See the event table below.
AckA 2xx response from the merchant endpoint. Marks the NotificationLog as SUCCESS and stops further dispatch.
Retry queueThe Celery task send_webhook_notification plus the periodic retry_failed_notifications sweep. Together they implement the fixed-interval retry policy described in Retry semantics.
IdempotencyYour handler may receive the same callback more than once. Deduplicate on (order_no, status) before applying side effects.
ReconciliationWhen 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.

Triggernotification_typeCarrying event fieldOrder type
Deposit matched to a bank transaction (channel-bound account orders)deposit_matcheddeposit.matcheddeposit
Deposit fully completed and credited to merchant balancedeposit_completeddeposit.completeddeposit
Deposit expired before payment was matcheddeposit_expireddeposit.expireddeposit
Deposit cancelled by operator actiondeposit_cancelleddeposit.cancelled (status cancelled)deposit
Withdrawal approved by operator and queued for upstream processingwithdrawal_approvedwithdrawal.approvedwithdrawal
Withdrawal settled successfully by upstream channelwithdrawal_completedwithdrawal.completedwithdrawal
Withdrawal rejected by operator before dispatchwithdrawal_rejectedwithdrawal.rejectedwithdrawal
Withdrawal failed at upstream channel after dispatchwithdrawal_failedwithdrawal.failedwithdrawal
Tenant owner invite link issuedtenant_owner_inviteplatform-level, not order-boundn/a
Billing statement issued for the merchantbilling_statement_issuedplatform-level, not order-boundn/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:

AspectValue
MethodPOST
Content-Typeapplication/json
X-API-KeyYour merchant API key. Same key issued to you for inbound calls.
X-TimestampUnix epoch seconds at dispatch time.
X-SignatureLowercase-hex MD5 over the sorted JSON body parameters plus your API key.
BodyA JSON object whose fields depend on notification_type. See examples below.
Connection timeout10 seconds by default (Merchant.config.callback_timeout_seconds). Slow handlers count as a failure and trigger retry.
TLSGateway 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

json
{
  "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"
}
FieldTypeNotes
eventstringDotted form of notification_type. Use this for branching, not message text.
order_nostringGateway-assigned order number. Stable identifier.
merchant_order_nostringThe identifier you supplied at creation. Use for idempotency lookup.
amountstringRequested amount as a decimal string.
actual_amountstringSettled amount. May differ from amount for float-matched bank transfers.
currencystringISO 4217 currency code (for example, THB).
statusstringOrder status at dispatch time. For deposit.completed this is "completed".
matched_atISO 8601 string | nullWhen the gateway matched the order.
completed_atISO 8601 string | nullWhen 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

json
{
  "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

json
{
  "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 worker and Retry sweep are separate tasks. The worker handles the inline retry via self.retry(countdown=30). The sweep is a safety net that picks up rows the worker dropped (process restart, crash, or SENDING rows stuck for more than 5 minutes).
  • The HTTP body and signature are byte-identical across retries. The X-Timestamp header 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.

bash
#!/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
python
#!/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
<?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);
typescript
#!/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:

  1. Parse the JSON body into a flat map of string keys to string values. Skip keys whose value is null.
  2. Sort keys alphabetically (ASCII order).
  3. Build a query string k1=v1&k2=v2&....
  4. Append your API key (no & separator).
  5. Compute MD5 of the UTF-8 bytes, output lowercase hex.
  6. Compare against X-Signature using a constant-time comparison (hmac.compare_digest in Python, hash_equals in PHP, crypto.timingSafeEqual in 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)

python
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:

sql
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 NotificationLog row across retries; there is no per-attempt event id you can rely on.
  • X-Timestamp changes between retries. Deduplicating on it would let the same event apply 5 times.
  • An order can transition through several states (for example, MATCHED then COMPLETED); each transition is a legitimate, distinct callback that you do want to process. (order_no, status) admits both MATCHED and COMPLETED while 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:

AttemptTime offsetNotificationStatus after attempt
1T+0WAITING_RETRY
2T+30sWAITING_RETRY
3T+60sWAITING_RETRY
4T+90sWAITING_RETRY
5T+120sFAILED

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:

python
# 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, NotificationStatus is set to FAILED and 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 4xx from 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.status becomes WAITING_RETRY and next_attempt_at = now + 30s. The Celery worker reschedules via self.retry(countdown=30).
  • Stuck SENDING recovery: A secondary periodic task (retry_failed_notifications) sweeps every minute and rescues SENDING rows that have not transitioned within 5 minutes. They are reset to WAITING_RETRY and re-dispatched.
  • Total dispatch window: From the first attempt to the terminal FAILED decision 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 = True by 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 2xx first; 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 same NotificationLog row across all 5 retries; there is no per-attempt event id. Deduplicating on X-Timestamp is wrong because timestamps differ between retries.
  • 4xx is terminal — and the retry budget is gone. Once you 4xx, the gateway marks FAILED and stops. If you 4xx by 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.matched can arrive after deposit.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-Signature is a privilege-escalation vector. Verify, then log.
  • Branch on event or notification_type, never on message. message is a human-readable label; it is not part of the contract and may change.
  • data keys vary by event type. deposit.completed carries matched_at and completed_at; deposit.expired carries expired_at; deposit.cancelled uses currency_code while others use currency. Parse defensively.
  • Field-name drift exists between events. Some payloads use currency (the ISO code), and deposit.cancelled uses currency_code for 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_at if 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 = FAILED rate 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.