Developer docs
Webhooks
Hidden sends your server a signed POST for every event you subscribe to, retries when you fail, and shows every attempt in the dashboard.
What an order goes through
Add endpoints under Developers, Webhooks. Each endpoint has a label, a list of subscribed events, its own signing secret (shown once; rotate it any time) and a delivery log you can filter and resend from. Plans allow 1 endpoint on Free, 5 on Starter and unlimited on Pro and Enterprise; logs are kept 7, 14, 30 and 90 days respectively. Endpoints must be public https URLs; private and local addresses are rejected.
- order.created
- A checkout was submitted and an invoice issued. Nothing is paid yet.
- the first event of every order · then order.paid, order.underpaid, order.manual_claimed, order.expired, order.cancelled
- order.paid
- Payment confirmed, or a manual payment verified. Delivery starts right after.
- after order.created, order.underpaid, order.manual_claimed · then order.delivered, order.delivery_failed, order.oversold, order.refunded
- order.delivered
- Every item in the order was delivered.
- after order.paid, order.oversold · the order ends here
- order.delivery_failed
- One or more items could not be delivered. Adds reason (oversold or delivery_failed) and failures, one entry per item with its error. This event used to be sent as order.failed: endpoints set up before the rename were moved to that older name, so they keep receiving it as order.failed until you switch them to order.delivery_failed (listed under Older names). Wildcard subscriptions (order.* or *) receive it as order.delivery_failed, plus order.oversold for oversold orders.
- after order.paid · the order ends here
- order.oversold
- Stock ran out for a paid order, so part of it waits for a restock. Sent alongside order.delivery_failed with the same payload.
- after order.paid · then order.delivered
- order.underpaid
- The buyer sent less than the amount due. Adds receivedUsd, remainingUsd and payableUntil: the buyer can pay the rest until then, or you accept or refund what arrived.
- after order.created · then order.paid, order.refunded, order.expired, order.cancelled
- order.manual_claimed
- A buyer submitted proof for a manual payment method. Adds proof, proofImage (true when an image was attached) and manualMethod. Verify it in the dashboard.
- after order.created · then order.paid, order.cancelled
- order.expired
- The invoice expired without a payment.
- after order.underpaid, order.created · the order ends here
- order.cancelled
- Cancelled by the seller, an admin or a refunded payment. Adds reason.
- after order.underpaid, order.manual_claimed, order.created · the order ends here
- order.refunded
- Refunded to the buyer's store balance. Adds refundedUsd, reason and partialPayment (true when only part of the total had arrived).
- after order.underpaid, order.paid · the order ends here
- Usual path
- Needs attention
- Ended
Every box is a webhook event. Most orders take the top row: created, paid, delivered.
Events
order.paid
Send a test eventSent when Payment confirmed, or a manual payment verified. Delivery starts right after.
POST https://example.com/hidden-webhook
Content-Type: application/json
X-Hidden-Event: order.paid
X-Hidden-Delivery: dlv_01j9k2…
X-Hidden-Timestamp: 1727344800
X-Hidden-Signature: sha256=3f8a…c21e{
"id": "evt_9f2c…",
"event": "order.paid",
"createdAt": "2026-09-26T10:01:12.000Z",
"data": {
"orderId": "clx…",
"orderKind": "product",
"storeId": "clx…",
"status": "paid",
"buyerEmail": "buyer@example.com",
"buyerName": null,
"discordUserId": null,
"grossUsd": 29,
"discountUsd": 0,
"buyerTotalUsd": 29,
"sellerNetUsd": 29,
"platformFeeUsd": 0,
"platformFeePaidByBuyerUsd": 0,
"feeBornBy": "seller",
"networkFeeUsd": 0.42,
"couponCode": null,
"paymentMethod": "crypto",
"paidWithBalance": false,
"payCurrency": "USDT",
"network": "…",
"txHash": "7c1e…a90b",
"paidAmountUsd": 29,
"paidCurrency": "USDT",
"paidAt": "2026-09-26T10:01:12.000Z",
"deliveredAt": null,
"refundedAt": null,
"createdAt": "2026-09-26T09:58:40.000Z",
"customFields": {},
"items": [
{
"itemId": "clx…",
"productId": "clx…",
"productSlug": "lifetime-license",
"title": "Lifetime license",
"variantId": "clx…",
"variantLabel": "Lifetime",
"quantity": 1,
"unitPriceUsd": 29,
"deliveryStatus": "pending"
}
]
}
}import { createHmac, timingSafeEqual } from "node:crypto";
// Use the raw request body exactly as received (do not JSON.parse first).
export function verifyHiddenSignature(rawBody: string, headers: Headers, secret: string) {
const header = headers.get("x-hidden-signature") ?? ""; // "sha256=<hex>"
const timestamp = headers.get("x-hidden-timestamp") ?? ""; // unix seconds
// Reject stale deliveries (replay protection), 5 minute tolerance.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const given = header.replace(/^sha256=/, "");
if (given.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(given, "hex"), Buffer.from(expected, "hex"));
}
// Next.js route handler
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyHiddenSignature(raw, req.headers, process.env.HIDDEN_WEBHOOK_SECRET!)) {
return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(raw);
// De-duplicate on the X-Hidden-Delivery header before acting.
// ...handle event.data
return new Response("ok");
}import hmac, hashlib, time
def verify_hidden_signature(raw_body: bytes, headers: dict, secret: str) -> bool:
given = headers.get("x-hidden-signature", "").removeprefix("sha256=")
ts = headers.get("x-hidden-timestamp", "0")
if abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(given, expected)<?php
// Use the raw request body exactly as received (do not json_decode first).
$raw = file_get_contents("php://input");
$given = substr($_SERVER["HTTP_X_HIDDEN_SIGNATURE"] ?? "", strlen("sha256="));
$timestamp = $_SERVER["HTTP_X_HIDDEN_TIMESTAMP"] ?? "0";
if (abs(time() - (int) $timestamp) > 300) { http_response_code(401); exit; } // stale: replay protection
$expected = hash_hmac("sha256", $timestamp . "." . $raw, getenv("HIDDEN_SECRET"));
if (!hash_equals($expected, $given)) { http_response_code(401); exit; }
$order = json_decode($raw, true);
if (!empty($order["test"])) { /* a test from the product editor: nothing was paid */ }
header("Content-Type: application/json");
echo json_encode(["deliveredData" => "Your licence: HDN-4K2P-88QL-9XQ1"]);Shown in Node
| Event | Sent when |
|---|---|
| order.created | A checkout was submitted and an invoice issued. Nothing is paid yet. |
| order.paid | Payment confirmed, or a manual payment verified. Delivery starts right after. |
| order.delivered | Every item in the order was delivered. |
| order.delivery_failed | One or more items could not be delivered. Adds reason (oversold or delivery_failed) and failures, one entry per item with its error. This event used to be sent as order.failed: endpoints set up before the rename were moved to that older name, so they keep receiving it as order.failed until you switch them to order.delivery_failed (listed under Older names). Wildcard subscriptions (order.* or *) receive it as order.delivery_failed, plus order.oversold for oversold orders. |
| order.oversold | Stock ran out for a paid order, so part of it waits for a restock. Sent alongside order.delivery_failed with the same payload. |
| order.underpaid | The buyer sent less than the amount due. Adds receivedUsd, remainingUsd and payableUntil: the buyer can pay the rest until then, or you accept or refund what arrived. |
| order.manual_claimed | A buyer submitted proof for a manual payment method. Adds proof, proofImage (true when an image was attached) and manualMethod. Verify it in the dashboard. |
| order.expired | The invoice expired without a payment. |
| order.cancelled | Cancelled by the seller, an admin or a refunded payment. Adds reason. |
| order.refunded | Refunded to the buyer's store balance. Adds refundedUsd, reason and partialPayment (true when only part of the total had arrived). |
| subscription.created | A buyer paid the first period of a subscription variation, or started its free trial. The payload carries the subscription (id, status, interval, quantity, currentPeriodStart, currentPeriodEnd, graceEndsAt, trial), the buyer and the product, plus orderId and the keys delivered so far. |
| subscription.renewed | The buyer paid a renewal (from the email link, the order page or automatically from store balance). Adds the renewal orderId and the new period dates. |
| subscription.expiring | The period ends within the store's reminder window and the buyer was emailed a one-click renewal checkout. Adds renewalOrderId and renewalUrl. |
| subscription.cancelled | The buyer or the seller cancelled the subscription. It stays active until currentPeriodEnd (cancelAtPeriodEnd) unless the seller ended it at once. Adds cancelledBy and endsAt. |
| subscription.expired | The period and its grace days passed without a renewal, or a cancelled subscription reached its period end. Discord roles granted by it are removed; the payload lists every license key it delivered (keys) so you can disable them on your side. |
| topup.paid | A buyer added funds to their store balance. Same payload as order.paid, with orderKind topup. |
| product.low_stock | A product or variation has 3 units or fewer left after a sale. Adds productId, productSlug, title, variantId, variantLabel, deliveryType, remaining and threshold. |
| product.out_of_stock | A product or variation sold its last unit. Same payload as product.low_stock, with remaining 0. |
| ticket.opened | A buyer opened a support ticket. Adds ticketId, orderId, buyerEmail, subject and message. |
| ticket.reply | A buyer replied on one of their tickets. Adds ticketId, messageId, orderId, buyerEmail, subject, message and from (buyer). |
| review.created | A buyer left a review. replacedAutomatic is true when it replaces an automatic rating. |
| review.edited | A buyer edited their review while it could still be changed. |
| withdrawal.requested | A withdrawal was requested from the store balance, by you or by the automatic threshold. |
| withdrawal.processed | A withdrawal was sent (with the transaction hash) or returned to the balance. |
| affiliate.commission.created | A paid order was attributed to an affiliate, or you added a manual adjustment. Adds commissionId, affiliateId, affiliateCode, affiliateEmail, orderId, amountUsd, baseUsd, pct, status (pending, or flagged when a fraud guard tripped), flagReason and releaseAt. |
| affiliate.commission.released | The hold passed (or you released it) and the amount was added to the affiliate's store balance. Same payload, with releasedAt. |
| affiliate.commission.reversed | The order was refunded or you reversed the commission. Adds reason and clawedBackUsd (what came back out of an already credited balance). |
| store.debt_limit | Your balance went past the debt limit and manual payment methods were paused, or it recovered and they resumed. Adds paused, balanceUsd and debtLimitUsd. |
| plan.changed | A paid plan was activated or renewed, or the store went back to Free when it ended. Adds plan, period and planExpiresAt. |
| webhook.test | You pressed Send test event in the dashboard. Never sent automatically. |
Headers and payload
Deliveries are JSON POSTs. X-Hidden-Event names the event, X-Hidden-Delivery is a unique id per delivery attempt, X-Hidden-Timestamp is unix seconds and X-Hidden-Signature carries the HMAC. Order events share one data shape:
POST https://example.com/hooks/hidden
Content-Type: application/json
User-Agent: Hidden-Webhooks/1.0 (+https://sellhidden.com)
X-Hidden-Event: order.paid
X-Hidden-Timestamp: 1790000000
X-Hidden-Signature: sha256=3f8a…c21e
X-Hidden-Delivery: whd_01j9…
{
"id": "evt_9f2c…",
"event": "order.paid",
"createdAt": "2026-09-26T10:01:12.000Z",
"data": {
"orderId": "clx…",
"orderKind": "product",
"storeId": "clx…",
"status": "paid",
"buyerEmail": "buyer@example.com",
"buyerName": null,
"discordUserId": null,
"grossUsd": 29,
"discountUsd": 0,
"buyerTotalUsd": 29,
"sellerNetUsd": 29,
"platformFeeUsd": 0,
"platformFeePaidByBuyerUsd": 0,
"feeBornBy": "seller",
"networkFeeUsd": 0.42,
"couponCode": null,
"paymentMethod": "crypto",
"paidWithBalance": false,
"payCurrency": "USDT",
"network": "TRON",
"txHash": "7c1e…a90b",
"paidAmountUsd": 29,
"paidCurrency": "USDT",
"paidAt": "2026-09-26T10:01:12.000Z",
"deliveredAt": null,
"refundedAt": null,
"createdAt": "2026-09-26T09:58:40.000Z",
"customFields": {},
"items": [
{ "itemId": "clx…", "productId": "clx…", "productSlug": "lifetime-license", "title": "Lifetime license", "variantId": "clx…", "variantLabel": "Lifetime", "quantity": 1, "unitPriceUsd": 29, "deliveryStatus": "pending" }
]
}
}Respond with any 2xx status within 15 seconds. Anything else counts as a failure. The body you return is ignored.
Orders a seller creates with Test order in the dashboard send the same events with "test": true in data (and in the dynamic delivery request), and have test: true in the API. Nothing was paid for them: never fulfil, book or count a test order.
Verifying signatures
Every delivery is signed with HMAC-SHA256. The signed string is the timestamp header, a dot, and the raw request body. Compute the digest with the endpoint secret, compare it in constant time with the header value (after the sha256= prefix), and reject anything older than a few minutes.
- 1Signed string
X-Hidden-Timestamp . rawBodyThe timestamp header, a dot, the raw request body.
- 2HMAC-SHA256
with your secretDigest the string with the endpoint secret.
- 3Compare
in constant timeAgainst X-Hidden-Signature after sha256=.
import { createHmac, timingSafeEqual } from "node:crypto";
// Use the raw request body exactly as received (do not JSON.parse first).
export function verifyHiddenSignature(rawBody: string, headers: Headers, secret: string) {
const header = headers.get("x-hidden-signature") ?? ""; // "sha256=<hex>"
const timestamp = headers.get("x-hidden-timestamp") ?? ""; // unix seconds
// Reject stale deliveries (replay protection), 5 minute tolerance.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const given = header.replace(/^sha256=/, "");
if (given.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(given, "hex"), Buffer.from(expected, "hex"));
}
// Next.js route handler
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyHiddenSignature(raw, req.headers, process.env.HIDDEN_WEBHOOK_SECRET!)) {
return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(raw);
// De-duplicate on the X-Hidden-Delivery header before acting.
// ...handle event.data
return new Response("ok");
}import hmac, hashlib, time
def verify_hidden_signature(raw_body: bytes, headers: dict, secret: str) -> bool:
given = headers.get("x-hidden-signature", "").removeprefix("sha256=")
ts = headers.get("x-hidden-timestamp", "0")
if abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(given, expected)<?php
// Use the raw request body exactly as received (do not json_decode first).
$raw = file_get_contents("php://input");
$given = substr($_SERVER["HTTP_X_HIDDEN_SIGNATURE"] ?? "", strlen("sha256="));
$timestamp = $_SERVER["HTTP_X_HIDDEN_TIMESTAMP"] ?? "0";
if (abs(time() - (int) $timestamp) > 300) { http_response_code(401); exit; } // stale: replay protection
$expected = hash_hmac("sha256", $timestamp . "." . $raw, getenv("HIDDEN_SECRET"));
if (!hash_equals($expected, $given)) { http_response_code(401); exit; }
$order = json_decode($raw, true);
if (!empty($order["test"])) { /* a test from the product editor: nothing was paid */ }
header("Content-Type: application/json");
echo json_encode(["deliveredData" => "Your licence: HDN-4K2P-88QL-9XQ1"]);Shown in Node
Retries and duplicates
A delivery that times out or gets a 5xx, 408 or 429 response is retried up to six times: about 30 minutes, then 1, 2, 6, 12 and 24 hours after each failed attempt. Retries are sent in batches every 30 minutes, so one can arrive up to 30 minutes after it is due. Other 4xx responses are treated as permanent and not retried. Every retry carries the same X-Hidden-Delivery id with a fresh timestamp and signature, so store that id and ignore duplicates. After 6 consecutive failures the endpoint is switched off; fix your receiver and switch it back on in the dashboard.
The dashboard log shows every attempt with its status code and when the next retry is due. Use Send test event to receive a signed webhook.test delivery, and Resend on any failed row to replay its exact payload.
- 0First attempt fails
- 1Retry after 30 min
- 2Retry after 1 h
- 3Retry after 2 h
- 4Retry after 6 h
- 5Retry after 12 h
- 6Retry after 24 h
- 7Endpoint switched off after 6 failures
Get help
Send us the endpoint, the request or delivery id and the response you got. Security findings go through responsible disclosure. Live platform status is on the support page.
See also