# 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](/webhooks/platforms/guide-to-svix-webhooks-features-and-best-practices). 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_webhooks_dashboard`) |
| 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_id}.{svix_timestamp}.{raw body}` |
| 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:

```json
{
  "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.

```javascript
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:

```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](/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Process idempotently

Use the payment, refund or disbursement identifier to apply each status change once. See our [guide to webhook idempotency](/webhooks/guides/implement-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](https://hookdeck.com) sits in front of your receiver, verifies Stitch's signatures, and holds each update until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Stitch webhooks reliably in minutes.