> Documentation index: https://docs.sellhidden.com/llms.txt

# Webhooks

Source: https://docs.sellhidden.com/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 events: order.created → order.paid → order.delivered; branches: underpaid, manual_claimed, expired, cancelled, delivery_failed, oversold, refunded.

Every box is a webhook event. Most orders take the top row: created, paid, delivered.

## Events

| 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

X-Hidden-Event · X-Hidden-Delivery · X-Hidden-Timestamp · X-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:

```http
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.

Three steps: join the timestamp header, a dot and the raw body; compute HMAC-SHA256 with the endpoint secret; compare with X-Hidden-Signature in constant time.

**Node**

```javascript
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");
}
```

**Python**

```python
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**

```php
<?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"]);
```

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

Retry schedule after a failed attempt: 30 min, 1 h, 2 h, 6 h, 12 h, 24 h; the endpoint is switched off after 6 failures in a row.

[Open webhooks in the dashboard](https://dashboard.sellhidden.com/developers/webhooks)
