Skip to content

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

Sent when Payment confirmed, or a manual payment verified. Delivery starts right after.

requesthttp
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
bodyjson
{
  "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

Webhook events
EventSent when
order.createdA checkout was submitted and an invoice issued. Nothing is paid yet.
order.paidPayment confirmed, or a manual payment verified. Delivery starts right after.
order.deliveredEvery item in the order was delivered.
order.delivery_failedOne 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.oversoldStock ran out for a paid order, so part of it waits for a restock. Sent alongside order.delivery_failed with the same payload.
order.underpaidThe 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_claimedA 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.expiredThe invoice expired without a payment.
order.cancelledCancelled by the seller, an admin or a refunded payment. Adds reason.
order.refundedRefunded to the buyer's store balance. Adds refundedUsd, reason and partialPayment (true when only part of the total had arrived).
subscription.createdA 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.renewedThe 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.expiringThe period ends within the store's reminder window and the buyer was emailed a one-click renewal checkout. Adds renewalOrderId and renewalUrl.
subscription.cancelledThe 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.expiredThe 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.paidA buyer added funds to their store balance. Same payload as order.paid, with orderKind topup.
product.low_stockA 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_stockA product or variation sold its last unit. Same payload as product.low_stock, with remaining 0.
ticket.openedA buyer opened a support ticket. Adds ticketId, orderId, buyerEmail, subject and message.
ticket.replyA buyer replied on one of their tickets. Adds ticketId, messageId, orderId, buyerEmail, subject, message and from (buyer).
review.createdA buyer left a review. replacedAutomatic is true when it replaces an automatic rating.
review.editedA buyer edited their review while it could still be changed.
withdrawal.requestedA withdrawal was requested from the store balance, by you or by the automatic threshold.
withdrawal.processedA withdrawal was sent (with the transaction hash) or returned to the balance.
affiliate.commission.createdA 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.releasedThe hold passed (or you released it) and the amount was added to the affiliate's store balance. Same payload, with releasedAt.
affiliate.commission.reversedThe order was refunded or you reversed the commission. Adds reason and clawedBackUsd (what came back out of an already credited balance).
store.debt_limitYour balance went past the debt limit and manual payment methods were paused, or it recovered and they resumed. Adds paused, balanceUsd and debtLimitUsd.
plan.changedA paid plan was activated or renewed, or the store went back to Free when it ended. Adds plan, period and planExpiresAt.
webhook.testYou pressed Send test event in the dashboard. Never sent automatically.

Headers and payload

X-Hidden-EventX-Hidden-DeliveryX-Hidden-TimestampX-Hidden-Signature

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:

example deliveryhttp
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.

  1. 1Signed string
    X-Hidden-Timestamp . rawBody

    The timestamp header, a dot, the raw request body.

  2. 2HMAC-SHA256
    with your secret

    Digest the string with the endpoint secret.

  3. 3Compare
    in constant time

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

  1. 0First attempt fails
  2. 1Retry after 30 min
  3. 2Retry after 1 h
  4. 3Retry after 2 h
  5. 4Retry after 6 h
  6. 5Retry after 12 h
  7. 6Retry after 24 h
  8. 7Endpoint switched off after 6 failures
A failed delivery is retried 6 times: after 30 min, 1 h, 2 h, 6 h, 12 h, 24 h. After 6 failures in a row the endpoint is switched off.

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