Webhook Notifications
Whenever an order status changes (e.g., buyer payment received, trader proof uploaded, payout settled, or order cancelled), the SureLink Gateway posts an HTTP POST notification to the Webhook Callback URL configured in your Merchant Portal.
1. Delivery & Security Specifications
1.1 Outbound Request Headers
Every Webhook POST request dispatched by the gateway carries symmetric HMAC authentication headers:
| Header | Description |
|---|---|
Content-Type | Fixed application/json |
User-Agent | Fixed otc-gateway-webhook/1.0 |
X-Api-Key | Merchant API Key intended for this event |
X-Timestamp | Millisecond Unix timestamp string |
X-Nonce | 32-character hexadecimal random nonce |
X-Signature | HMAC-SHA256 lowercase hex signature computed with the merchant's API Secret |
1.2 Signature Verification
Merchants must verify the signature over the raw request payload before processing:
$$\text{Expected Signature} = \operatorname{HMAC-SHA256}_{\text{API Secret}}(\text{X-Timestamp} + \text{"."} + \text{X-Nonce} + \text{"."} + \text{RawRequestBody})$$
The computed signature must match the X-Signature header byte-for-byte in constant time.
2. Event Types & Payloads
Webhook payloads share a standard top-level envelope:
{
"eventId": "c7a8b9d0-1234-5678-9abc-def012345678",
"occurredAt": 1715000000000,
"type": "order.completed",
"data": { ... }
}2.1 Event Catalog
Event Type (type) | Trigger | Flow |
|---|---|---|
order.completed | Pay-in order completed; crypto credited to balance | Pay-in (DEPOSIT) |
order.cancelled | Pay-in order expired, user cancelled, or dispute cancelled | Pay-in (DEPOSIT) |
payout.proof_submitted | Trader wired fiat and uploaded bank transfer receipt | Payout (WITHDRAW) |
payout.completed | Merchant confirmed release or arbitrated complete | Payout (WITHDRAW) |
payout.disputed | Payout order entered dispute review | Payout (WITHDRAW) |
payout.cancelled | Payout order cancelled; locked crypto refunded | Payout (WITHDRAW) |
2.2 Pay-in Completion Notification (order.completed)
{
"eventId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"occurredAt": 1715000000000,
"type": "order.completed",
"data": {
"orderId": "698c56fa-b0f1-460d-8302-39c09c916781",
"orderNo": "ORD20261011000001",
"merchantOrderNo": "MCH_PAYIN_20261011_001",
"status": "COMPLETED",
"direction": "DEPOSIT",
"assetSymbol": "USDT",
"assetAmount": "100.000000",
"fiatCurrency": "CNY",
"fiatAmount": "725.50",
"fxRate": "7.2550",
"paymentSubmittedAt": "2026-10-11T08:38:12.000Z",
"completedAt": "2026-10-11T08:40:05.000Z",
"chainTxKey": null,
"createdAt": "2026-10-11T08:35:00.000Z",
"updatedAt": "2026-10-11T08:40:05.000Z"
}
}2.3 Trader Proof Submitted Notification (payout.proof_submitted)
Notifies the merchant that the trader wired the fiat funds and submitted proof:
{
"eventId": "8b9c0d1e-2345-6789-abcd-ef0123456789",
"occurredAt": 1715000100000,
"type": "payout.proof_submitted",
"data": {
"orderId": "3b2e5a7d-8f90-4c12-9e34-5a6b7c8d9e0f",
"orderNo": "WD20261011000088",
"merchantOrderNo": "MCH_PAYOUT_20261011_888",
"status": "PAYMENT_SUBMITTED",
"direction": "WITHDRAW",
"fiatCurrency": "CNY",
"lockedFiatAmount": "1000.00",
"merchantActionDueAt": "2026-10-11T11:00:00.000Z",
"paymentDueAt": "2026-10-11T09:30:00.000Z",
"firstProofSubmittedAt": "2026-10-11T09:15:30.000Z",
"completedAt": null,
"reason": null,
"createdAt": "2026-10-11T09:00:00.000Z",
"updatedAt": "2026-10-11T09:15:30.000Z"
}
}3. Webhook Verification Example
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
app.post('/api/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const apiKey = req.headers['x-api-key'] as string;
const timestamp = req.headers['x-timestamp'] as string;
const nonce = req.headers['x-nonce'] as string;
const receivedSig = req.headers['x-signature'] as string;
const rawBody = req.body.toString('utf8');
const myApiSecret = process.env.SURELINK_API_SECRET!;
// 1. Recompute expected signature
const source = `${timestamp}.${nonce}.${rawBody}`;
const expectedSig = createHmac('sha256', myApiSecret).update(source).digest('hex');
// 2. Constant-time comparison
const isMatch = receivedSig.length === expectedSig.length &&
timingSafeEqual(Buffer.from(receivedSig, 'hex'), Buffer.from(expectedSig, 'hex'));
if (!isMatch) {
console.error('Webhook signature mismatch; rejected');
return res.status(401).send('Invalid signature');
}
// 3. Process event
const payload = JSON.parse(rawBody);
console.log(`Received event: ${payload.type}, ID: ${payload.eventId}`);
// Return HTTP 2xx to acknowledge receipt
res.status(200).send('OK');
});from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib
import json
import os
app = FastAPI()
API_SECRET = os.getenv("SURELINK_API_SECRET", "your_secret")
@app.post("/api/webhook")
async def handle_webhook(request: Request):
api_key = request.headers.get("x-api-key")
timestamp = request.headers.get("x-timestamp")
nonce = request.headers.get("x-nonce")
received_sig = request.headers.get("x-signature")
raw_body = (await request.body()).decode("utf-8")
source = f"{timestamp}.{nonce}.{raw_body}"
expected_sig = hmac.new(
API_SECRET.encode("utf-8"),
source.encode("utf-8"),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(received_sig, expected_sig):
raise HTTPException(status_code=401, detail="Invalid signature")
event = json.loads(raw_body)
# Business logic...
return {"status": "success"}4. Response Requirements, Retries & Deduplication
- Response Contract:
- The merchant endpoint must return an HTTP 2xx status code (e.g.,
200 OK) upon receipt. - The HTTP timeout threshold is 10 seconds.
- The merchant endpoint must return an HTTP 2xx status code (e.g.,
- Retry Strategy:
- In case of
5xxerrors,429 Too Many Requests, or connection timeouts, the gateway automatically retries with exponential backoff via an outbox worker. - Client errors (
4xx) and redirects (3xx) are treated as terminal failures and moved to the dead-letter queue (DLQ) without retrying.
- In case of
- Idempotent Handling:
- Due to network retries, the same event may be delivered more than once. Merchants must record and deduplicate using
eventId. If theeventIdhas already been processed, return200 OKimmediately without repeating actions.
- Due to network retries, the same event may be delivered more than once. Merchants must record and deduplicate using
