Skip to content

Webhooks

Receive signed events when checkout sessions and withdrawals change state.

When you pass a webhook_url, Paysum sends a JSON POST to it whenever the checkout session or withdrawal changes state. Respond with any 2xx status within 10 seconds.

Events #

EventSent when
checkout.session.completedThe customer paid.
checkout.session.cancelledThe session was cancelled.
checkout.session.expiredThe session passed its expiry without payment.
withdrawal.completedThe payout was broadcast; includes tx_hash.
withdrawal.failedThe payout could not be sent.

Payload #

checkout.session.completed
{
  "event": "checkout.session.completed",
  "data": {
    "id": "cs_9f2c4e1a7b3d4c5e8f90a1b2c3d4e5f6",
    "status": "completed",
    "total_amount": 49.98,
    "currency": "USD",
    "customer_email": "[email protected]",
    "payment_method": "stripe",
    "line_items": [
      {
        "name": "Pro Plan",
        "price": 39.99,
        "quantity": 1,
        "description": "Monthly subscription"
      }
    ],
    "metadata": {
      "orderNumber": "ORD-4821"
    },
    "client_reference_id": "ref_abc123",
    "completed_at": "2026-03-04T18:30:00.000Z"
  },
  "created": "2026-03-04T18:30:01.000Z"
}

Verifying signatures #

Every request carries an x-webhook-signature header of the form t=<unix seconds>,v1=<hex>. The v1 value is a hex HMAC-SHA256 of ${t}.${rawBody}, keyed by your store’s webhook signing secret (whsec_…). Store owners and members with the API keys permission can view it with GET /api/api-keys/webhook-secret/store/:storeId and rotate it with POST /api/api-keys/webhook-secret/store/:storeId/rotate. After a rotation the old secret stays valid for 24 hours, and during that window the header carries two v1 entries. Accept the request if any of them matches.

verify.js
import crypto from 'node:crypto'

const TOLERANCE_SECONDS = 300

export function verify(rawBody, header, secret) {
  const parts = (header || '').split(',').map((part) => part.split('='))
  const t = parts.find(([k]) => k === 't')?.[1]
  const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v)
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false
  const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'))
  return signatures.some((sig) => {
    const actual = Buffer.from(sig)
    return actual.length === expected.length && crypto.timingSafeEqual(actual, expected)
  })
}

Troubleshooting #

Make sure the URL is publicly reachable over HTTPS and was passed as webhook_url when the session or withdrawal was created - it can’t be added afterwards.

Compute the HMAC over the exact raw body bytes. Parsing and re-serialising the JSON changes whitespace and breaks the check.