SpicePay

Webhooks

Receiving and verifying event notifications.

Webhooks push events to your systems as they happen — payment results, refunds, disputes, subscription invoices — so you never poll.

Setup

Add an HTTPS endpoint in the Control Center and select the events you want. Each endpoint has a webhook secret used to sign deliveries.

Event format

The body carries the event type plus the full resource object it concerns:

{
  "merchant_id": "merchant_1234567890",
  "event_id": "evt_018f2c1b9a7e",
  "event_type": "payment_succeeded",
  "content": {
    "type": "payment_details",
    "object": {
      "payment_id": "pay_V4t1RJgSy4hoBQzoqqRz",
      "status": "succeeded",
      "amount": 4900,
      "currency": "EUR"
    }
  },
  "timestamp": "2026-07-13T10:24:31Z"
}

content.type tags what kind of object the payload holds (payment_details, refund_details, dispute_details, …); event_type says what happened to it.

Key event types:

Event type Fired when
payment_succeeded / payment_failed A payment reaches a final state
payment_processing Payment sent to the processor, result pending (e.g. crypto confirmations)
action_required Customer action needed (e.g. 3DS challenge at checkout)
payment_cancelled / payment_expired Payment voided or timed out
refund_succeeded / refund_failed Refund completes
dispute_opened / dispute_challenged / dispute_won / dispute_lost Chargeback lifecycle
mandate_active / mandate_revoked Saved-payment-method mandates
invoice_paid A subscription billing period was charged successfully

Verifying signatures

Every delivery carries an X-Webhook-Signature-512 header: the hex-encoded HMAC-SHA512 of the raw request body, keyed with your webhook secret.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: Buffer, signatureHeader: string, secret: string): boolean {
  const expected = createHmac("sha512", secret).update(rawBody).digest("hex");
  const received = Buffer.from(signatureHeader, "hex");
  const wanted = Buffer.from(expected, "hex");
  return received.length === wanted.length && timingSafeEqual(received, wanted);
}

Always verify against the raw request body — parsing and re-serializing JSON will break the signature. In Express, use express.raw({ type: "application/json" }) on the webhook route, not express.json().

Delivery & retries

  • Respond with a 2xx promptly; do heavy work asynchronously.
  • Failed deliveries are retried with backoff.
  • Deliveries can arrive out of order and more than once — treat handlers as idempotent, using event_id as your deduplication key.
  • The Control Center shows each webhook's delivery attempts with response codes, and lets you redeliver manually.