Purchases & balance · WebhooksSignature Verification

Signature Verification

Every webhook request carries an HMAC-SHA256 signature in X-Signature. It covers {timestamp}.{raw_body}: an old body cannot be given a fresh timestamp without a new signature. The webhook secret is separate from your API key.

Algorithm

X-Signature = lowercase_hex(
  HMAC-SHA256(
    key  = UTF8(your_webhook_secret),
    data = ASCII(X-Timestamp) + ASCII(".") + raw_body
  )
)
  • your_webhook_secret is the secret shown once in the cabinet under Settings → Webhook → Regenerate secret. Store it in your secrets manager.
  • X-Timestamp is Unix seconds represented as a decimal string. Sign its exact received text; do not change its formatting.
  • raw_body is the exact received bytes, before parsing or re-encoding. Changing whitespace, key order, or a trailing newline changes the signature.

Verification recipe

  1. Require X-Timestamp to be a decimal Unix-seconds value within ±300 seconds of your server clock. Keep that clock synchronized.
  2. Require X-Signature to contain a 32-byte SHA-256 digest encoded as 64 hexadecimal characters. Compute the expected HMAC over the raw bytes and compare the digests in constant time (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). Reject an invalid signature or stale/malformed timestamp.
  3. Parse the verified JSON and check its required envelope fields. Check that X-Event-Id matches event_id and X-Event matches event.
  4. Durably accept a new event before returning 2xx. If the valid event is already in your durable inbox, return 2xx again without repeating its business effect. A duplicate event is not an authentication failure.
  5. Process accepted events with both delivery and business deduplication. If intake storage is unavailable, return 503 and let the sender retry.

An event ID is not proof of authenticity. Verify the signature and freshness even when you recognize the ID. Unknown fields or correctly signed new event types must not break your receiver; see versioning.

Working code

The Node.js receiver and Python receiver use raw request bytes, a durable inbox, and separate business deduplication keys. Follow their local setup and test instructions before connecting a receiver to your merchant account.

Testing your verifier

Use the public synthetic fixture. This key and payload are documentation-only inputs; no purchase, payment, or live replay is needed.

InputValue
Test secretwhsec_documentation_test_only
X-Timestamp1789732800
Expected X-Signature109252424863c4643c92a561470e9b60e77d2d1ce48f4088ba94f04ec9f094de

Exact UTF-8 body, on one line, without a trailing newline:

{"event":"purchase.completed","event_id":"11111111-1111-4111-8111-111111111111","occurred_at":"2026-09-18T12:00:00.000Z","data":{"purchase_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","custom_id":"documentation-order-42","completed_at":"2026-09-18T12:00:00.000Z"}}

The fixture’s raw string preserves these bytes. Its fixed timestamp lets tests verify the digest deterministically. For the full verifier, inject a test clock set to 1789732800; do not disable freshness checks in a deployed receiver. Also test that a changed byte, wrong secret, stale timestamp, and malformed signature fail. A correctly signed, previously accepted duplicate must return 2xx without another business effect.

The fixture suite also covers reordered purchase events, a rollback after delivery, and two deposit event IDs sharing one provider_uuid.

Rotating the secret

Rotate in the merchant cabinet under Settings → Webhook → Regenerate secret. The new secret is shown once. Future delivery attempts use the new secret immediately; requests already in flight can still carry a signature made with the old secret.

Coordinate the receiver deployment and rotation. For a planned rotation, you can accept both secrets briefly on your receiver while in-flight requests finish, then remove the old secret. There is no server-side grace period that keeps signing new attempts with the old secret. If a secret was compromised, remove it promptly and recover any failed deliveries through the delivery log and manual replay.