Skip to content

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.

  1. 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.

  2. Copy the signing secret (whsec_…) into your server’s environment, for example as STACK_WEBHOOK_SECRET.

  3. 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>;
  4. Click Send test event in the dashboard and check the delivery shows 200.

POST /stack/webhook HTTP/1.1
Content-Type: application/json
Stack-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.

  • 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.id and 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.amount with what the order should cost before you fulfil. The demo servers do this.
  • Match on the payment id. Store the payment intent’s id with your order when you create it, and look the order up by data.id. Don’t trust metadata alone.

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.

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.

  1. Split the header on , and read t and every v1.
  2. Compute HMAC-SHA256(secret, t + "." + rawBody) as lowercase hex.
  3. Compare it with each v1 using a constant-time comparison. One must match.
  4. Reject the event if t is 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)

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.