Webhooks
Scan & Pay POSTs a signed JSON payload to your webhook URL whenever a payment session changes terminal state. Verify the signature, parse the body, mark the order — return a 2xx within a few seconds and you're done.
Method: POST · Content-Type: application/json
Signature header: X-Scanpay-Signature — HMAC-SHA256 hex digest of the raw request body
Signing key: your Webhook Secret from the merchant dashboard
Payload shape
{
"order_id": "order_456",
"payment_session_id": "SP_SESS_abc123def456",
"status": "confirmed",
"amount": 19.90,
"currency": "AUD",
"tx_id": "bank_ref_789",
"timestamp": 1761878400,
"nonce": "SP_SESS_abc123def456_1761878400"
}| Field | Type | Description |
|---|---|---|
| order_id | string | The platformOrderId you sent when creating the session. |
| payment_session_id | string | The SP_SESS_* identifier returned from createPaymentSession. |
| status | enum | confirmed · failed · expired. Note: confirmed mirrors PAID from the Payments API. |
| amount | number | AUD major units. Match against your local order before marking paid. |
| currency | string | Currently always AUD. |
| tx_id | string | Bank transaction reference. Falls back to the session ID if the rail did not return one. |
| timestamp | integer | Unix epoch seconds when the event was sent. Use for replay protection (reject events older than 60 seconds). |
| nonce | string | Unique per delivery. Cache for 24h to ensure idempotent processing. |
Verifying the signature
Compute hmac_sha256(raw_body, webhook_secret) as a hex string and compare it (using a constant-time comparison) to the X-Scanpay-Signature header. Reject the request if they differ.
Sign the raw body, not the parsed JSON. Frameworks that automatically parse JSON before your handler runs will break verification. Configure your handler to receive the raw bytes (e.g. Express express.raw({ type: 'application/json' }) or Next.js await req.text()).
# Compute the expected signature
echo -n "$RAW_BODY" | openssl dgst -sha256 -hmac "$SCANANDPAY_WEBHOOK_SECRET" -hex
# Compare to the X-Scanpay-Signature header on the inbound requestuse ScanAndPay\WebhookVerifier;
use ScanAndPay\Exceptions\WebhookSignatureException;
$verifier = new WebhookVerifier(getenv('SCANANDPAY_WEBHOOK_SECRET'));
try {
$event = $verifier->verify(
signature: $_SERVER['HTTP_X_SCANPAY_SIGNATURE'] ?? '',
body: file_get_contents('php://input'),
);
} catch (WebhookSignatureException $e) {
http_response_code(401);
exit('Invalid signature');
}
if ($event->status === 'confirmed') {
$order = Order::find($event->orderId);
$order->markPaid($event->txId);
}
http_response_code(200);import { verifyWebhook, WebhookSignatureError } from '@scanandpay/node';
export async function POST(req: Request) {
const rawBody = await req.text(); // raw bytes, NOT req.json()
const signature = req.headers.get('x-scanpay-signature') ?? '';
let event;
try {
event = verifyWebhook({
secret: process.env.SCANANDPAY_WEBHOOK_SECRET!,
signature,
body: rawBody,
});
} catch (err) {
if (err instanceof WebhookSignatureError) {
return new Response('Invalid signature', { status: 401 });
}
throw err;
}
if (event.status === 'confirmed') {
await db.orders.markPaid(event.orderId, event.txId);
}
return new Response('OK', { status: 200 });
}Replay protection
The signature alone proves authenticity, not freshness. To defend against replays, also check:
- 1Timestamp window. Reject events whose
timestampis more than 60 seconds older than your server clock. - 2Nonce store. Cache the
noncefor 24 hours. Reject any inbound event whose nonce is already cached.
Both SDKs do this automatically when you provide a cache backend. Without one they fall back to in-process memory, which is fine for single-instance servers but unsafe behind a load balancer.
Delivery and retries
- Success: respond
2xxwithin 10 seconds. The event is consumed. - Retry: any non-2xx response (or timeout) triggers up to 3 retries with exponential backoff (10s, 60s, 5m).
- Dead-letter: after the third failure the event lands in the merchant dashboard's Settings → Webhook events queue for manual replay.
- Idempotency: we may deliver the same event more than once. Use the
noncecache above to stay idempotent.
Configuring your webhook URL
The webhook URL is read from your merchant profile. Set it in the merchant dashboard or, for WooCommerce shops, the plugin auto-registers /wp-json/scanpay/v1/payment-confirmed on activation.
For custom integrations, expose any HTTPS endpoint that accepts a JSON POST. Plain HTTP is rejected.
Next
- → Payments API — create the sessions that fire these webhooks
- → OpenAPI spec — full machine-readable contract