# Reseller API

Base URL: `https://perkshelf.dev/api/v1/buyer`. This API is for server-to-server integrations. Keep the key on your server, not in storefront JavaScript.

Create a website account and generate a key at `/dashboard/api`. Choose wallet funding or a purchase-sized checkout invoice; a large prefunded PerkShelf balance is not required. There is one active key per account. Rotation invalidates the old key; revocation disables access without deleting orders or changing the wallet. A replacement key can read the same account's API orders. Revocation does not cancel issued payment invoices; cancel an unpaid invoice separately if you no longer intend to pay it. Keys are stored as hashes and shown only when generated.

The public [OpenAPI specification](https://perkshelf.dev/api/v1/buyer/openapi.json) describes the request and response fields.

## Authentication and pricing

Send `Authorization: Bearer <key>` on every authenticated request. All amounts are USD; integer `price_cents` and `balance_cents` fields avoid floating-point calculations. Purchases use the website account's prepaid wallet, including for an admin using an API key. There is no API pay-later credit.

An account-specific admin quote applies to catalog and checkout prices. Prices stay at or above the cost of an available fulfillment source and at or below the public shelf. Without a quote, the normal shelf price applies. No source IDs, credentials or wholesale costs are returned.

Catalog stock and prices use source caches of up to 60 seconds. Checkout reads live data before a new reservation. Set `max_unit_price` if your store must reject a price increase before debit. Do not assume a previously listed item is still available.

## Choose a purchase flow

**Wallet purchases:** create a small top-up with `POST /deposits`, pay it, wait for approval, then call `POST /orders`. The top-up does not buy anything by itself. You can keep a small working balance rather than fund a large amount.

**Just-in-time purchases:** call `POST /checkouts` with `product_id`, `quantity`, `external_order_id`, `method` and, for on-chain crypto, `network`. The response freezes that purchase's USD price and contains its invoice. Pay the exact returned `pay_amount`, then poll `GET /checkouts?external_order_id=...`. Confirmed receipt credits and consumes the purchase amount atomically, books the unit orders, and starts the normal background delivery process. Any pre-existing wallet balance is unchanged for Wise. Crypto matching cents remain as extra credit in your wallet. You do not need a second purchase call or a large PerkShelf balance.

Checkouts do not reserve supplier stock before payment. If delivery cannot finish, the charged order stays pending for retry/operator review. Its quoted price remains fixed. Checkout totals must be $1–$10,000 and quantity 1–20; use wallet purchases for smaller totals. Up to 20 invoices can be awaiting payment per account, including website and API invoices. Checkout creation also allows at most 60 quoted units per account per minute.

### Wise: what can be automated

The API returns the receiving Wisetag and the exact **USD amount to arrive**, including PerkShelf's Wise FX adjustment and matching cents. It does not issue a Wise payment link, collect ACH/SWIFT/bank receive details, or accept your Wise credentials. Use Pay-with-Wise to that Wisetag. If paying from another currency, arrange conversion on your side so the receiver gets the exact USD invoice amount. Your Wise fees are separate.

PerkShelf automatically matches the signed USD credit webhook on its configured receiving profile. A sender transfer ID or a screenshot does not mark the invoice paid. An authorized operator can reconcile a missed payment after review; reseller API calls cannot force approval. You can poll invoice or checkout status without a Binance account.

Your integration initiates and recovers payments using your own Wise account and credentials. PerkShelf supplies the invoice and recipient, confirms receipt, and fulfills the purchase. Follow [Wise's authentication and supported operations](https://docs.wise.com/guides/developer/auth-and-security) for your sender-side integration; our API does not authorize or fund it. See [Wise balance-credit events](https://docs.wise.com/api-reference/webhook-event/eventbalancesupdate) for the receiving confirmation format.

PerkShelf's side is the same either way: issue invoice, confirm incoming payment, book order, deliver through the API. Customer payment collection and your Wise authorization remain on your server.

## Endpoints

| Method | Path | Result |
| --- | --- | --- |
| GET | `/products` | Account-priced products, availability and stock |
| GET | `/products/{slug}` | One product, including when sold out |
| GET | `/balance` | Prepaid USD wallet balance |
| POST | `/orders` | Reserve/debit a purchase and attempt delivery |
| GET | `/orders?external_order_id={id}` | One purchase batch, its unit invoices and delivery content |
| GET | `/orders/{invoice}` | One unit invoice and its delivery content |
| GET | `/orders` | Cursor-paginated API order history |
| GET | `/payment-methods` | Available rails and on-chain network IDs; no recipient addresses before an invoice |
| POST | `/deposits` | Idempotent wallet top-up invoice |
| GET | `/deposits?external_deposit_id={id}` | Recover a top-up invoice by your own ID |
| GET | `/deposits` | Cursor-paginated API payment invoice history |
| GET | `/deposits/{reference}` | Invoice status and frozen recipient instructions |
| POST | `/deposits/{reference}/verify` | Submit a crypto `tx_id`, or read Wise webhook confirmation state |
| DELETE | `/deposits/{reference}` | Cancel an unpaid invoice and its linked checkout |
| POST | `/checkouts` | Frozen-price product invoice for just-in-time funding |
| GET | `/checkouts?external_order_id={id}` | Payment, order and delivery status for that checkout |
| POST | `/purchase` | Existing purchase endpoint with legacy pending-response behavior |

Product IDs are PerkShelf slugs from the catalog. `stock: 0` means sold out; `stock: null` with `in_stock: true` means availability has no counted total (an unlimited feed or an uncounted cached snapshot). Plan-picker pages are excluded; their deliverable packages are listed as separate products.

Product responses also include highlights, redemption steps, sales policy, a storefront URL and an optional SVG brand-mark URL (not a product photograph). Respect `image_invert_on_dark` when rendering a monochrome mark on a dark background; do not invert colourful marks.

Catalog filters: `q` (name/slug search), `category` (case-insensitive exact category), `in_stock=true|false`, optional `limit=1..100`, and `cursor`. Without `limit`, the full filtered catalog is returned. For another page, pass `next_cursor` back as `cursor` with the same filters. If the catalog changes and the cursor is no longer valid, restart from the first page.

Order history defaults to 50 invoices, at most 100 per page. It returns `data` and `next_cursor` (null at the end). Preserve filters when passing a cursor. `status` accepts `pending_fulfillment`, `completed`, `refunded` or legacy `failed`. Supplying `external_order_id` selects the batch lookup instead of the history list. These endpoints expose this account's API purchases, not another customer's orders or admin restocks. Older invoices may have a null `external_order_id`; an existing idempotency receipt can still be looked up by its known external ID.

## Payment invoices

`POST /deposits` accepts `amount` (requested USD credit, $1–$10,000, at most two decimal places), `method` (`wise`, `binance`, `usdt`, `usdc`), required `external_deposit_id` (1–128 characters), and `network` for on-chain payments. Get valid network IDs from `/payment-methods`; do not supply a network for Wise or Binance Pay. Repeating an ID with the same amount/method/network returns the original invoice. Changing those fields returns 409.

```bash
curl "$BASE/deposits" \
  -H "Authorization: Bearer $PS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount":1,"method":"wise","external_deposit_id":"your-topup-1001"}'
```

This example requests a $1 wallet credit, not a product price. Always pay the **returned** invoice amount, which includes unique matching cents and, for Wise, the FX adjustment. Wise credits the requested USD amount only; its FX adjustment and matching cents are not wallet credit. Crypto credits the full received amount, including matching cents; a linked checkout consumes only its quoted product total and leaves the extra cents in the wallet. Payment details are frozen on the invoice. The selected on-chain coin/network and memo, if present, must be followed exactly. Do not subtract network fees from the amount that reaches the receiver.

For Binance Pay or on-chain invoices, submit the payment ID/hash:

```bash
curl "$BASE/deposits/$PAYMENT_REFERENCE/verify" \
  -H "Authorization: Bearer $PS_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"tx_id\":\"$TX_ID\"}"
```

A payment must arrive after invoice creation, within the 72-hour matching window, on the correct rail/coin/network and for the exact amount. Transaction IDs are locked against reuse. Pending crypto IDs are saved for verification retries. HTTP 202 is pending/reconciling; read invoice status before paying again. Wise is automatically confirmed by the signed incoming-credit webhook; an authorized operator can reconcile a missed match after review. `POST /verify` cannot approve it from caller-provided data.

Cancel only invoices you have **not paid**. `DELETE /deposits/{reference}` is idempotent for an already cancelled invoice; it never refunds money. Cancelling also stops an unpaid linked checkout. Approved payments cannot be cancelled this way. Do not pay expired/cancelled/rejected invoices; contact support if money was already sent. Payment statuses include `awaiting_payment`, `approved`, `cancelled`, `rejected` and derived `expired`.

## Just-in-time checkout example

```bash
curl "$BASE/checkouts" \
  -H "Authorization: Bearer $PS_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"product_id\":\"$PRODUCT_ID\",\"quantity\":1,\"external_order_id\":\"your-sale-2001\",\"method\":\"wise\"}"

# Pay checkout.payment.pay_amount USD to checkout.payment.instructions.wisetag.
# The send/funding is done in your Wise account, subject to its access limits.

curl "$BASE/checkouts?external_order_id=your-sale-2001" \
  -H "Authorization: Bearer $PS_API_KEY"
```

`max_unit_price` can protect the initial checkout quote. Repeating a checkout ID returns the original quote/invoice even if the catalog changed; changing product, quantity or rail/network returns 409. Checkout IDs share the wallet order namespace: do not call `POST /orders` to pay an awaiting checkout, or reuse its ID for a different flow.

Before payment, `checkout.order` is null and `checkout.status` describes the invoice. After approval, `checkout.order` contains the same unit-invoice/delivery batch as `/orders`. Poll every 10 seconds; background delivery retries run every two minutes. A rare `payment_review` state leaves the confirmed credit in the wallet and requires support rather than an unverified or duplicate debit. Operator refunds go to the PerkShelf wallet, not automatically back through Wise/crypto.

If a response is lost while creating an invoice/checkout, repeat the same request with the same external ID. If a verification response is lost, read status and repeat verification with the same transaction. Never send another payment to recover an uncertain response.

## Creating a wallet order

Save your own unique order ID before sending the request. Use a different ID for each new purchase, even when two customers buy the same SKU.

```bash
export BASE="https://perkshelf.dev/api/v1/buyer"
# Set PS_API_KEY securely on your server.
# Choose PRODUCT_ID from GET /products.

curl "$BASE/orders" \
  -H "Authorization: Bearer $PS_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"product_id\":\"$PRODUCT_ID\",\"quantity\":1,\"external_order_id\":\"your-order-1001\"}"
```

The body accepts `product_id` or the existing `slug` alias; if both are sent they must match. `quantity` is an integer from 1 to 20 and defaults to 1. Optional `max_unit_price` is a positive USD unit-price ceiling. Unknown body fields are rejected.

`external_order_id` is required unless you send `Idempotency-Key`. Either ID is 1–128 characters after trimming. If body and header IDs are both present, they must match. Reusing an ID for a different product or quantity returns 409. The same ID/product/quantity retrieves the original charge and invoices, even after the product sells out, prices move or a refund occurs. A new `max_unit_price` does not rewrite a charge already made.

Each unit gets its own `PS-...` invoice. A response includes the purchase batch in `order`, with individual invoices in `order.orders`. Deliveries may be voucher codes, activation links, logins or instructions; treat the `codes` entries as delivery text. A unit's delivery content is returned only when its status is `completed`.

## Pending delivery and uncertain responses

A successful POST to `/orders` returns `success: true` and `accepted: true`. Inspect `status`, not just `success`:

- `completed`: every unit is delivered.
- `pending_fulfillment`: delivery is still pending for at least one unit. Some units may already be ready.
- `processing`: the reservation response was uncertain or the receipt needs reconciliation. It does not prove that the wallet was debited.
- `refunded` or `partially_refunded`: operator-reviewed refund state. Reusing the ID does not buy replacement units.

For HTTP 202, wait at least 10 seconds and poll:

```bash
curl "$BASE/orders?external_order_id=your-order-1001" \
  -H "Authorization: Bearer $PS_API_KEY"
```

If the connection drops or the POST result is `REQUEST_PENDING`, use the same lookup. If it returns 404, retry POST with the same product, quantity and external ID. Never switch to a new ID to recover an uncertain purchase. Once recorded, a charged order continues through the existing background fulfillment retry process; GET reads do not debit or place orders.

For partial batches, process `order.orders` individually. Deliver each unit once, using its invoice as your delivery deduplication key. Do not zip the top-level `codes` array to all invoices: not every invoice may have been delivered yet.

The existing `/purchase` endpoint keeps top-level `invoices`, `keys`, `product`, `quantity`, `unit_price`, `total_charged`, `currency`, `created_at` and `delivered_content` fields. Known pending delivery returns HTTP 202 with `success: false` and `error: "DELIVERY_PENDING"`; an uncertain reservation returns `REQUEST_PENDING`. It now also requires an external ID or `Idempotency-Key`. Quantity must be a JSON integer. New integrations should use `/orders`.

## Errors and limits

Rejected/read-error responses contain `success: false`, a machine-readable `error`, and a human-readable `message`. Validation errors may include `details`.

| HTTP | Meaning | Action |
| --- | --- | --- |
| 400 | Invalid request, missing idempotency ID or invalid catalog cursor | Correct the request; restart catalog pagination if needed |
| 401 | Missing Bearer key | Add the authorization header |
| 403 | Invalid/revoked key | Use the account's current key |
| 402 | Insufficient wallet funds | Fund the wallet; the rejected new purchase did not debit |
| 404 | Product or owned API order not found | Check the ID; for an uncertain POST, retry with the same external ID |
| 409 | Idempotency conflict or `PRICE_CHANGED` | Correct the conflicting product/quantity or review the price ceiling; no new debit |
| 413 | Body exceeds 8,192 characters | Send only the supported fields |
| 429 | Rate limit reached | Wait the `Retry-After` seconds |
| 503 | Authorization/read service unavailable, or delivery not configured before reservation | Retry shortly, preserving any purchase ID |

Read requests: 60 per account per minute per server instance. Write requests (orders, invoice/checkout creation, verification and cancellation): 20 per account per minute per instance. Authentication protection: 120 requests per IP per minute per instance. Rotating a key does not reset the account request bucket. Those request buckets are in-memory, not a global distributed quota. Wallet API purchases also have a database-enforced limit of 60 new units per account per minute; replaying an existing charge does not consume new units. Checkout creation separately limits 60 quoted units per minute. Already confirmed payments are booked at their agreed quote rather than rejected after money arrives.

Responses containing account prices, balances or deliveries are private and `no-store`. Do not put them in a shared cache. API delivery does not send email or webhooks. Customers can open a support ticket at `/dashboard/tickets`; refunds remain operator-controlled.
