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.
GET https://sellhidden.com/api/v1
# → lists every endpoint with its full URLAuthentication
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.
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.
{ "ok": true, "products": [ … ], "nextCursor": "Y2xxazF…", "hasMore": true }{ "ok": false, "error": "Invalid API key.", "code": "unauthorized" }| Status | Code | Meaning |
|---|---|---|
| 200 | — | Success. |
| 400 | bad_request | A parameter is malformed; the error says which. |
| 401 | unauthorized, key_revoked | Missing, invalid or revoked key. |
| 403 | store_suspended | The store cannot be served. |
| 404 | not_found | Unknown store, product, order, customer or ticket. |
| 429 | rate_limited | Too 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.
# 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 | Type | Description |
|---|---|---|
| categorySlug | string | Only products in this category. |
| limit | 1..100 | Default 50. |
| cursor | string | From the previous page. |
{
"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.
{
"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.
{
"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 | Type | Description |
|---|---|---|
| productSlug | string | Only reviews of this product. |
| rating | 1..5 | Only this star rating. |
| limit | 1..100 | Default 20. |
| cursor | string | From the previous page. |
{
"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 | Type | Description |
|---|---|---|
| limit | 1..25 | Default 10. |
{
"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 | Type | Description |
|---|---|---|
| status | string | One of pending, awaiting_verification, paid, delivered, cancelled, underpaid, oversold, expired, failed, refunded. |
| kind | product | topup | Product orders or buyer balance top-ups. |
| string | Buyer email contains this text. | |
| since | ISO date | Created at or after. |
| until | ISO date | Created before. |
| limit | 1..100 | Default 25. |
| cursor | string | From 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.
{
"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.
{
"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 | Type | Description |
|---|---|---|
| productId | string | Only reviews of this product. |
| rating | 1..5 | Only this star rating. |
| visible | true | false | Only shown or only hidden reviews. Default: both. |
| limit | 1..1000 | Default 50. |
| cursor | string | From 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 | Type | Description |
|---|---|---|
| string | Email contains this text. | |
| hasAccount | true | false | Only buyers who signed in to your storefront (or only guests). |
| limit | 1..100 | Default 50. |
| cursor | string | From the previous page. |
GET/customers/{id}Bearer key
One customer with the same aggregates plus their ten most recent orders (any status).
{
"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 | Type | Description |
|---|---|---|
| status | open | answered | closed | Filter by status. |
| string | Buyer email contains this text. | |
| limit | 1..100 | Default 25. |
| cursor | string | From the previous page. |
GET/tickets/{id}Bearer key
One ticket with its full thread. fromBuyer tells you who wrote each message.
{
"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 | Type | Description |
|---|---|---|
| active | true | false | Only usable coupons (active and not expired), or only unusable ones. |
{
"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 | Type | Description |
|---|---|---|
| period | 24h | 7d | 30d | 90d | 365d | Window ending now. Default 30d. |
{
"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
| Event | Sent when |
|---|---|
| order.created | A checkout was submitted and an invoice issued. |
| order.paid | Payment confirmed. Balance credited. Delivery starts. |
| order.delivered | Every item in the order was delivered. |
| order.delivery_failed | One or more items could not be delivered (details in the payload). |
| order.underpaid | Buyer sent less than the amount due; seller has 48 hours to decide. |
| order.expired | The invoice expired without a payment. |
| order.cancelled | Cancelled by the seller, an admin or a refunded payment. |
| ticket.opened | A buyer opened a support ticket. |
| review.created | A verified buyer left a review. |
| withdrawal.requested | A withdrawal was requested from the store balance. |
| withdrawal.processed | A withdrawal was sent (with the transaction hash) or returned. |
| 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…",
"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.
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)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 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
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 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".
<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:
window.HiddenEmbed.open("my-store", "lifetime-license"); // open the checkout modal
window.HiddenEmbed.close(); // close it
window.HiddenEmbed.scan(); // bind elements added after loadPrefer your own markup? Fetch the product and render the price yourself:
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 | Type | Description |
|---|---|---|
| file | upload | Buyer gets a signed download link, refreshed on every visit to the order page. |
| text | text | The text, on the order page and in the receipt email. |
| keys | pool | Exactly one unused key per unit; stock counts down with the pool. |
| link | URL | The URL, on the order page and in the receipt email. |
| discord | server + role | Role granted by the Hidden bot on payment and revoked when the term ends (Pro and Enterprise). |
| dynamic | webhook URL | Whatever 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 | Type | Description |
|---|---|---|
| Crypto | hosted invoice | Default. 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 balance | prepaid | Buyers 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. |
| Manual | off-platform | Seller-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 currency | informational | Storefronts 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.