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 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.
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.
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 (optional) | Present only for Stripe donations — the Stripe Checkout Session ID. |
paypalOrderId | string (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.
{
"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"
}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.