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

# Start here

Source: https://docs.sellhidden.com/start

Five minutes from an empty store to a signed webhook on your server. Everything is on the Free plan.

## Your first call in five minutes

The API lives under `https://api.sellhidden.com/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 manage your products, keys, orders, customers, coupons, reviews, tickets, store settings and webhooks; call them from your server only.

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

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.

1. **Create a store and a product** A $0 product is enough to test the whole flow without paying.
2. **Read your catalogue, no key needed** Type your store slug and press the button.
3. **Create an API key** Dashboard, Developers, API keys. The key is shown once; keep it on your server.
4. **Add a webhook endpoint** Developers, Webhooks. Subscribe to `order.paid`, copy the signing secret, press Send test event and check the signature with the snippet on the [Webhooks page](https://docs.sellhidden.com/webhooks#verify-signature).
5. **Sell from anywhere** Add the [Buy button](https://docs.sellhidden.com/buy-button) to your site or link straight to checkout.

Call GET https://api.sellhidden.com/v1/public/products/<your-slug> in a browser to see your products.

**curl**

```bash
# 1. Your catalogue, no key needed
curl https://api.sellhidden.com/v1/public/products/my-store

# 2. Your orders, with the key from Developers, API keys
curl https://api.sellhidden.com/v1/orders?status=paid&limit=5 \
  -H "Authorization: Bearer hsk_live_…"

# 3. Receive a signed test event on your endpoint
#    (press "Send test event" in Developers, Webhooks, then verify X-Hidden-Signature)
```

**Node**

```javascript
// 1. Your catalogue, no key needed
const products = await fetch("https://api.sellhidden.com/v1/public/products/my-store").then((r) => r.json());

// 2. Your orders, with the key from Developers, API keys
const key = process.env.HIDDEN_API_KEY;
const orders = await fetch("https://api.sellhidden.com/v1/orders?status=paid&limit=5", {
  headers: { Authorization: `Bearer ${key}` },
}).then((r) => r.json());

// 3. Receive a signed test event on your endpoint
//    (press "Send test event" in Developers, Webhooks, then verify X-Hidden-Signature)
```

**Python**

```python
import os, requests

# 1. Your catalogue, no key needed
products = requests.get("https://api.sellhidden.com/v1/public/products/my-store").json()

# 2. Your orders, with the key from Developers, API keys
key = os.environ["HIDDEN_API_KEY"]
orders = requests.get(
    "https://api.sellhidden.com/v1/orders",
    params={"status": "paid", "limit": 5},
    headers={"Authorization": f"Bearer {key}"},
).json()

# 3. Receive a signed test event on your endpoint
#    (press "Send test event" in Developers, Webhooks, then verify X-Hidden-Signature)
```

**PHP**

```php
<?php
// 1. Your catalogue, no key needed
$products = json_decode(file_get_contents('https://api.sellhidden.com/v1/public/products/my-store'), true);

// 2. Your orders, with the key from Developers, API keys
$key = getenv('HIDDEN_API_KEY');
$ch = curl_init('https://api.sellhidden.com/v1/orders?status=paid&limit=5');
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
]);
$orders = json_decode(curl_exec($ch), true);

// 3. Receive a signed test event on your endpoint
//    (press "Send test event" in Developers, Webhooks, then verify X-Hidden-Signature)
```

## Authentication

Your server sends Authorization: Bearer hsk_live_… to Hidden and gets a 200 back.

You create your own API keys and choose what each one may do: read or write, per resource. Nobody else can make a key for your store. Keys start with `hsk_live_` 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.

1. **Open Developers, API keys** In your dashboard. Any member of the store can open it.
2. **Create a key and pick its scopes** Name it after where it will run, tick read or write per resource, and set an expiry or an IP allowlist if you want one.
3. **Copy it once** The full key is shown one time. Keep it on your server; if it ever leaks, revoke it and make a new one.

[Open API keys](https://dashboard.sellhidden.com/developers/api-keys)

Send the key as a Bearer token from your server:

**curl**

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

**Node**

```javascript
const key = process.env.HIDDEN_API_KEY;

const res = await fetch("https://api.sellhidden.com/v1/orders?status=paid&limit=25", {
  headers: {
    Authorization: `Bearer ${key}`,
  },
});
const data = await res.json();
```

**Python**

```python
import os
import requests

key = os.environ["HIDDEN_API_KEY"]
res = requests.get(
    "https://api.sellhidden.com/v1/orders?status=paid&limit=25",
    headers={
        "Authorization": f"Bearer {key}",
    },
)
data = res.json()
```

**PHP**

```php
<?php
$key = getenv('HIDDEN_API_KEY');
$ch = curl_init('https://api.sellhidden.com/v1/orders?status=paid&limit=25');
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    'Authorization: Bearer ' . $key,
  ],
]);
$data = json_decode(curl_exec($ch), true);
```

401 unauthorized · 401 key_revoked · 401 key_expired · 403 ip_not_allowed · 403 insufficient_scope · 403 store_suspended

| Code | Meaning |
| --- | --- |
| `401 unauthorized` | A missing or invalid key. |
| `401 key_revoked` | A revoked key. |
| `401 key_expired` | A key past its expiry. |
| `403 ip_not_allowed` | A key used from an address outside its allowlist. |
| `403 insufficient_scope` | A key without the permission an endpoint needs. |
| `403 store_suspended` | A suspended store. |

`GET /me` shows what a key can do.

## Keys and scopes

Every key carries a list of scopes, one `read` and one `write` per resource; a write scope includes its read scope. Pick them when you create the key, together with an optional **IP allowlist** (addresses or CIDR ranges your servers call from) and an **expiry**. Each endpoint names the scope it needs. Writes made through the API appear in your store's audit log with the key that made them.

| Resource | Scopes | Covers |
| --- | --- | --- |
| Products | `products:read`, `products:write` | Products, variations, keys and stock |
| Categories | `categories:read`, `categories:write` | Categories and their order |
| Coupons | `coupons:read`, `coupons:write` | Discount codes |
| Orders | `orders:read`, `orders:write` | Orders, manual payments, refunds, redelivery |
| Subscriptions | `subscriptions:read`, `subscriptions:write` | Recurring buyers: list, cancel, extend |
| Customers | `customers:read`, `customers:write` | Buyers, store balance, bans |
| Reviews | `reviews:read`, `reviews:write` | Replies and visibility |
| Tickets | `tickets:read`, `tickets:write` | Support threads |
| Store | `store:read`, `store:write` | Store settings and identity |
| Webhooks | `webhooks:read`, `webhooks:write` | Webhook endpoints, secrets and test events |
| Analytics | `analytics:read` | Revenue and order statistics |

All 21 scopes together are shown as `*` (full access).

## Responses and errors

Every body carries an `ok` boolean. Successful responses put their data next to it; failures carry one `error` object with a stable `code` you can branch on, a human-readable `message` and, for validation errors, the `field` at fault.

```json
{ "ok": true, "products": [ … ], "nextCursor": "Y2xxazF…", "hasMore": true }
```

```json
{ "ok": false, "error": { "code": "validation_error", "message": "Give the product a title", "field": "title" } }
```

200 · 201 · 400 · 401 · 403 · 404 · 409 · 422 · 429

| Status | Code | Meaning |
| --- | --- | --- |
| `200, 201` | — | Success (201 when something was created). |
| `400` | bad_request, validation_error | A parameter or body field is malformed; validation errors name the field. |
| `401` | unauthorized, key_revoked, key_expired | Missing, invalid, revoked or expired key. |
| `403` | insufficient_scope, ip_not_allowed, store_suspended, plan_limit | The key may not do this, or the plan does not allow more. |
| `404` | not_found | Unknown store, product, order, customer, ticket or webhook. |
| `409` | conflict, … | A business rule refused the change; the code says which (invoice_open, partial_payment, pool_full, stock_from_keys…). |
| `422` | idempotency_conflict | The Idempotency-Key was already used with a different request. |
| `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.

120 requests per minute. X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset come with every response; a 429 adds Retry-After.

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

Page one, then page two with nextCursor from the previous response, until hasMore is false.

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

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

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

## Idempotency

A request can be lost after the server acted on it. Send an `Idempotency-Key` header (any unique string up to 255 characters, a UUID is fine) with a `POST` and you can safely retry it: for 24 hours the same key with the same body replays the first response, marked with `Idempotent-Replayed: true`, and nothing happens twice. The same key with a different body is refused with `422 idempotency_conflict`. Keys are per API key. Responses of 5xx are not stored, so a failed request can be retried with the same key.

Two POSTs with the same Idempotency-Key create one category; the second answer carries Idempotent-Replayed: true.

**curl**

```bash
# First call creates the category
curl -X POST https://api.sellhidden.com/v1/categories   -H "Authorization: Bearer hsk_live_…"   -H "Content-Type: application/json"   -H "Idempotency-Key: 6f1c2a4e-…"   -d '{ "name": "Software" }'

# A retry with the same key and body replays the stored 201
#   → header "Idempotent-Replayed: true", nothing is created twice
# The same key with a different body → 422 idempotency_conflict
```

**Node**

```javascript
// First call creates the category
const key = process.env.HIDDEN_API_KEY;
const idempotencyKey = crypto.randomUUID();
const res = await fetch("https://api.sellhidden.com/v1/categories", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${key}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
  },
  body: JSON.stringify({ name: "Software" }),
});

// A retry with the same key and body replays the stored 201
//   → header "Idempotent-Replayed: true", nothing is created twice
// The same key with a different body → 422 idempotency_conflict
```

**Python**

```python
import os, uuid, requests

# First call creates the category
key = os.environ["HIDDEN_API_KEY"]
idempotency_key = str(uuid.uuid4())
res = requests.post(
    "https://api.sellhidden.com/v1/categories",
    headers={"Authorization": f"Bearer {key}", "Idempotency-Key": idempotency_key},
    json={"name": "Software"},
)

# A retry with the same key and body replays the stored 201
#   → header "Idempotent-Replayed: true", nothing is created twice
# The same key with a different body → 422 idempotency_conflict
```

**PHP**

```php
<?php
// First call creates the category
$key = getenv('HIDDEN_API_KEY');
$idempotencyKey = bin2hex(random_bytes(16));
$ch = curl_init('https://api.sellhidden.com/v1/categories');
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    'Authorization: Bearer ' . $key,
    'Content-Type: application/json',
    'Idempotency-Key: ' . $idempotencyKey,
  ],
  CURLOPT_POSTFIELDS => json_encode(['name' => 'Software']),
]);
$res = curl_exec($ch);

// A retry with the same key and body replays the stored 201
//   → header "Idempotent-Replayed: true", nothing is created twice
// The same key with a different body → 422 idempotency_conflict
```

## OpenAPI and SDKs

The whole API is described in an OpenAPI 3.1 document at `https://api.sellhidden.com/v1/openapi.json`: every path, parameter, body field, the scope each operation needs (`x-scope`) and which ones take an `Idempotency-Key`. Feed it to any generator to get a typed client in your language, or import it into your API tool. `GET https://api.sellhidden.com/v1` returns the same list with full URLs.

- **[OpenAPI 3.1](/openapi.json)**: hidden-openapi.json · every path, field and scope
- **[Postman collection](/postman.json)**: hidden-postman.json · one folder per group

Import in Postman or Insomnia: paste `https://api.sellhidden.com/v1/openapi.json`.

```bash
# The OpenAPI 3.1 document (public, cached a minute)
curl https://api.sellhidden.com/v1/openapi.json

# Generate a typed client, e.g. TypeScript
npx openapi-typescript https://api.sellhidden.com/v1/openapi.json -o hidden-api.d.ts
```
