Public buyer API

The API a customer's own scripts, panels or reseller storefronts use to place and track orders programmatically — the same catalogue and money rules a signed-in customer's browser session uses, exposed at one endpoint.

Base URL and convention

POST https://<your-domain>/api/v2
Content-Type: application/json  (or application/x-www-form-urlencoded)

One endpoint, one action field. This mirrors the "standard SMM panel" convention most reseller software already speaks — the same protocol this project's own workers already speak as a client when reselling from upstream providers (workers/src/providers/standard-panel.adapter.ts). That means most off-the-shelf reseller tooling can point at this API with little or no custom integration work.

Every request carries two fields beyond whatever the action itself needs:

Field Required Description
key Yes Your API key (see Authentication below).
action Yes One of services, add, status, balance, refill.

Authentication

Generate a key from your account's API Keys screen (POST /api-keys, signed in). The raw key is shown exactly once — store it; the server keeps only a hash of it and cannot show it to you again.

Send it either way:

A missing or revoked key is refused with 401 Unauthorized before any action runs.

Rate limits

Requests are limited per key, independent of the general per-IP limit every endpoint on this API has. The default is 60 requests/minute per key; exceeding it returns 429 Too Many Requests.

Errors

Two different things can go wrong, and they look different on purpose:

A genuinely unexpected server fault (not a refusal this API chose to make) returns a real 5xx — those are never disguised as a 200.


Actions

services — list the active catalogue

POST /api/v2
{ "key": "...", "action": "services" }

Returns a flat JSON array (not paginated — this is the whole active catalogue, the same shape a reseller panel's own action=services call would return):

[
  {
    "service": "1f2e3a4b-...",
    "name": "Instagram Followers | Real | Max 500K",
    "type": "Default",
    "category": "Instagram",
    "rate": "1.50",
    "min": 50,
    "max": 10000,
    "dripfeed": false,
    "refill": true
  }
]

rate is the price per 1,000 units, as a decimal string, to full stored precision (a catalogue rate can carry more than two decimal places — see packages/shared/src/pricing.ts — so this is not rounded to a chargeable amount the way an actual charge is).

add — place an order

POST /api/v2
{
  "key": "...",
  "action": "add",
  "service": "1f2e3a4b-...",
  "link": "https://instagram.com/someone",
  "quantity": 1000,
  "runs": 5,            // optional — drip-feed only
  "interval": 60        // optional — minutes between runs, drip-feed only
}

service, link and quantity are required. Response:

{ "order": "9c7e...-order-id" }

Places the order through the exact same path (OrderPlacement in @uhq/db) a signed-in customer's POST /orders uses: the same pricing, the same wallet hold, the same coupon and rank rules. A wallet without enough available balance, a service that is not orderable, or a quantity outside the service's bounds all come back as {"error": "..."}.

status — check an order

POST /api/v2
{ "key": "...", "action": "status", "order": "9c7e...-order-id" }
{
  "charge": "1.50",
  "start_count": 0,
  "status": "In progress",
  "remains": 400,
  "currency": "USD"
}

status is one of: Pending, Processing, In progress, Completed, Partial, Canceled, Failed, Refunded. An order id that does not exist, or exists but was not placed with this key, answers identically — {"error": "Order not found."} — so an id cannot be used to probe whether it belongs to somebody else.

balance — wallet balance

POST /api/v2
{ "key": "...", "action": "balance" }
{ "balance": "48.32", "currency": "USD" }

balance is what may actually be spent right now — the wallet's balance less whatever is already held against pending orders, not the raw stored balance.

refill — request a refill

POST /api/v2
{ "key": "...", "action": "refill", "order": "9c7e...-order-id" }
{ "refill": "4a1b...-refill-id" }

Only valid for a Completed or Partial order on a service that supports refill. A second refill request while one is already open returns the existing request rather than creating a duplicate.


Webhooks

Rather than polling status on a schedule, register an endpoint and be told about three order-lifecycle events as they happen: order_created, order_completed, order_failed.

Registering an endpoint

Signed in (not via the buyer API — this is account configuration, done once, typically from a dashboard rather than a script):

POST /api/v1/webhooks
Authorization: Bearer <your session access token>
{ "url": "https://your-server.example.com/hooks/uhq" }
{
  "subscription": {
    "id": "...",
    "url": "https://your-server.example.com/hooks/uhq",
    "events": ["order_created", "order_completed", "order_failed"],
    "status": "ACTIVE",
    "createdAt": "..."
  },
  "secret": "whsec_..."
}

secret is returned exactly once, at creation — store it; it is never retrievable again, and you will need it to verify every delivery. Pass "events": ["order_completed"] (or any subset) if you only want some of the three. GET /api/v1/webhooks lists your endpoints (without their secrets); DELETE /api/v1/webhooks/:id stops deliveries to one.

Endpoints must be https:// — a webhook carries a signed copy of your own order data, and there is no reason to offer that in plaintext.

What a delivery looks like

POST <your url>
Content-Type: application/json
X-Uhq-Event: order_completed
X-Uhq-Signature: t=1735689600,v1=5257a869e7...

{
  "event": "order_completed",
  "order": {
    "id": "9c7e...",
    "serviceId": "1f2e...",
    "link": "https://instagram.com/someone",
    "quantity": 1000,
    "status": "COMPLETED",
    "chargedMinor": "150",
    "createdAt": "2026-09-12T09:00:00.000Z"
  },
  "timestamp": 1735689600
}

chargedMinor is the charge in minor currency units (cents, for USD) as a string — the same representation the rest of this project's money code uses internally, so nothing is lost to floating-point rounding on the way out.

Verifying a delivery

The scheme is deliberately the one Stripe uses, so if you have already written a Stripe webhook handler you can reuse most of it: the header is t=<unix-seconds>,v1=<hex>, and the signature is an HMAC-SHA256 over "<t>.<raw request body>", keyed by your endpoint's secret.

const crypto = require("crypto");

function verify(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=")),
  );
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  return age < 300 && crypto.timingSafeEqual(
    Buffer.from(parts.v1),
    Buffer.from(expected),
  );
}

Compute the HMAC over the raw bytes of the request body — re-serialising parsed JSON can reorder keys or change whitespace and will not match.

A delivery your endpoint answers with anything other than 2xx is retried with backoff; after the retry budget is exhausted it is recorded as failed and not retried again.


What this API does not (yet) do

Honestly, not everything the wider brief names for this surface:

None of these are architectural limits — each is a small, additive change to PublicApiService and its DTO — they simply were not in scope for the first version of this surface.