Guide to Stitch Webhooks: Features and Best Practices
Stitch webhooks tell your systems when a payment, refund, payout or recurring-payment consent changes status. If you take payments with Stitch, the South African payments platform behind Pay by Bank, Capitec Pay, card, DebiCheck, Manual EFT and cash payments, webhooks are how you learn that a payment completed, a card transaction failed or a disbursement was paid, without polling the API.
This guide covers how to subscribe to Stitch webhooks, how they are signed, the event types and the two payload shapes they use, and the best practices for running a Stitch receiver in production. It covers Stitch's current webhooks, which run on Svix, and not the legacy webhooks Stitch documents separately.
What are Stitch webhooks?
Stitch delivers webhooks through Svix. You create a subscription for an HTTPS URL and a set of event types, once per client ID rather than per payment, and Svix sends a POST with the event in the body whenever a matching status changes.
Each subscription has its own signing secret with a whsec_ prefix. Every request carries svix-id, svix-timestamp and svix-signature headers, and the signature is an HMAC-SHA256 over the ID, the timestamp and the raw body.
Stitch's steps for an integration are: create the subscription, verify every webhook's signature and discard any that fail, record the payload, return 200, and process the update idempotently.
Stitch webhook features
| Feature | Details |
|---|---|
| Delivery platform | Svix (Stitch's Svix account is in the EU region) |
| Subscription | Per client ID: GraphQL clientWebhookAdd (scope client_webhook_manage) or the Svix consumer portal (scope client_) |
| Event types | payment, payment.confirmation, payment-consent-request, transaction, collection, refund, disbursement, plus three deprecated types |
| Payload shapes | An {id, type, datetime, data} envelope for three types, a GraphQL-shaped body for the rest |
| Headers | svix-id, svix-timestamp, svix-signature |
| Scheme | Base64 HMAC-SHA256 over {svix_ |
| Secret | One whsec_ secret per subscription |
| Acknowledgement | HTTP 200 |
| Retries | Svix's retry policy, with exponential backoff |
| Extra authentication | Optional mTLS client certificate and custom headers, per subscription |
| Source IPs | Svix's EU-region addresses |
Common events
| Type | Products | Sent when |
|---|---|---|
payment | Pay by Bank, Capitec Pay, Card (once-off), Manual EFT, Cash | A payment request is completed, cancelled or expires |
payment.confirmation | The same, with a Stitch intermediary account | Funds for a payment request are received (PaymentReceived, PaymentUnsettled) |
payment-consent-request | Card, Capitec Pay Recurring, DebiCheck | A consent is processing, granted, failed, cancelled, expired, revoked or paused |
transaction | Card, Capitec Pay Recurring, DebiCheck | A transaction reaches SUCCESS, FAILURE or CANCELLED |
collection | Batch collections (subscriptions) | An individual collection in a batch is completed, failed or cancelled |
refund | Refunds for Pay by Bank, Card and Wallet | A refund is submitted, completed, errors or is paused |
disbursement | Disbursements | A payout is submitted, completed, errors, is paused, cancelled or reversed |
direct-deposit, payment-initiation and payment-initiation.confirmation are marked as deprecated.
The bodies come in two shapes. transaction, payment-consent-request and collection use an envelope with a type field. Here is Stitch's transaction example, trimmed:
{
"id": "transaction:status:success:40431E69-3DF7-4B2A-B464-DE540474BC1C",
"type": "transaction",
"datetime": "2023-05-10T12:22:42.865Z",
"data": {
"id": "Y2FyZHRyYW5zYWN0aW9uLzQwNDMxRTY5LTNERjctNEIyQS1CNDY0LURFNTQwNDc0QkMxQw==",
"type": "CARD",
"status": "SUCCESS",
"amount": { "currency": "ZAR", "quantity": "1" },
"externalReference": "79261d16-c53b-48eb-9019-dc9cfb6c5126",
"paymentRequestId": "RRFyZHRyYW5zYWN0aW9uLzQwNDMxRTY5LTNERjctNEIyQS1CNDY0LURFNTQwNDc0QkMxQw==",
"createdAt": "2023-05-10T12:22:42.865Z",
"updatedAt": "2023-05-10T12:22:42.865Z"
}
}
payment, payment.confirmation, refund and disbursement are shaped like a GraphQL query result, {"data": {"client": {"<resource>": {"node": {...}}}}}, with no event-type field. The key under data.client names the resource (paymentInitiationRequests, refunds, disbursements), and payment and payment.confirmation both use paymentInitiationRequests. Amounts are decimal strings such as "1", with the currency alongside.
Setting up Stitch webhooks
Through the API, request a client token with the client_webhook_manage scope and call the clientWebhookAdd mutation with two fields: url, your HTTPS endpoint, and filterTypes, the event types to send there. The response includes the subscription's signing secret. If token retrieval returns invalid_scope, ask your Stitch integration contact to enable the scope.
Through the portal:
- Request a client token with the
client_scope and run thewebhooks_ dashboard webhookLoginquery. It returns a one-time link that logs you in to the Svix consumer portal. - Add an endpoint with your URL and one or more event types, then click Create.
- Copy the Signing Secret from the endpoint's details page and store it server-side, for example as
STITCH_WEBHOOK_SECRET. - Under the Advanced tab, set a rate limit, custom headers or mTLS if you need them.
The portal's Event Catalog shows each event type's schema, and Logs shows past messages, which you can resend whether they succeeded or failed.
Securing Stitch webhooks
Stitch documents the manual check: concatenate the svix-id and svix-timestamp header values with the raw body, separated by ., compute an HMAC-SHA256 with the base64-decoded part of the secret after whsec_, base64-encode it, and compare it with the signatures in svix-signature. That header is a space-separated list of <version>,<signature> entries, and it usually holds one entry unless you are rotating the secret.
const crypto = require('crypto');
const express = require('express');
function verifySvixSignature(rawBody, headers, secret) {
const msg_id = headers['svix-id'];
const msg_timestamp = headers['svix-timestamp'];
const signature_header = headers['svix-signature'];
if (!msg_id || !msg_timestamp || !signature_header) return false;
if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;
// The HMAC key is the base64-decoded part of the secret after "whsec_".
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const signed_content = Buffer.concat([Buffer.from(`${msg_id}.${msg_timestamp}.`, 'utf8'), rawBody]);
const expected = Buffer.from(
crypto.createHmac('sha256', key).update(signed_content).digest('base64'),
'utf8'
);
// The header can hold several space-separated "v1,<signature>" entries.
return signature_header.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const received = Buffer.from(signature, 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/stitch', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.STITCH_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifySvixSignature(req.body, req.headers, secret)) {
return res.sendStatus(400); // Stitch: discard webhooks that fail verification
}
const payload = JSON.parse(req.body.toString('utf8'));
// Record the payload, deduplicate on req.get('svix-id'), queue it, then respond.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
import os
def verify_svix_signature(raw_body: bytes, headers, secret: str) -> bool:
msg_id = headers.get("svix-id")
msg_timestamp = headers.get("svix-timestamp")
signature_header = headers.get("svix-signature")
if not (msg_id and msg_timestamp and signature_header):
return False
if not secret or not secret.startswith("whsec_"):
return False
key = base64.b64decode(secret[len("whsec_"):])
signed_content = f"{msg_id}.{msg_timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
return True
return False
# In FastAPI: raw_body = await request.body()
# verify_svix_signature(raw_body, request.headers, os.environ["STITCH_WEBHOOK_SECRET"])
Stitch also shows the official Svix library for Express, and says the body must be the raw request: whitespace or line-ending changes, or a framework that parses and re-stringifies the JSON, break verification. The timestamp is part of the signed content, so you can check its age in your own receiver if you want to, but Stitch publishes no tolerance.
Make Stitch webhooks production-ready. Hookdeck Event Gateway verifies Stitch's Svix signatures, returns the
200Svix expects as soon as a webhook arrives, and queues every payment, refund and payout update until your service has processed it.
Hookdeck's Stitch source type verifies the svix-* headers with the subscription's whsec_ secret. Each subscription has its own secret, so use a separate Hookdeck source for each subscription you verify.
Stitch webhook limitations and pain points
Two payload shapes on one endpoint
The Problem: A handler that switches on type silently ignores payments, refunds and disbursements.
Why It Happens: Only the envelope types carry type. The GraphQL-shaped types have no event field, and payment and payment.confirmation share the same resource key.
Workarounds:
- Check for
typefirst. If it is absent, route on the key underdata.client. - Give
paymentandpayment.confirmationseparate subscriptions and URLs if you need to tell them apart reliably.
How Hookdeck Can Help: Hookdeck filters can match on body fields, so you can send envelope events and GraphQL-shaped events to different handlers from one source.
Duplicate deliveries
The Problem: The same payment update is processed twice, and an order is credited twice.
Why It Happens: Any non-200 response or timeout leads to a retry, and Stitch warns that a webhook can be received more than once.
Workarounds:
- Deduplicate on
svix-id, and for envelope events on the body'sid, which names the status transition. - Make status updates idempotent: only move a payment forward if the new status differs from the one you have.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeats before they reach your service, based on the header or body fields you choose.
Rotation means more than one signature
The Problem: Verification breaks during secret rotation.
Why It Happens: While you rotate a subscription's secret in the portal, svix-signature carries more than one signature, and code that reads only the first entry can fail.
Workarounds:
- Check every entry in the header, as the sample does.
How Hookdeck Can Help: Hookdeck checks every v1 signature in the header, so a rotation does not cause failed verifications at the gateway.
Firewall rules depend on Svix's IPs
The Problem: Deliveries are blocked by a firewall that was configured for Stitch's API addresses.
Why It Happens: Webhooks come from Svix, not from Stitch's own infrastructure, and Stitch points you to Svix's EU-region IP list rather than publishing one.
Workarounds:
- Allow Svix's EU addresses, and use mTLS if your policy needs stronger sender authentication.
How Hookdeck Can Help: With Hookdeck in front, your firewall only needs to accept traffic from Hookdeck.
Best practices
Verify against the raw body
Stitch stresses this. Use express.raw() in Express, Buffer.from(await request.arrayBuffer()) in a Next.js route handler, or await request.body() in FastAPI, and parse only after verification.
Record every payload
Stitch recommends keeping webhook payloads for records and support investigations. Store the raw body and headers before processing.
Acknowledge fast and process asynchronously
Return 200 once the payload is stored, and update your order or payout records from a queue. See why to process webhooks asynchronously.
Process idempotently
Use the payment, refund or disbursement identifier to apply each status change once. See our guide to webhook idempotency.
Branch on status, not on event type alone
transaction covers success, failure and cancellation, and disbursement covers reversal as well as completion. Read the status field before you mark anything as paid.
Conclusion
Stitch delivers payment, refund, payout and consent updates through Svix, signed with base64 HMAC-SHA256 over svix-id, svix-timestamp and the raw body using each subscription's whsec_ secret. Verify every request, handle both payload shapes, read the status before acting, and process each update once.
Hookdeck Event Gateway sits in front of your receiver, verifies Stitch's signatures, and holds each update until your service has processed it.
Get started with Hookdeck for free and handle Stitch webhooks reliably in minutes.