Fulfil orders with webhooks
When a payment succeeds, Stack sends your server a signed payment_intent.succeeded event.
That event is the signal to fulfil the order: ship the food, unlock the film, issue the
ticket.
Don’t fulfil from the result in your app. The app can be closed mid-payment, lose its connection, or be tampered with. Stack sends the webhook even if nobody is looking at your app, and keeps retrying until your server confirms it.
Set it up
Section titled “Set it up”-
In the dashboard under Webhooks, set your endpoint URL, for example
https://your-server.example/stack/webhook. Each project has one endpoint. It must be HTTPS and reachable from the internet. -
Copy the signing secret (
whsec_…) into your server’s environment, for example asSTACK_WEBHOOK_SECRET. -
Add the handler:
webhook.ts import express from 'express';import Stack, { StackSignatureError, type PaymentFailedData, type PaymentSucceededData } from '@usestack/node';const stack = new Stack(process.env.STACK_SECRET_KEY!);const app = express();// Signatures are over the exact bytes Stack sent, so take the raw body here.app.post('/stack/webhook', express.raw({ type: 'application/json' }), async (req, res) => {let event;try {event = stack.webhooks.verify(req.body, req.headers['stack-signature'], process.env.STACK_WEBHOOK_SECRET!);} catch (err) {if (err instanceof StackSignatureError) return res.status(400).send('Invalid signature');throw err;}// "Send test event" in the dashboard carries { test: true } instead of a payment.if ((event.data as { test?: boolean }).test) return res.sendStatus(200);if (event.type === 'payment_intent.succeeded') {const payment = event.data as unknown as PaymentSucceededData;const orderId = payment.metadata?.orderId as string;// Stack may deliver the same event more than once: fulfilling twice must be a no-op.await markOrderPaid(orderId, { paymentId: payment.id, amount: Number(payment.amount) });} else if (event.type === 'payment_intent.failed') {const failure = event.data as unknown as PaymentFailedData;// The payment is still open: the customer can try again in the sheet.await noteFailedAttempt(failure.id, failure.last_error);}// Any 2xx tells Stack you have it. Anything else, or no answer in 10 seconds, is retried.res.sendStatus(200);});app.listen(4242);declare function markOrderPaid(orderId: string, payment: { paymentId: string; amount: number }): Promise<void>;declare function noteFailedAttempt(paymentId: string, reason: string): Promise<void>; -
Click Send test event in the dashboard and check the delivery shows
200.
What Stack sends
Section titled “What Stack sends”POST /stack/webhook HTTP/1.1Content-Type: application/jsonStack-Signature: t=1790000000,v1=5f2b0c…
{ "id": "evt_8c1f…", "type": "payment_intent.succeeded", "created": 1790000000, "data": { "id": "pi_Lb_-_wULEDdFYYJT", "amount": "570000", "currency": "NGN", "method": "card", "stack_fee": "2850", "metadata": { "orderId": "ord_123" } }}Amounts are strings in kobo. See Webhook events for every event and field.
Rules for a reliable handler
Section titled “Rules for a reliable handler”- Answer quickly with a 2xx. Stack waits 10 seconds. Do slow work (emails, calls to other services) after answering, or in a background job.
- Expect duplicates. Stack may deliver the same event more than once. Make fulfilment
idempotent: mark the order paid only if it isn’t already, or record
event.idand skip events you’ve seen. - Don’t rely on order. Events can arrive in any order. Check the order’s own state rather than assuming the previous event arrived.
- Check the amount. Compare
data.amountwith what the order should cost before you fulfil. The demo servers do this. - Match on the payment id. Store the payment intent’s
idwith your order when you create it, and look the order up bydata.id. Don’t trustmetadataalone.
Retries
Section titled “Retries”If your endpoint doesn’t answer with a 2xx within 10 seconds, Stack retries. It makes up to 12 attempts over about 17 hours, 30 seconds after the first failure and then doubling each time. Redirects aren’t followed and count as failures.
Every delivery, with your server’s response, is listed in the dashboard under Webhooks, where you can retry one by hand once your endpoint is fixed.
Verifying without @usestack/node
Section titled “Verifying without @usestack/node”The Stack-Signature header is t=<unix time>,v1=<signature>. The signature is the hex
HMAC-SHA256 of "<t>.<raw body>", keyed with your signing secret.
- Split the header on
,and readtand everyv1. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as lowercase hex. - Compare it with each
v1using a constant-time comparison. One must match. - Reject the event if
tis more than 5 minutes from your clock. That stops replays.
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = [p.split("=", 1) for p in header.split(",")] t = next((v for k, v in parts if k == "t"), None) sigs = [v for k, v in parts if k == "v1"] if t is None or abs(time.time() - int(t)) > 300: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, s) for s in sigs)Testing locally
Section titled “Testing locally”Stack needs to reach your endpoint over the internet. While developing, expose your local
server with a tunnel such as cloudflared tunnel --url http://localhost:4242 or ngrok, and
set the tunnel’s HTTPS URL as your endpoint.