Gareth Wilson Gareth Wilson

Guide to Stitch Webhooks: Features and Best Practices

Published


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

FeatureDetails
Delivery platformSvix (Stitch's Svix account is in the EU region)
SubscriptionPer client ID: GraphQL clientWebhookAdd (scope client_webhook_manage) or the Svix consumer portal (scope client_webhooks_dashboard)
Event typespayment, payment.confirmation, payment-consent-request, transaction, collection, refund, disbursement, plus three deprecated types
Payload shapesAn {id, type, datetime, data} envelope for three types, a GraphQL-shaped body for the rest
Headerssvix-id, svix-timestamp, svix-signature
SchemeBase64 HMAC-SHA256 over {svix_id}.{svix_timestamp}.{raw body}
SecretOne whsec_ secret per subscription
AcknowledgementHTTP 200
RetriesSvix's retry policy, with exponential backoff
Extra authenticationOptional mTLS client certificate and custom headers, per subscription
Source IPsSvix's EU-region addresses

Common events

TypeProductsSent when
paymentPay by Bank, Capitec Pay, Card (once-off), Manual EFT, CashA payment request is completed, cancelled or expires
payment.confirmationThe same, with a Stitch intermediary accountFunds for a payment request are received (PaymentReceived, PaymentUnsettled)
payment-consent-requestCard, Capitec Pay Recurring, DebiCheckA consent is processing, granted, failed, cancelled, expired, revoked or paused
transactionCard, Capitec Pay Recurring, DebiCheckA transaction reaches SUCCESS, FAILURE or CANCELLED
collectionBatch collections (subscriptions)An individual collection in a batch is completed, failed or cancelled
refundRefunds for Pay by Bank, Card and WalletA refund is submitted, completed, errors or is paused
disbursementDisbursementsA 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:

  1. Request a client token with the client_webhooks_dashboard scope and run the webhookLogin query. It returns a one-time link that logs you in to the Svix consumer portal.
  2. Add an endpoint with your URL and one or more event types, then click Create.
  3. Copy the Signing Secret from the endpoint's details page and store it server-side, for example as STITCH_WEBHOOK_SECRET.
  4. 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 200 Svix 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 type first. If it is absent, route on the key under data.client.
  • Give payment and payment.confirmation separate 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's id, 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.