
Webhooks
OpenStock Docs
Receive signed events when checkout sessions and withdrawals change state.
When you pass a webhook_url, OpenStock sends a JSON POST to it whenever the checkout session or withdrawal changes state. Respond with any 2xx status within 10 seconds.
Events#
| Event | Sent when |
|---|---|
checkout.session.completed | The customer paid. |
checkout.session.cancelled | The session was cancelled. |
checkout.session.expired | The session passed its expiry without payment. |
withdrawal.completed | The payout was broadcast; includes tx_hash. |
withdrawal.failed | The payout could not be sent. |
Payload#
{
"event": "checkout.session.completed",
"data": {
"id": "cs_9f2c4e1a7b3d4c5e8f90a1b2c3d4e5f6",
"status": "completed",
"total_amount": 49.98,
"currency": "USD",
"customer_email": "jane@example.com",
"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.
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)
})
}Legacy X-Swiftly-Signature is deprecated
Requests also still carry the old X-Swiftly-Signature header: an HMAC of the body keyed by the session or withdrawal ID. Anyone who knows that ID can produce it, so don’t trust it. Switch to x-webhook-signature. The legacy header will be removed in a future release.
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.