Skip to content
hidden

Docs

Developer documentation

Everything you need to wire Hidden into your own tools: a public read API for storefront data, an authenticated API for your orders and customers, signed webhooks for events, dynamic delivery for provisioning, and a Buy button you can embed anywhere. All of it is on every plan.

Getting started

The API lives under https://sellhidden.com/api/v1. Public endpoints need no key and are safe to call from a browser: they serve products, categories, visible reviews and recent sales for one store. Authenticated endpoints need a personal access key and expose your orders, customers, tickets, coupons and analytics; call them from your server only.

Every response is JSON. Times are ISO 8601 in UTC. Money is USD with two decimals unless a field says otherwise. Ids are opaque strings; do not parse them.

discoveryhttp
GET https://sellhidden.com/api/v1
# → lists every endpoint with its full URL

Authentication

Create a key under Developers, API keys in your dashboard. Keys start with sk_live_, are scoped to the store that issued them, and are shown once: we keep only a SHA-256 hash and the first characters for display. Up to 10 keys can be active per store; revoking one keeps it listed for the audit trail.

curlbash
curl https://sellhidden.com/api/v1/orders?status=paid&limit=25 \
  -H "Authorization: Bearer sk_live_…"

A missing or invalid key returns 401 with code: "unauthorized"; a revoked key returns 401 with code: "key_revoked"; a suspended store returns 403. Rotate a key immediately if it leaks: create a new one, switch your integration, then revoke the old one.

Responses and errors

Every body carries an ok boolean. Successful responses put their data next to it; failures carry a human-readable error and a stable code you can branch on.

successjson
{ "ok": true, "products": [ … ], "nextCursor": "Y2xxazF…", "hasMore": true }
failurejson
{ "ok": false, "error": "Invalid API key.", "code": "unauthorized" }
StatusCodeMeaning
200—Success.
400bad_requestA parameter is malformed; the error says which.
401unauthorized, key_revokedMissing, invalid or revoked key.
403store_suspendedThe store cannot be served.
404not_foundUnknown store, product, order, customer or ticket.
429rate_limitedToo many requests; honour Retry-After.

Rate limits

Public endpoints allow 120 requests per minute per IP address and are cached for 60 seconds (cache-control: public, s-maxage=60), so a busy page rarely hits the origin. Authenticated endpoints allow 120 requests per minute per key. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 also includes Retry-After in seconds.

Pagination

List endpoints are cursor-based and return newest first. Pass limit (default and maximum vary per endpoint) and, for the next page, the nextCursor from the previous response. Cursors are opaque; do not build them yourself.

paginghttp
# First page
GET https://sellhidden.com/api/v1/orders?limit=50

# Next page: pass the cursor from the previous response
GET https://sellhidden.com/api/v1/orders?limit=50&cursor=Y2xxazF…

# Stop when "hasMore" is false (nextCursor is then null)

Public API

No key, CORS open, cached 60 seconds at the edge. Only live stores and active products are served; a suspended store is a 404. Buyer emails in reviews are masked and order ids are never exposed here.

GET/public/products/{storeSlug}No key

Active products in storefront order. displayPriceUsd is the lowest variation price (or the base price when there are no variations). For serial-key products stock is the number of unused keys. When the seller hides sold-out products on the storefront, they are omitted here too.

Query parameters
Query parametersTypeDescription
categorySlugstringOnly products in this category.
limit1..100Default 50.
cursorstringFrom the previous page.
200 OKjson
{
  "ok": true,
  "store": { "slug": "my-store", "name": "My Store" },
  "products": [ { "slug": "lifetime-license", "name": "Lifetime license", "displayPriceUsd": 29, "inStock": true, … } ],
  "nextCursor": null,
  "hasMore": false
}

GET/public/products/{storeSlug}/{slug}No key

One product with its variations, category, stock and rating. This is the call the live-price embed makes.

200 OKjson
{
  "ok": true,
  "store": { "slug": "my-store", "name": "My Store" },
  "product": {
    "id": "clx…",
    "slug": "lifetime-license",
    "name": "Lifetime license",
    "description": "…",
    "priceUsd": 49,
    "displayPriceUsd": 29,
    "compareAtUsd": null,
    "currency": "USD",
    "coverImage": "https://…/cover.png",
    "deliveryType": "keys",
    "inStock": true,
    "stock": 120,
    "variations": [
      { "id": "clx…", "label": "1 device", "priceUsd": 29, "compareAtUsd": null, "inStock": true, "stock": 80, "description": null },
      { "id": "clx…", "label": "5 devices", "priceUsd": 49, "compareAtUsd": 69, "inStock": true, "stock": 40, "description": "Team pack" }
    ],
    "category": { "id": "clx…", "name": "Software", "slug": "software" },
    "salesCount": 312,
    "reviewCount": 41,
    "averageRating": 4.8,
    "url": "https://sellhidden.com/my-store/p/lifetime-license",
    "createdAt": "2026-06-01T10:00:00.000Z",
    "updatedAt": "2026-09-20T08:12:44.000Z"
  }
}

GET/public/categories/{storeSlug}No key

Categories in display order with the number of active products in each. Use slug as categorySlug on the products endpoint.

200 OKjson
{
  "ok": true,
  "store": { "slug": "my-store", "name": "My Store" },
  "categories": [
    { "id": "clx…", "name": "Software", "slug": "software", "position": 0, "productCount": 12, "createdAt": "2026-05-02T09:00:00.000Z" }
  ]
}

GET/public/reviews/{storeSlug}No key

Visible reviews with a store-wide summary. distribution counts one to five stars in order. Every review is tied to a paid order; sellers can hide reviews but never edit them.

Query parameters
Query parametersTypeDescription
productSlugstringOnly reviews of this product.
rating1..5Only this star rating.
limit1..100Default 20.
cursorstringFrom the previous page.
200 OKjson
{
  "ok": true,
  "store": { "slug": "my-store", "name": "My Store" },
  "summary": { "count": 41, "average": 4.8, "distribution": [0, 1, 1, 5, 34] },
  "reviews": [
    {
      "id": "clx…",
      "productId": "clx…",
      "productSlug": "lifetime-license",
      "productName": "Lifetime license",
      "orderId": null,
      "buyerEmail": "j***e@e***.com",
      "rating": 5,
      "comment": "Delivered in seconds.",
      "verified": true,
      "visible": true,
      "createdAt": "2026-09-19T15:04:00.000Z",
      "updatedAt": "2026-09-19T15:04:00.000Z"
    }
  ],
  "nextCursor": null,
  "hasMore": false
}

GET/public/recent-sales/{storeSlug}No key

Social-proof feed of the latest paid items. Times are rounded to the hour and no buyer data is included. Returns an empty list when the seller turned the recent-sales widget off.

Query parameters
Query parametersTypeDescription
limit1..25Default 10.
200 OKjson
{
  "ok": true,
  "store": { "slug": "my-store", "name": "My Store" },
  "sales": [
    { "product": { "slug": "lifetime-license", "name": "Lifetime license", "coverImage": null }, "variationLabel": "1 device", "quantity": 1, "country": "DE", "at": "2026-09-26T10:00:00.000Z" }
  ]
}

Authenticated API

Send Authorization: Bearer sk_live_…. Everything is scoped to the key's store. Responses are never cached (cache-control: no-store). Read-only: writes (products, coupons, replies) happen in the dashboard, and orders are created by buyers at checkout.

GET/ordersBearer key

Orders newest first, each with items, amounts, payment details, custom checkout fields and delivery status per item.

Query parameters
Query parametersTypeDescription
statusstringOne of pending, awaiting_verification, paid, delivered, cancelled, underpaid, oversold, expired, failed, refunded.
kindproduct | topupProduct orders or buyer balance top-ups.
emailstringBuyer email contains this text.
sinceISO dateCreated at or after.
untilISO dateCreated before.
limit1..100Default 25.
cursorstringFrom the previous page.

GET/orders/{id}Bearer key

One order. amounts.sellerNetUsd is what was credited to your balance; payment.manual is set for orders paid through a manual rail and carries the claim, verification and rejection timeline.

200 OKjson
{
  "ok": true,
  "order": {
    "id": "clx…",
    "kind": "product",
    "status": "delivered",
    "buyer": { "email": "buyer@example.com", "customerId": "clx…", "country": "DE" },
    "amounts": {
      "grossUsd": 29, "discountUsd": 0, "buyerTotalUsd": 29,
      "platformFeeRate": 3.8, "platformFeeUsd": 1.10, "sellerNetUsd": 27.90,
      "feeBornBy": "seller", "networkFeeUsd": null, "currencyShown": "EUR"
    },
    "payment": { "method": "crypto", "paidWithBalance": false, "paidAmountUsd": 29, "paidCurrency": "USDT", "manual": null },
    "coupon": null,
    "customFields": { "discord_id": "1234567890" },
    "items": [
      {
        "id": "clx…", "productId": "clx…", "productSlug": "lifetime-license", "name": "Lifetime license",
        "variationId": "clx…", "variationLabel": "1 device", "quantity": 1, "unitPriceUsd": 29,
        "deliveryType": "keys", "deliveryStatus": "delivered", "deliveryError": null
      }
    ],
    "refund": null,
    "paidAt": "2026-09-26T10:01:12.000Z",
    "deliveredAt": "2026-09-26T10:01:13.000Z",
    "expiresAt": null,
    "createdAt": "2026-09-26T09:58:40.000Z",
    "updatedAt": "2026-09-26T10:01:13.000Z"
  }
}

GET/categoriesBearer key

Every category with productCount (all products) and activeProductCount.

200 OKjson
{
  "ok": true,
  "store": { "slug": "my-store", "name": "My Store" },
  "categories": [
    { "id": "clx…", "name": "Software", "slug": "software", "position": 0, "productCount": 12, "createdAt": "2026-05-02T09:00:00.000Z" }
  ]
}

GET/reviewsBearer key

Same shape as the public endpoint but with unmasked buyer emails, order ids and hidden reviews included.

Query parameters
Query parametersTypeDescription
productIdstringOnly reviews of this product.
rating1..5Only this star rating.
visibletrue | falseOnly shown or only hidden reviews. Default: both.
limit1..1000Default 50.
cursorstringFrom the previous page.

GET/customersBearer key

Everyone who checked out or signed in, with lifetime aggregates: paid order count, top-up count, total spend, open tickets and store balance.

Query parameters
Query parametersTypeDescription
emailstringEmail contains this text.
hasAccounttrue | falseOnly buyers who signed in to your storefront (or only guests).
limit1..100Default 50.
cursorstringFrom the previous page.

GET/customers/{id}Bearer key

One customer with the same aggregates plus their ten most recent orders (any status).

200 OKjson
{
  "ok": true,
  "customer": {
    "id": "clx…",
    "email": "buyer@example.com",
    "name": null,
    "hasAccount": true,
    "balanceUsd": 12.5,
    "lastLoginAt": "2026-09-25T18:20:00.000Z",
    "createdAt": "2026-07-01T12:00:00.000Z",
    "orderCount": 4,
    "topUpCount": 1,
    "totalSpentUsd": 116,
    "openTicketCount": 0,
    "recentOrders": [ { "id": "clx…", "status": "delivered", … } ]
  }
}

GET/ticketsBearer key

Tickets ordered by last activity with message counts. Replying is done from the dashboard so the buyer is emailed.

Query parameters
Query parametersTypeDescription
statusopen | answered | closedFilter by status.
emailstringBuyer email contains this text.
limit1..100Default 25.
cursorstringFrom the previous page.

GET/tickets/{id}Bearer key

One ticket with its full thread. fromBuyer tells you who wrote each message.

200 OKjson
{
  "ok": true,
  "ticket": {
    "id": "clx…",
    "orderId": "clx…",
    "buyerEmail": "buyer@example.com",
    "subject": "Key not activating",
    "status": "answered",
    "messageCount": 2,
    "lastMessageAt": "2026-09-26T11:00:00.000Z",
    "createdAt": "2026-09-26T10:30:00.000Z",
    "messages": [
      { "id": "clx…", "fromBuyer": true, "body": "The key says invalid.", "createdAt": "2026-09-26T10:30:00.000Z" },
      { "id": "clx…", "fromBuyer": false, "body": "Try again without the dashes.", "createdAt": "2026-09-26T11:00:00.000Z" }
    ]
  }
}

GET/couponsBearer key

Coupons with usage. type is percent or fixed; value is the percentage or the USD amount.

Query parameters
Query parametersTypeDescription
activetrue | falseOnly usable coupons (active and not expired), or only unusable ones.
200 OKjson
{
  "ok": true,
  "coupons": [
    { "id": "clx…", "code": "SUMMER25", "type": "percent", "value": 25, "maxUses": 100, "usedCount": 37, "active": true, "expiresAt": "2026-10-01T00:00:00.000Z", "createdAt": "2026-08-01T00:00:00.000Z" }
  ]
}

GET/analyticsBearer key

Revenue and paid order counts with zero-filled daily buckets (hourly for 24h), the number of distinct buyers and the top products by revenue. Counts paid and delivered product orders only; top-ups are excluded so balance-paid orders are not double counted.

Query parameters
Query parametersTypeDescription
period24h | 7d | 30d | 90d | 365dWindow ending now. Default 30d.
200 OKjson
{
  "ok": true,
  "period": "7d",
  "revenue": { "totalUsd": 1240.5, "daily": [ { "t": "2026-09-20T00:00:00.000Z", "value": 180 }, … ] },
  "orders": { "total": 43, "daily": [ { "t": "2026-09-20T00:00:00.000Z", "value": 6 }, … ] },
  "customerCount": 39,
  "topProducts": [ { "productId": "clx…", "slug": "lifetime-license", "title": "Lifetime license", "imageUrl": null, "units": 21, "revenueUsd": 609, "orders": 21 } ]
}

Webhooks

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.

Events

EventSent when
order.createdA checkout was submitted and an invoice issued.
order.paidPayment confirmed. Balance credited. Delivery starts.
order.deliveredEvery item in the order was delivered.
order.delivery_failedOne or more items could not be delivered (details in the payload).
order.underpaidBuyer sent less than the amount due; seller has 48 hours to decide.
order.expiredThe invoice expired without a payment.
order.cancelledCancelled by the seller, an admin or a refunded payment.
ticket.openedA buyer opened a support ticket.
review.createdA verified buyer left a review.
withdrawal.requestedA withdrawal was requested from the store balance.
withdrawal.processedA withdrawal was sent (with the transaction hash) or returned.
webhook.testYou 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:

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…",
    "storeId": "clx…",
    "status": "paid",
    "buyerEmail": "buyer@example.com",
    "grossUsd": 29,
    "discountUsd": 0,
    "buyerTotalUsd": 29,
    "sellerNetUsd": 27.90,
    "platformFeeUsd": 1.10,
    "couponCode": null,
    "paymentMethod": "crypto",
    "paidAt": "2026-09-26T10:01:12.000Z",
    "deliveredAt": null,
    "createdAt": "2026-09-26T09:58:40.000Z",
    "items": [
      { "itemId": "clx…", "productId": "clx…", "title": "Lifetime license", "variantId": "clx…", "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.

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.

verify.ts (Node / Next.js)ts
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");
}
verify.pypython
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)

Retries and idempotency

A delivery that times out or gets a 5xx, 408 or 429 response is retried up to six times, roughly 1, 5, 30, 120, 360 and 1440 minutes after the first attempt. 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.

Dynamic delivery

Set a product's delivery type to dynamic and paste your endpoint URL. On every paid order Hidden POSTs the signed order payload (same format as a webhook) and delivers the deliveredData string from your JSON response. Use it to provision accounts, generate keys on the fly, or call your own licensing server.

your-endpoint (response)http
// Your endpoint receives the same signed payload as a webhook.
// Respond within 10 seconds with what the buyer should receive:
HTTP/1.1 200 OK
Content-Type: application/json

{
  "deliveredData": "Your licence: HDN-4K2P-88QL-9XQ1\nActivate at https://example.com/activate",
  "note": "Shown to the buyer under the item (optional)"
}

// Anything other than 2xx (or a timeout) marks the item "failed" with your response
// body as the error. You can redeliver from the order page in the dashboard.

Always verify the signature

Anyone who learns your endpoint URL could POST to it. Verify the signature before provisioning anything.

Embed and live price

Give any element data-hidden-store and data-hidden-product, include embed.js once, and clicking opens a checkout modal with your live product page. The buyer never leaves the host page; the modal closes with its X, Escape, a click outside, or from checkout once the order is placed.

button.htmlhtml
<button data-hidden-store="my-store" data-hidden-product="lifetime-license">Buy now</button>
<script src="https://sellhidden.com/embed.js" async></script>

A price span keeps itself current from the public API (cached 60 seconds). Optional attributes: data-hidden-price-prefix, data-hidden-currency-symbol and data-hidden-format="raw". Once loaded the span gets data-hidden-in-stock="true|false".

price.htmlhtml
<span data-hidden-price="my-store/lifetime-license" data-hidden-price-prefix="from "></span>

Open and close the modal from your own code, or rebind after rendering new elements in a single-page app:

app.jsjs
window.HiddenEmbed.open("my-store", "lifetime-license"); // open the checkout modal
window.HiddenEmbed.close();                                // close it
window.HiddenEmbed.scan();                                 // bind elements added after load

Prefer your own markup? Fetch the product and render the price yourself:

custom.jsjs
const res = await fetch("https://sellhidden.com/api/v1/public/products/my-store/lifetime-license");
const { ok, product } = await res.json();
if (ok) priceEl.textContent = `$${product.displayPriceUsd.toFixed(2)}`;

The script sets no cookies, reads nothing on the host page and takes the checkout host from its own src, so a copied snippet cannot be redirected. Messages from the modal are accepted only from that origin. The v1 snippet (<script data-store data-product>) keeps working and now opens the modal too.

Delivery types

Every product has a delivery type that decides what the buyer receives when an order is paid. Delivery runs automatically once the network confirms; nothing is revealed for pending, expired or cancelled orders.

  • Files

    deliveryType: "file"

    Signed download link.

  • Text and credentials

    deliveryType: "text"

    Revealed after payment.

  • License and serial keys

    deliveryType: "keys"

    One unique key per order.

  • Private links

    deliveryType: "link"

    URL unlocked on payment.

  • Discord roles

    deliveryType: "discord"

    Granted by the bot on payment.

  • Dynamic delivery

    deliveryType: "dynamic"

    Your webhook decides.

Type
TypeTypeDescription
fileuploadBuyer gets a signed download link, refreshed on every visit to the order page.
texttextThe text, on the order page and in the receipt email.
keyspoolExactly one unused key per unit; stock counts down with the pool.
linkURLThe URL, on the order page and in the receipt email.
discordserver + roleRole granted by the Hidden bot on payment and revoked when the term ends (Pro and Enterprise).
dynamicwebhook URLWhatever your endpoint returns; see Dynamic delivery.

Buyer accounts and payments

Buyers never create a password. On any storefront they can sign in with their email and a 6-digit one-time code (valid 10 minutes), which unlocks an account page with their orders, reviews (editable for 24 hours), support tickets and a store balance. The session is a per-store cookie that lasts 30 days.

Flow
FlowTypeDescription
Cryptohosted invoiceDefault. The buyer is sent to a hosted invoice where the network and processing fee is added; the order is delivered when the network confirms. Pending invoices expire after 2 hours.
Store balanceprepaidBuyers top up in crypto (fee taken at top-up) and pay later from the balance, which needs a signed-in session. Balance orders are delivered instantly and carry no further fee.
Manualoff-platformSeller-configured rails (PayPal, Cash App, bank, gift cards…). The buyer pays outside Hidden, submits proof, and the order waits in "awaiting verification" until the seller marks it paid or rejects it (7-day window). The platform fee is debited from the seller balance.
Display currencyinformationalStorefronts can show prices in the buyer's local currency (browser locale) with a switcher; every charge is still made in USD and the order records the currency the buyer saw.

Orders exposed through the API carry kind (product or topup), payment.method (crypto, balance, manual or free) and, for manual rails, the claim and verification timestamps, so integrations can tell a settled crypto order from one still awaiting the seller.

Testing

Create a $0 product to walk through checkout, the order page and delivery without a payment. To exercise webhooks, press Send test event on an endpoint, or use a low-priced product and pay a small USDT amount; the fee is only charged on the confirmed amount. Any order can be redelivered from its order page, which sends its events again with the same delivery id.