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.
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. |
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:
key=<your key>Authorization: Bearer <your key>A missing or revoked key is refused with 401 Unauthorized before any
action runs.
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.
Two different things can go wrong, and they look different on purpose:
401 or 429.200 OK with a JSON body of {"error": "<message>"}
— the same convention the wider SMM-reseller ecosystem uses, so existing
client code that only checks the response body for an error key keeps
working without also having to branch on HTTP status.A genuinely unexpected server fault (not a refusal this API chose to make)
returns a real 5xx — those are never disguised as a 200.
services — list the active cataloguePOST /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 orderPOST /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 orderPOST /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 balancePOST /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 refillPOST /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.
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.
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.
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.
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.
Honestly, not everything the wider brief names for this surface:
order_partial,
no gateway/payment events).status looks up one order per call; there is no bulk
orders=1,2,3 form some panels also offer.cancel action, even though the underlying order-cancellation path
(OrderActionsService.requestCancel) already exists for signed-in
customers — it was not part of the five actions the brief names for this
endpoint.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.