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

POST {your webhook URL}json
{
  "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"
}
FieldTypeDescription
order_idstringThe platformOrderId you sent when creating the session.
payment_session_idstringThe SP_SESS_* identifier returned from createPaymentSession.
statusenumconfirmed · failed · expired. Note: confirmed mirrors PAID from the Payments API.
amountnumberAUD major units. Match against your local order before marking paid.
currencystringCurrently always AUD.
tx_idstringBank transaction reference. Falls back to the session ID if the rail did not return one.
timestampintegerUnix epoch seconds when the event was sent. Use for replay protection (reject events older than 60 seconds).
noncestringUnique 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()).

curl + openssl (manual)bash
# 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 request
PHP — scanandpay/phpphp
use 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);
TypeScript — Next.js Route Handlerts
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:

  1. 1Timestamp window. Reject events whose timestamp is more than 60 seconds older than your server clock.
  2. 2Nonce store. Cache the nonce for 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 2xx within 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 nonce cache 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