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

# Payments

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

Take crypto payments on your own site. Your server creates a payment for an amount in USD, the buyer pays on a payment page that Hidden hosts, and your server gets a signed webhook when the payment is complete.

## How it works

[payments:write](https://docs.sellhidden.com/start?scope=payments%3Awrite#scopes) · [5 endpoints](https://docs.sellhidden.com/reference/payments) · [Test payments](https://docs.sellhidden.com/payments#payments-test) · [0% platform fee](https://sellhidden.com/pricing)

1. **Create a payment** Your server calls `POST /payments` with the amount in USD and your own reference. The answer carries `url`.
2. **Send the buyer to the page** Redirect the buyer to `url`. They pick a coin and a network and pay with the details shown there.
3. **Get the result** Hidden sends `payment.paid` to your webhook endpoint when the payment is complete, and the page sends the buyer back to your `returnUrl`.
4. **Deliver** Deliver what the buyer bought after `payment.paid`, never after the return to your site alone: anyone can open a return URL.

> **The buyer always pays on the Hidden page.** The API never returns a deposit address, a coin amount or a QR code, and the payment page cannot be shown inside a frame. Every buyer pays on a page that shows your store name and the Hidden mark (Enterprise stores show only their own name and logo).

## Turn on payments

1. **Turn on payments** In [Developers, Payments](https://dashboard.sellhidden.com/developers/payments), turn on payments and add the website where you take them, for example `https://shop.example.com`. Return and cancel URLs must be on one of these websites. You can add up to 5.
2. **Create a key** Create an API key with the Payments scope in [Developers, API keys](https://dashboard.sellhidden.com/developers/api-keys). Keep it on your server: calls to create a payment from a web page are refused.
3. **Add a webhook endpoint** Add an endpoint that receives `payment.*` events in [Developers, Webhooks](https://dashboard.sellhidden.com/developers/webhooks).

Test payments work before you turn payments on.

## Create a payment

Send the amount in USD and your own `reference` from your server. Only `amountUsd` is required.

**curl**

```bash
curl -X POST https://api.sellhidden.com/v1/payments \
  -H "Authorization: Bearer hsk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "amountUsd": 49,
    "reference": "order-1042",
    "description": "Pro plan, 1 month",
    "returnUrl": "https://shop.example.com/thanks?order=1042",
    "cancelUrl": "https://shop.example.com/cart",
    "buyerEmail": "buyer@example.com",
    "metadata": {
      "plan": "pro"
    }
  }'
```

**Node**

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

const res = await fetch("https://api.sellhidden.com/v1/payments", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${key}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "amountUsd": 49,
    "reference": "order-1042",
    "description": "Pro plan, 1 month",
    "returnUrl": "https://shop.example.com/thanks?order=1042",
    "cancelUrl": "https://shop.example.com/cart",
    "buyerEmail": "buyer@example.com",
    "metadata": {
      "plan": "pro"
    }
  }),
});
const data = await res.json();
```

**Python**

```python
import os
import requests
import uuid

key = os.environ["HIDDEN_API_KEY"]
res = requests.post(
    "https://api.sellhidden.com/v1/payments",
    headers={
        "Authorization": f"Bearer {key}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "amountUsd": 49,
        "reference": "order-1042",
        "description": "Pro plan, 1 month",
        "returnUrl": "https://shop.example.com/thanks?order=1042",
        "cancelUrl": "https://shop.example.com/cart",
        "buyerEmail": "buyer@example.com",
        "metadata": {
            "plan": "pro",
        },
    },
)
data = res.json()
```

**PHP**

```php
<?php
$key = getenv('HIDDEN_API_KEY');
$ch = curl_init('https://api.sellhidden.com/v1/payments');
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_CUSTOMREQUEST => 'POST',
  CURLOPT_HTTPHEADER => [
    'Authorization: Bearer ' . $key,
    'Content-Type: application/json',
    'Idempotency-Key: ' . bin2hex(random_bytes(16)),
  ],
  CURLOPT_POSTFIELDS => json_encode([
    'amountUsd' => 49,
    'reference' => 'order-1042',
    'description' => 'Pro plan, 1 month',
    'returnUrl' => 'https://shop.example.com/thanks?order=1042',
    'cancelUrl' => 'https://shop.example.com/cart',
    'buyerEmail' => 'buyer@example.com',
    'metadata' => [
      'plan' => 'pro',
    ],
  ]),
]);
$data = json_decode(curl_exec($ch), true);
```

| Field | Type | Description |
| --- | --- | --- |
| `amountUsd` (required) | number | At least 1.00 and at most your per-payment limit (never more than 10,000). Rounded to cents. |
| `reference` | string | Your own id, such as an order number: 1 to 64 letters, digits and _ . : @ + / = -. While a payment with it is open, the same amount returns that payment. |
| `description` | string | Up to 120 characters of plain text, shown to the buyer. |
| `returnUrl` | string | Where the page sends the buyer after paying: https, up to 2,048 characters, on one of your payment websites. Test payments may also return to localhost. |
| `cancelUrl` | string | Linked on the page until the buyer pays. The same rules as returnUrl. |
| `buyerEmail` | string | The buyer's email, up to 254 characters. Hidden emails them a receipt once the payment is paid. |
| `expiresInMinutes` | 15..1440 | The time the buyer has to start paying. Default 60. |
| `metadata` | object | Up to 20 keys of letters, digits, _ . or - (40 characters each), with string, number or true/false values of up to 200 characters, and 2 KB of JSON in all. Returned to you, never shown to the buyer. |
| `test` | boolean | Set to true for a test payment: no money moves, and its page offers Mark as paid and Mark as expired. |

The answer is `201 Created` with the payment. Every payment endpoint and every `payment.*` event carries this same object:

```json
{
  "ok": true,
  "payment": {
    "id": "NOVA-7K2Q-9XHD",
    "url": "https://sellhidden.com/order/NOVA-7K2Q-9XHD",
    "status": "pending",
    "test": false,
    "amountUsd": 49,
    "paidUsd": null,
    "shortUsd": 0,
    "overpaidUsd": 0,
    "creditedUsd": null,
    "feeUsd": 0,
    "networkFeeUsd": 0,
    "reference": "order-1042",
    "description": "Pro plan, 1 month",
    "metadata": {
      "plan": "pro"
    },
    "buyerEmail": "buyer@example.com",
    "returnUrl": "https://shop.example.com/thanks?order=1042",
    "cancelUrl": "https://shop.example.com/cart",
    "coin": null,
    "network": null,
    "underpaid": null,
    "availableAt": null,
    "createdAt": "2026-09-26T09:58:40.000Z",
    "expiresAt": "2026-09-26T10:58:40.000Z",
    "paidAt": null
  }
}
```

Then send the buyer to `payment.url`, for example with a 303 redirect. Keep `payment.id`: it is the id of the payment in every event and in `GET /payments/{id}`.

Create the payment when the buyer clicks Pay, not when your page loads: open payments count against your limits.

## The payment page

The page shows your store name and logo, the description, the amount and the Hidden mark. On the Enterprise plan the Hidden mark is left out.

The buyer picks a coin and a network. The page shows the exact amount and a QR code, waits for the network to confirm, and then sends the buyer to your `returnUrl`. Until they pay, a link takes them back to your `cancelUrl`.

The page is in the buyer's language and works on phones.

The buyer has `expiresInMinutes` to start paying. Once they pick a coin, the address stays valid for the time shown on the page.

## Statuses and webhooks

| Status | Description |
| --- | --- |
| `pending` | Waiting for the buyer. |
| `confirming` | A transfer was seen and is waiting for confirmations. |
| `underpaid` | Less than the amount arrived. The buyer can send the rest until `underpaid.payableUntil`. |
| `paid` | Complete. Deliver. |
| `expired` | The time ran out. `paidUsd` above 0 means money arrived and waits for you to accept it. |
| `cancelled` | You cancelled it. |

| Event | Sent when |
| --- | --- |
| `payment.paid` | An API payment was paid in full, within your tolerance, or accepted by you. Carries the payment object: deliver now. |
| `payment.underpaid` | Less than the amount arrived. Adds underpaid.receivedUsd, remainingUsd and payableUntil. |
| `payment.expired` | The payment expired. paidUsd above 0 means money arrived and waits for you to accept it. |
| `payment.cancelled` | The payment was cancelled through the API or the dashboard. |
| `payment.overpaid` | A transfer arrived after the payment was complete. Adds extraUsd; it is not added to your balance. |

Each event carries the payment as `data`: the same object as `GET /payments/{id}`. Verify the signature like every webhook ([Verify signatures](https://docs.sellhidden.com/webhooks#verify-signature)). Test payments carry `"test": true`, and `order.*` events never fire for payments.

```http
POST https://example.com/hidden-webhook
Content-Type: application/json
X-Hidden-Event: payment.paid
X-Hidden-Delivery: dlv_01j9k2…
X-Hidden-Timestamp: 1727344800
X-Hidden-Signature: sha256=3f8a…c21e

{
  "id": "evt_9f2c…",
  "event": "payment.paid",
  "createdAt": "2026-09-26T10:01:12.000Z",
  "data": {
    "id": "NOVA-7K2Q-9XHD",
    "url": "https://sellhidden.com/order/NOVA-7K2Q-9XHD",
    "status": "paid",
    "test": false,
    "amountUsd": 49,
    "paidUsd": 49,
    "shortUsd": 0,
    "overpaidUsd": 0,
    "creditedUsd": 49,
    "feeUsd": 0,
    "networkFeeUsd": 0,
    "reference": "order-1042",
    "description": "Pro plan, 1 month",
    "metadata": {
      "plan": "pro"
    },
    "buyerEmail": "buyer@example.com",
    "returnUrl": "https://shop.example.com/thanks?order=1042",
    "cancelUrl": "https://shop.example.com/cart",
    "coin": "USDT",
    "network": "Tron (TRC20)",
    "underpaid": null,
    "availableAt": "2026-09-28T10:01:12.000Z",
    "createdAt": "2026-09-26T09:58:40.000Z",
    "expiresAt": "2026-09-26T10:58:40.000Z",
    "paidAt": "2026-09-26T10:01:12.000Z"
  }
}
```

**Node**

```javascript
// After checking X-Hidden-Signature (see Verifying signatures on the Webhooks page):
const event = JSON.parse(rawBody);
const payment = event.data;

if (event.event === "payment.paid") {
  if (payment.test) {
    console.log("Test payment marked as paid:", payment.reference); // nothing was paid
  } else {
    await deliverOnce(payment.reference, payment.id); // your order id, Hidden's payment id
  }
}
return new Response("ok");
```

**Python**

```python
# After checking X-Hidden-Signature (see Verifying signatures on the Webhooks page):
event = json.loads(raw_body)
payment = event["data"]

if event["event"] == "payment.paid":
    if payment["test"]:
        print("Test payment marked as paid:", payment["reference"])  # nothing was paid
    else:
        deliver_once(payment["reference"], payment["id"])  # your order id, Hidden's payment id
```

**PHP**

```php
<?php
// After checking X-Hidden-Signature (see Verifying signatures on the Webhooks page):
$event   = json_decode($raw, true);
$payment = $event["data"];

if ($event["event"] === "payment.paid") {
    if ($payment["test"]) {
        error_log("Test payment marked as paid: " . $payment["reference"]); // nothing was paid
    } else {
        deliver_once($payment["reference"], $payment["id"]); // your order id, Hidden's payment id
    }
}
http_response_code(200);
```

If an event did not arrive, read the payment with `GET /payments/{id}`.

## Amounts, fees and payouts

There is no platform fee. The buyer pays the network fee on top of the amount, unless you chose in Payment methods to pay it yourself. Then it comes out of your credit and shows as `networkFeeUsd`.

`creditedUsd` is what was added to your store balance.

Money from an API payment can be withdrawn from `availableAt`, after a short hold.

New stores start with lower limits on single payments and on the total per 24 hours. Your limits and hold are shown in Developers, Payments. Write to [support](https://sellhidden.com/support?as=developer) to raise them.

## Short and extra payments

A payment short by no more than your underpayment tolerance (Payment methods) counts as paid. You are credited what arrived, and `shortUsd` shows the gap.

If more is missing, the payment is `underpaid`. The buyer can send the rest from the same page for 48 hours. You can accept what arrived at any time with `POST /payments/{id}/accept`. Otherwise the payment expires and the money waits for you to accept it.

Money above the amount is not added to your balance. `overpaidUsd` shows it, and `payment.overpaid` tells you when a transfer arrives after the payment was complete.

## Refunds

Crypto payments are final, and Hidden does not send refunds for API payments. To refund a buyer, send them the money yourself.

## References and retries

Send an `Idempotency-Key` header with every create. A retry after a timeout then returns the first answer instead of creating a second payment.

An answer that asks you to wait is not kept for its key: a `429`, a `503`, and `409 payment_started`, `payment_in_flight` or `payment_busy`. After the wait, retry with the same key.

`reference` is your own id, such as an order number. While a payment with that reference is open, creating it again with the same amount returns the same payment (`existing: true`). With another amount the answer is `409 reference_in_use`.

After a payment with that reference is paid, the reference cannot be used again (`409 reference_paid`).

## Test payments

Create a payment with `"test": true`. Its page shows Test mode and two buttons: Mark as paid and Mark as expired.

Your endpoint receives the matching events with `"test": true`. No money moves and nothing is credited.

Keys made by the Try it button in these docs always create test payments.

> **Test events.** Never deliver for an event that has `"test": true`.

## Errors

Errors have the usual shape ([Responses and errors](https://docs.sellhidden.com/start#responses)), with any extra values next to `code`. The codes of the payments endpoints:

| Code | Status | Description |
| --- | --- | --- |
| `validation_error` | 400 | A field is missing or not valid. `field` names it. |
| `site_not_allowed` | 400 | `returnUrl` or `cancelUrl` is not on one of your payment websites. The message lists them. |
| `amount_over_limit` | 400 | `amountUsd` is above your limit for one payment (`maxUsd`). |
| `payments_off` | 403 | Payments are off for your store. Turn them on in Developers, Payments. |
| `payments_blocked` | 403 | Payments are blocked for your store. Write to support. |
| `payments_paused` | 403 | Your store is suspended or its withdrawals are paused. |
| `server_only` | 403 | The call came from a web page. Create payments from your server. |
| `reference_in_use` | 409 | An open payment has this reference with another amount, or one expired less than 2 hours after the buyer got an address and a late transfer can still arrive (`paymentId`). Cancel the open one, wait, or use a new reference. |
| `reference_paid` | 409 | A payment with this reference was already paid (`paymentId`). |
| `daily_limit` | 429 | Your payments of the last 24 hours reached your daily limit (`dailyUsd`). Try again after `Retry-After`. |
| `open_limit` | 429 | You have too many open payments (`openMax`). Cancel some, or let them expire. |
| `rate_limited` | 429 | Too many payments in a short time. Try again after `Retry-After`. |
| `payments_unavailable` | 503 | New payments cannot be created right now. Test payments still work. Try again later. |
| `not_found` | 404 | No payment with this id in your store. |
| `not_cancellable` | 409 | Only a pending payment can be cancelled. |
| `payment_started` | 409 | The buyer started paying, or money arrived. Try again after `retryAfter`, or accept what arrived. |
| `nothing_received` | 409 | Nothing arrived, so there is nothing to accept. |
| `payment_in_flight` | 409 | A transfer is still confirming (`until`). Try again after it settles. |
| `payment_busy` | 409 | Another action on this payment is running. Try again in a moment. |
| `not_underpaid` | 409 | Only an underpaid or expired payment can be accepted. |

Key errors such as `unauthorized`, `insufficient_scope` and `store_suspended` are the same as on every endpoint ([Authentication](https://docs.sellhidden.com/start#authentication)).

[Every Payments endpoint](https://docs.sellhidden.com/reference/payments)
