# 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 the custom input fields the donor submitted, the perk the donation bought (if any), and
  the payment identifier (`provider` and `providerPaymentId`). 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.

## Match Rules [#match-rules]

**Match rules** in the webhook editor decide which donations this webhook goes out for. Every rule
is a condition on the donation or the donor:

* **Donation** — Donation amount, Currency, Payment provider, Billing country, Donation message,
  Perk purchase, Perk.
* **Donor** — Discord linked, Lifetime giving, Donation count.

Conditions inside a rule group are joined with **all** or **any**, and every group has to hold for
the delivery to go out. The editor writes the whole thing back to you as a sentence ("Runs only
when all of these hold…"), so you can read the rule instead of decoding it.

Rules are evaluated on **both** delivery paths — the donor-triggered webhook on your donation page
and the same webhook fired as a perk's action. Nothing in the list depends on the donor's form
input: every fact comes from the donation record itself, which the perk path holds in full.

When a perk-fired delivery doesn't match, no request goes out and the outcome is recorded rather
than lost. Expand the donation in **Recent Donations** and its **Perk actions** list shows
**Skipped** — "this delivery's match rules didn't match this donation". That is a decision, not a
failure: it isn't retried and it doesn't count against the webhook.

<Callout type="info">
  A perk rule no longer prevents a webhook from being a perk's action. Adding &#x2A;*Perk is …** used to
  hide the webhook from the perk's action picker; now it stays selectable and the rule is simply
  evaluated when the perk fires — true when the donation bought that perk, a recorded skip when it
  bought another.
</Callout>

<Callout type="warn">
  A donation without a perk never matches a **Perk** rule — "is not" included. To target perkless
  donations, use **Perk purchase** set to "the donation bought no perk".
</Callout>

## 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. |
| `incentiveId`       | string or null    | The perk this donation funded, when one was chosen — `null` otherwise.               |
| `incentiveTitle`    | string or null    | The label of that perk — `null` otherwise.                                           |
| `provider`          | string            | How the donor paid — `"stripe"` or `"paypal"`.                                       |
| `providerPaymentId` | string            | The Stripe Payment Intent ID, or the PayPal capture ID, for the payment we verified. |
| `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.      |

```json
{
  "webhookId": "b1a7c3e2-4f5d-6a7b-8c9d-0e1f2a3b4c5d",
  "webhookName": "Minecraft Server Access",
  "data": {
    "Discord ID": "steve#1234",
    "Minecraft Username": "StevePro123"
  },
  "incentiveId": "9c2f8a41-6d3b-4e15-9a70-2f8b1c4d5e6a",
  "incentiveTitle": "VIP Discord role",
  "provider": "stripe",
  "providerPaymentId": "pi_3Pa1B2c3D4e5F6g7",
  "deliveryId": "5f3c1d9e8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d",
  "timestamp": "2026-07-10T10:00:00.000Z"
}
```

<Callout type="info">
  Deliveries used to identify the payment with `sessionId` (Stripe Checkout Session) or
  `paypalOrderId` instead. Those keys are gone: they are the same identifiers a donor's browser
  carries in its return URL, and forwarding them to a third-party endpoint spread them further than
  they should go. Use `provider` and `providerPaymentId`, which name the payment itself.
</Callout>

<Callout type="info">
  Deliveries that were queued before the perk keys existed replay without them, so treat a missing
  `incentiveId` or `incentiveTitle` the same as `null`.
</Callout>

## One Webhook, Many Perks [#one-webhook-many-perks]

You don't need one URL per perk. Attach the **same** webhook as the action on every perk that should
reach your server (see [Perks](/docs/perks)), then let the payload tell you which one was bought:
`incentiveTitle` is what the donor saw, and `incentiveId` is the stable identifier that survives a
rename. Dispatch on it server-side, after you've verified the signature:

```js
switch (payload.incentiveId ?? null) {
  case "9c2f8a41-6d3b-4e15-9a70-2f8b1c4d5e6a":
    await grantVip(payload);
    break;
  case "3e7d05b2-91cc-4a88-b0f1-7d2e6a9c8b40":
    await queueMerchOrder(payload);
    break;
  case null:
    await thankDonor(payload); // a donation with no perk
    break;
  default:
    // A perk you haven't wired up yet. Log it and return 200 so ZeroCut stops retrying.
    break;
}
```

The perk keys arrive on both delivery paths:

* **Perk actions** fire after payment with no donor input, so `data` carries `incentiveId` and
  `incentiveTitle` too — those entries predate the top-level keys and stay for receivers already
  reading them. Both name the same perk.
* **Donor-triggered webhooks** (the ones with custom input fields on your donation page) fire for
  any donation and name the perk when the donor picked one, so a single endpoint can serve perk and
  perkless donations alike.

If you'd rather not branch at all, scope the webhook instead: [match rules](#match-rules) include
**Perk purchase** (yes/no) and **Perk** (is / is not / is one of, picked from your own perks), so
the request only goes out for the donations you care about. They apply to perk-fired deliveries
too, which makes the two settings compose predictably:

* A webhook attached to perk X with a **Perk is X** rule always matches when that perk fires — the
  rule restates the binding.
* The same webhook attached to perk Y instead sends nothing and records a **Skipped** outcome on
  that donation, so you can see the rule turned the delivery away rather than wonder where it went.
* A rule on a **Donor** field (say, **Lifetime giving is at least $100**) narrows a perk's
  deliveries to repeat supporters without touching the perk itself.

### Linking Perks from the Webhook [#linking-perks-from-the-webhook]

You can wire the same binding from either end. Open the webhook and its **Used by perks** list shows
every perk that currently fires it, with a toggle per perk to link or unlink. Linking is exactly
what picking this webhook in a perk's **Action** field does; unlinking removes only this webhook's
action from that perk and leaves the perk's other actions — a Discord role, a second webhook —
alone.

<Callout type="info">
  A webhook with a **required** donor input field can't be a perk's action: it shows up in neither
  the perk's action picker nor **Used by perks**. A perk fires after payment with no form in
  between, so nothing can collect that required answer. Make the field optional, or use a separate
  webhook for perks.
</Callout>

<Callout type="warn">
  Match rules never create links on their own. A rule is a condition ("only for these donations"); a
  link is an action ("this perk fires this webhook"). Adding **Perk is VIP** to a webhook does not
  attach it to that perk — ZeroCut may point out the mismatch, but you decide.
</Callout>

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