# Webhooks

> Build custom workflows with donation webhooks

- Canonical page: https://zerocut.gg/docs/webhooks
- Markdown version: https://zerocut.gg/docs/webhooks.md



# Webhooks [#webhooks]

<Callout type="info">
  Webhooks require Premium Access. After your 7-day free trial ends, upgrade to Premium to unlock
  this feature.
</Callout>

Webhooks let you collect custom information from a donor and forward it to your own server after a
successful donation. This is perfect for building custom integrations, like:

* Adding donors to a Minecraft whitelist.
* Triggering in-game events.
* Updating a custom database.

## How It Works [#how-it-works]

Webhooks fire **after a donation is paid and verified**. Once the donor submits your custom fields,
ZeroCut confirms the payment (a paid Stripe Checkout Session or a completed PayPal order) and then
sends a `POST` request to the URL you configured.

The request body is JSON and contains the webhook's identity, the custom fields the donor filled
out, the identifier of the verified payment, and the delivery metadata used for signing.

<Callout type="warn">
  The payload does **not** include the donation amount, currency, donor name, or donation message.
  It carries only the custom input fields the donor submitted plus the payment identifier
  (`sessionId` or `paypalOrderId`). If you need the amount or payer details, look them up in your
  own records or via the payment provider using that identifier.
</Callout>

## Custom Input Fields [#custom-input-fields]

You can ask donors for specific information when they donate. For example, if you run a Minecraft
server, you can add a required field labelled "Minecraft Username". Each field you configure has a
**Label** (up to 100 characters), an optional placeholder, and a required toggle. A webhook can have
up to 20 fields, and each submitted value can be up to 1000 characters.

The donor's answers are sent in the `data` object, keyed by the field label. Keys are sorted
alphabetically.

## Setting Up a Webhook [#setting-up-a-webhook]

1. Go to **Settings > Donations**.
2. Find the **Custom Webhooks** card.
3. Click **Create Webhook**.
4. Enter a **Name**, an optional **Description**, and your **Webhook URL**.
5. Add any **Input Fields** you need (Label, optional Placeholder, and whether it's required).
6. Save. ZeroCut generates a signing secret and shows it to you once — copy it now (see below).

## Payload [#payload]

The JSON body has these keys:

| Key             | Type                     | Notes                                                                                |
| --------------- | ------------------------ | ------------------------------------------------------------------------------------ |
| `webhookId`     | string                   | The webhook's UUID.                                                                  |
| `webhookName`   | string                   | The name you gave the webhook.                                                       |
| `data`          | object of strings        | Donor-submitted custom fields, keyed by field label. Keys are sorted alphabetically. |
| `sessionId`     | string &#x2A;(optional)* | Present only for Stripe donations — the Stripe Checkout Session ID.                  |
| `paypalOrderId` | string &#x2A;(optional)* | Present only for PayPal donations — the PayPal order ID.                             |
| `deliveryId`    | string                   | Stable ID for this delivery. Matches the `X-Zerocut-Delivery-Id` header.             |
| `timestamp`     | string                   | ISO-8601 time the request was signed. Matches the `X-Zerocut-Timestamp` header.      |

Exactly one of `sessionId` or `paypalOrderId` is present, depending on how the donor paid.

```json
{
  "webhookId": "b1a7c3e2-4f5d-6a7b-8c9d-0e1f2a3b4c5d",
  "webhookName": "Minecraft Server Access",
  "data": {
    "Discord ID": "steve#1234",
    "Minecraft Username": "StevePro123"
  },
  "sessionId": "cs_live_a1B2c3D4e5F6g7H8",
  "deliveryId": "5f3c1d9e8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d",
  "timestamp": "2026-07-10T10:00:00.000Z"
}
```

## Headers [#headers]

Every delivery includes these headers:

| Header                  | Value                                                           |
| ----------------------- | --------------------------------------------------------------- |
| `Content-Type`          | `application/json`                                              |
| `X-Zerocut-Timestamp`   | ISO-8601 timestamp (the same value as `timestamp` in the body). |
| `X-Zerocut-Delivery-Id` | Stable delivery ID. Use it as an idempotency key.               |
| `X-Zerocut-Signature`   | `v1=<hex>`, an HMAC-SHA256 signature (see below).               |

## Verifying a Delivery [#verifying-a-delivery]

Anyone who knows your URL could `POST` to it. Verify the signature so you only act on requests that
genuinely came from ZeroCut.

The signature is computed as:

```
v1= + hex( HMAC_SHA256(secret, `${timestamp}.${rawBody}`) )
```

where `secret` is the signing secret shown when you created (or rotated) the webhook, `timestamp`
is the `X-Zerocut-Timestamp` header value, and `rawBody` is the exact request body.

To verify, on your server:

1. Read the **raw request bytes** before any JSON parsing or re-serialisation. Re-encoding the body
   changes the bytes and breaks the signature.
2. Parse the ISO-8601 `X-Zerocut-Timestamp&#x60; and reject anything outside a **±5-minute** window of
   your own clock. This blocks replays.
3. Compute `HMAC_SHA256(secret, timestamp + "." + rawBody)` and hex-encode it, prefixed with `v1=`.
4. Compare it against `X-Zerocut-Signature` using a **timing-safe** comparison with an equal-length
   guard (for example `crypto.timingSafeEqual`) — never `===`.
5. Treat `X-Zerocut-Delivery-Id` as an **idempotency key**. Deliveries are retried (with exponential
   backoff, up to 12 attempts), so store the ID and ignore any you have already processed.

<Callout type="warn">
  Retries reuse the **same** `X-Zerocut-Delivery-Id` but generate a **new** `X-Zerocut-Timestamp`
  and signature each time. Verify the signature on every attempt, and dedupe on the delivery ID —
  not on the timestamp or the signature.
</Callout>

## Example (Node / Express) [#example-node--express]

```js
const express = require("express");
const { createHmac, timingSafeEqual } = require("node:crypto");

const SECRET = process.env.ZEROCUT_WEBHOOK_SECRET;
const REPLAY_WINDOW_MS = 5 * 60 * 1000;

// Swap this in-memory set for Redis or a database in production so dedupe
// survives restarts and works across multiple instances.
const seenDeliveries = new Set();

const app = express();

// The raw body is required: parsing then re-serialising JSON changes the bytes
// and invalidates the signature.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("X-Zerocut-Timestamp");
  const deliveryId = req.get("X-Zerocut-Delivery-Id");
  const signature = req.get("X-Zerocut-Signature");
  const rawBody = req.body; // Buffer, thanks to express.raw

  if (!SECRET || !timestamp || !deliveryId || !signature) {
    return res.sendStatus(400);
  }

  // 1. Reject stale or future timestamps (guards against replays).
  const sentAt = Date.parse(timestamp);
  if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > REPLAY_WINDOW_MS) {
    return res.sendStatus(400);
  }

  // 2. Recompute the signature over the raw bytes and compare in constant time.
  const expected =
    "v1=" + createHmac("sha256", SECRET).update(`${timestamp}.`).update(rawBody).digest("hex");
  const expectedBuf = Buffer.from(expected);
  const actualBuf = Buffer.from(signature);
  if (expectedBuf.length !== actualBuf.length || !timingSafeEqual(expectedBuf, actualBuf)) {
    return res.sendStatus(401);
  }

  // 3. Idempotency: deliveries are retried, so ignore ones we've already handled.
  if (seenDeliveries.has(deliveryId)) {
    return res.sendStatus(200);
  }
  seenDeliveries.add(deliveryId);

  const payload = JSON.parse(rawBody.toString("utf8"));
  // payload.data holds the donor's answers, e.g. payload.data["Minecraft Username"].
  console.log(`Verified delivery ${deliveryId} for "${payload.webhookName}"`);

  return res.sendStatus(200);
});

app.listen(3000);
```

## The Signing Secret [#the-signing-secret]

* The secret (it looks like `zwhsec_...`) is shown **exactly once**, when you create the webhook or
  rotate its secret. ZeroCut does not expose it again.
* Copy it and store it somewhere safe (an environment variable like `ZEROCUT_WEBHOOK_SECRET`, a
  secrets manager, etc.). Never commit it or log it.
* Rotation stops future and not-yet-started deliveries from using the old secret. An attempt that
  already crossed ZeroCut's durable send boundary may still arrive—or be retried after an ambiguous
  timeout/process crash—with the old secret and the same `X-Zerocut-Delivery-Id`. During rotation,
  accept both the previous and new secret for at least the delivery retry window, deduplicate by
  delivery ID, then retire the previous secret. New deliveries use only the new secret.
* Webhooks created before request signing existed have no secret and are flagged **Not signed** in
  the dashboard. They are unavailable until you click **Generate signing secret**.

## Delivery Ordering During Changes [#delivery-ordering-during-changes]

ZeroCut linearizes each outbound attempt immediately before network I/O:

* If deletion, URL/name change, secret rotation, refund, or dispute wins first, that uncommitted
  attempt is canceled and its encrypted snapshot is destroyed.
* If the attempt wins first, it is allowed to finish with its immutable URL, payload, and signing
  secret even when the configuration or payment changes immediately afterward.
* A confirmed non-2xx HTTP response releases that attempt boundary before a retry is scheduled, so
  a later configuration/payment change can cancel the next attempt.
* A network timeout or process death is ambiguous: the receiver may already have accepted the
  request. ZeroCut preserves the boundary and retries the same delivery ID at least once. This is
  why receiver-side durable idempotency is required.

These rules prevent an owner change from racing a request that has not started while preserving an
honest at-least-once contract for requests that may already have crossed the network.
