Build custom workflows with donation webhooks
Webhooks require Premium Access. After your 7-day free trial ends, upgrade to Premium to unlock this feature.
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:
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.
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.
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 in the webhook editor decide which donations this webhook goes out for. Every rule is a condition on the donation or the donor:
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.
A perk rule no longer prevents a webhook from being a perk's action. Adding 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.
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".
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. |
incentives | array, optional | Frozen purchased lines with each perk's ID, title, unit price, quantity, and total. |
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. |
{
"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",
"incentives": [
{
"incentiveId": "9c2f8a41-6d3b-4e15-9a70-2f8b1c4d5e6a",
"incentiveTitle": "VIP Discord role",
"unitAmount": 1000,
"currency": "USD",
"quantity": 1,
"lineTotal": 1000
}
],
"provider": "stripe",
"providerPaymentId": "pi_3Pa1B2c3D4e5F6g7",
"deliveryId": "5f3c1d9e8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d",
"timestamp": "2026-07-10T10:00:00.000Z"
}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.
Deliveries that were queued before the perk keys existed replay without them, so treat a missing
incentiveId or incentiveTitle the same as null. The incentives array is also omitted for
donations recorded before cart snapshots existed.
For a combined donation, incentives is the authoritative list. The legacy incentiveId and
incentiveTitle fields remain useful for a single-perk donation; they are null when a
donor-triggered webhook covers several purchased perks. A webhook action attached to one perk still
names that action's perk in the legacy fields and includes the full purchased list in incentives.
Structured request bodies that interpolate {{donation.incentiveTitle}} get a joined summary of
every purchased line (for example Shout-out, 50 push-ups ×3), so a stacked cart does not render
as an empty string. {{donation.incentiveId}} stays the single-perk identifier and is empty for a
combined donation — branch on incentives when you need every line.
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), 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:
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:
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.If you'd rather not branch at all, scope the webhook instead: 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 Perk rule evaluates all lines in a combined donation: is and is one of match when any purchased line matches, while is not matches only when none of them do.
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.
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.
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.
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). |
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:
X-Zerocut-Timestamp and reject anything outside a ±5-minute window of
your own clock. This blocks replays.HMAC_SHA256(secret, timestamp + "." + rawBody) and hex-encode it, prefixed with v1=.X-Zerocut-Signature using a timing-safe comparison with an equal-length
guard (for example crypto.timingSafeEqual) — never ===.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.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.
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);zwhsec_...) is shown exactly once, when you create the webhook or
rotate its secret. ZeroCut does not expose it again.ZEROCUT_WEBHOOK_SECRET, a
secrets manager, etc.). Never commit it or log it.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.ZeroCut linearizes each outbound attempt immediately before network I/O:
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.