{"openapi":"3.1.0","info":{"title":"NSIN Reseller API","version":"1.0.0","description":"The API a reseller uses to manage his own clients on NSIN.\n\n**This document is the contract.** The Go handlers are generated from it\n(`oapi-codegen`), and the reseller dashboard's TypeScript client is generated\nfrom it (`openapi-typescript`). Nothing is hand-written on either side. If a\nbehaviour is not described here, it does not exist.\n\n## Who NSIN is\n\nNSIN is the **provider**, not a reseller. There is no \"reseller #1\".\nA direct NSIN customer has `reseller_id = null`.\n\n## Money, in one paragraph\n\nA reseller tops up his own wallet at **face value** through the existing\nZarinpal flow (not in this API), and everything his clients consume is paid\nfor out of that wallet. A plan purchase debits **the client's** wallet at\nNSIN list price, but the money is the reseller's: `POST /services` moves the\nshortfall from his wallet into the client's inside the same transaction —\nboth ledger rows carry `ref_type: reseller_autofund` — and only then buys\nthe plan. Pre-funding a client with `POST /clients/{clientId}/credit` is\ntherefore **optional**; it makes that automatic top-up smaller, or a no-op.\nThere is no reseller discount at purchase time, ever.\n\n**Traffic is not billed to a reseller's client at all.** He has no\npay-as-you-go: usage beyond the plan's included GB is recorded with\n`charged_rials = 0`, no wallet is debited, and the quota sweep suspends his\ndomain instead. `max_traffic_gb` is a cap, not a meter, so a reseller's cost\nfor a client is exactly the plans he bought him — nothing arrives later.\n\nBecause the reseller is the one paying, a 402 on a purchase is about **his**\nwallet, not his client's (`credit_limit_reached`). An admin can grant him an\noverdraft — `reseller_max_negative_rials`, default `0`, so by default a\npurchase he cannot afford simply fails — and a reseller who stays below zero\npast the configured grace window has every domain in his tenancy, his own\nand all his clients', suspended until he settles.\n\nThe client's wallet is real, but the client can never see or reach it: every\nwallet, invoice, price and ticket endpoint returns 403 for a user with a\n`reseller_id`.\n\n## What the reseller's clients never receive\n\nAll business SMS and email about a reseller's client (domain down, plan\nexpiring, balance negative, ...) is redirected to **the reseller**, not the\nclient. Two things are deliberately exempt and still go to the client:\n**login OTP** and **password reset** — both are account recovery, and\nsuppressing either would lock the client out of the panel permanently.\n\n## Amounts\n\nEvery monetary field is an integer count of **Rials** (`*_rials`), matching\nthe backend. The UI divides by 10 to display Toman. Never send a float.\n\n## Rate limits\n\n**Every operation in this document is throttled.** One limiter is mounted on\nthe whole `/reseller/v1` group, so all 83 routes share the same budget:\n**300 requests per minute** by default, per deployment\n(`RESELLER_RATE_LIMIT`). Over budget is `429` with\n`{\"error\": \"Rate limit exceeded. Slow down and retry shortly.\", \"code\": \"rate_limited\"}`.\n\nThe budget is counted **per credential, not per reseller**. Each `nsin_live_`\ntoken has its own, and a dashboard session has another. That is deliberate: a\nrunaway WHMCS cron must not be able to lock its owner out of his own panel,\nand revoking that one token must be enough to stop it.\n\nTwo mechanics to build against rather than discover:\n\n* The window is **fixed, not sliding**. The counter resets a minute after the\n  window's first request, so two bursts either side of a reset put 600\n  requests through in seconds and are both inside the limit. Do not build a\n  client that depends on that.\n* The counter lives **in the API process's memory** and is not shared between\n  instances, so a deployment running more than one API process gives a caller\n  roughly one budget per process. Treat 300/min as the floor you are\n  guaranteed, never as a ceiling you can measure.\n\nThe limiter is mounted **after** authentication and the reseller check, so a\nrejected credential never spends anyone's budget — and a `401` or `403` tells\nyou nothing about how much of yours is left.\n\nEvery response the limiter lets through carries `X-RateLimit-Limit`,\n`X-RateLimit-Remaining` and `X-RateLimit-Reset` (seconds until the window\nresets). The `429` itself does **not** carry those; it carries `Retry-After`,\nin seconds. Sleep for that rather than retrying immediately.\n\nOnly the operations most likely to reach the limit — the bulk and list reads\nan integration walks in a loop, and the money writes — declare `429`\nindividually. See the `TooManyRequests` response component; it applies to\nevery operation here, listed or not.\n"},"servers":[{"url":"https://api.nsin.cloud","description":"Production"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Meta","description":"One operation, `ping`: an authenticated round trip returning the caller's `reseller_id` and company name, so an integration can prove a credential reaches the right tenancy before it starts provisioning. WHMCS `TestConnection` maps here."},{"name":"Clients","description":"The end-user accounts the caller owns (`users.reseller_id`) — provision one without an OTP, amend it, set its password, and disable or enable its panel login. Their money is under Wallet and their sites under Domains; there is deliberately no delete, and disabling an account does not stop its domains being served."},{"name":"Wallet","description":"Both sides of the money: the reseller's own balance and ledger, any client's balance and ledger, and `credit` — the only call whose entire purpose is moving Rials between two users. It is optional: buying a plan under Services moves the same money by itself, from the reseller's wallet into the client's, inside the purchase transaction. The list prices it all ends up spent at are under Catalog."},{"name":"Domains","description":"The domain object and its lifecycle: add one for a client, read it, list one client's domains or the whole fleet, force a delegation check, change its settings, delete it, and disable/enable it — the switch that actually stops traffic and therefore the meter. What is configured inside a domain lives under Records, Rules, Cache, SSL, Members and Uptime."},{"name":"Services","description":"A service is one `subscriptions` row: buy a plan term for a client's domain against that client's wallet, change it, cancel it, and list across the tenancy what is live and what expires when. Cancelling is a billing act that leaves the site serving — the off switch is `POST /domains/{domainId}/disable` under Domains."},{"name":"Catalog","description":"`GET /plans` alone: the active plans with their active terms and list prices in Rials, which the reseller is shown and his clients never are. Purchases are keyed on `terms[].id`, so this is where a `plan_term_id` comes from."},{"name":"Usage","description":"One bulk aggregate of billable traffic per client and domain over a `from`/`to` date range (`to` is an inclusive day), in bytes cached, proxied and direct plus the Rials already charged. It is the consumption report a daily WHMCS `UsageUpdate` sweep reads in a single call — charts are under Analytics and individual requests under Logs."},{"name":"Tokens","description":"The caller's own `nsin_live_` machine credentials — list, mint once, revoke — not his clients' accounts and not the customer-panel API keys documented in `openapi.yaml`. All three refuse a machine token and require a dashboard session JWT, so a leaked token can neither mint a quieter replacement nor revoke the credential you would use to lock it out."},{"name":"Diagnostics","description":"Two read-only endpoints for working out why an integration is not behaving: `activity` is the reseller's own request log — every call he has made to this API, with the status and error it received — and `diagnostics` is a single round trip that reports which credential arrived, what it resolved to, and the request budget it is spending. They exist because the only evidence a partner previously had was `last_used_at`, a single date, which cannot distinguish a rejected credential from a request that never arrived."},{"name":"Support","description":"Read and write the contact block stored on the reseller's own `users.support_contact`, which his clients are shown in the panel in place of the ticket UI they are 403'd out of. It is an escalation address, not a ticket queue — this API has no ticketing."},{"name":"Analytics","description":"Aggregates: the tenancy-wide traffic roll-up, day-over-day request buckets, and per-domain summary, requests, bandwidth, status-code and cache-ratio series, plus the Markdown-for-Agents preview. The individual rows these counts are computed from are under Logs, and everything here answers 503 rather than a page of zeros when ClickHouse is unreachable."},{"name":"Logs","description":"The raw rows themselves — one per request the edge served and one per WAF decision, with end-user IP, user agent, headers, cache status and matched rule ids — fleet-wide or narrowed to one domain. Paged with `limit`/`offset` rather than `page`/`per_page`, and with no plan-feature gate, unlike the customer API's `/analytics/logs`."},{"name":"Records","description":"DNS records on one of the caller's domains: CRUD, a scan of the domain's live DNS at its current host, zone-file parse and import, and batch update/delete for the bulk changes a per-record loop would rate-limit itself out of. These run the customer panel's own `record` handlers, so the bodies are the customer reference's; a disabled domain is read-only and writes answer 409 `domain_disabled`."},{"name":"SSL","description":"Certificates: the fleet list across every client sorted by soonest expiry, and per domain the current status, custom-certificate upload, PEM inspection before installing, and forced issuance. Private keys are never returned by anything in this document, and issuance and renewal are automatic — `issue` is only the escape hatch for a delegation the customer has just fixed."},{"name":"Cache","description":"What the edge is currently holding for a domain and how to drop it: purge everything, browse the cached entries, read the per-node totals, or purge selected keys. Cache policy — TTLs, bypass conditions — is a rule type under Rules, and how much traffic was served from cache is under Analytics."},{"name":"Rules","description":"The domain's edge rules, all twelve types (`waf`, `cache`, `redirect`, `rewrite`, `rate-limit`, `origin_pool`, ...) behind one route set with the type as `{ruleType}`, rather than the twelve near-identical route sets the customer API mounts. Rules of a type are evaluated in priority order so `reorder` is a behaviour change, and an unrecognised `ruleType` is 404 rather than an empty list."},{"name":"Members","description":"Who besides the owner can reach a client's domain — the member list, role changes, removal, and the invitation flow that creates an NSIN account for someone who has none. The reseller holds this because a managed client is blocked from his own sharing endpoints (`RoleProvider` carries `PermMembersManage`, the client does not), so his agency or developer can only be added here."},{"name":"Uptime","description":"Origin-outage detection for one domain: the history of sustained incidents, the live per-subdomain error picture including hosts that have not crossed the threshold, and the detection thresholds behind both. This is the detector's judgement rather than 5xx counting under Analytics, and its settings write is the one configuration write accepted on a disabled domain."}],"paths":{"/reseller/v1/ping":{"get":{"tags":["Meta"],"operationId":"ping","summary":"Verify credentials and connectivity","description":"Cheap liveness + auth check. WHMCS `TestConnection` maps here.","responses":{"200":{"description":"Authenticated","content":{"application/json":{"schema":{"type":"object","required":["ok","reseller_id","company"],"properties":{"ok":{"type":"boolean","const":true},"reseller_id":{"type":"integer","examples":[7]},"company":{"type":"string","nullable":true,"examples":["Parsa Host"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/clients":{"get":{"tags":["Clients"],"operationId":"listClients","summary":"List my clients","description":"Every user whose `users.reseller_id` is the caller, newest first (`id DESC`) — a direct NSIN customer, or another reseller's client, is outside the query that builds the page rather than filtered out of it. `q` is a raw substring match against `email`, `phone_number` and `name`, any one of which may hit; phone numbers are stored normalized (`09123456789`), so searching for `+98912…` finds nothing. Disabled accounts come back like any other — there is no `active` filter — so read `active` per row.\n\nEach row carries `wallet_balance_rials`, the number the client himself is never shown. A balance that fails to load is rendered as `0` and logged, so a zero here is not proof of an empty wallet. `per_page` above 100 is clamped rather than rejected: ask for 1000 and you silently get 100, so drive your loop off `meta.total`.\n","parameters":[{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/PerPage"},{"name":"q","in":"query","description":"Free-text match on name, email, or phone.","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of clients","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Client"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Clients"],"operationId":"createClient","summary":"Create a client","description":"Creates an end user owned by the calling reseller (`users.reseller_id = <caller>`).\n\n**No OTP is required or consumed.** A reseller provisions a client from\nhis own system, where there is nobody to type a code. The client is\ncreated with `phone_verified = true` and\n`phone_verified_by = \"provider\"`: you entered the number and you vouch\nfor it, so your customer is never challenged to re-prove it.\n\nHe is upgraded to `phone_verified_by = \"otp\"` automatically the first\ntime he happens to sign in with a one-time code — that is stronger\nevidence, and it is recorded as such.\n\nNote that a client detached from your account by an NSIN administrator\nloses a `provider` verification and must prove the number himself, since\nthe vouching that backed it no longer applies.\n\nThis does not weaken the public `POST /auth/register`, which still\nrequires an OTP. The shared creation logic is factored out; the OTP gate\nstays on the public route only.\n\nPhone and national code are validated with the same validators the panel\nuses (`ValidIranianPhone`, `ValidIranianNationalCode`) — a reseller must\nsupply real identity data, not placeholders.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateClientRequest"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"Email, phone, or national code already registered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/clients/{clientId}":{"parameters":[{"$ref":"#/components/parameters/ClientId"}],"get":{"tags":["Clients"],"operationId":"getClient","summary":"Get one client","description":"The same object one row of `GET /clients` carries, `wallet_balance_rials` included. `clientId` is the numeric `users.id` — an email or a name is not accepted.\n\nA client belonging to another reseller, a client id that never existed, and an id that is not a number all answer **404** with the same body. That is deliberate: a 403 that read differently from a 404 would let you walk the id space and count a competitor's customers, so outside your tenancy nothing exists. The 403 below means only that the credential itself is not a reseller's.\n","responses":{"200":{"description":"The client","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"patch":{"tags":["Clients"],"operationId":"updateClient","summary":"Update a client","description":"Partial update. Omitted fields are left unchanged.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateClientRequest"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/clients/{clientId}/disable":{"parameters":[{"$ref":"#/components/parameters/ClientId"}],"post":{"tags":["Clients"],"operationId":"disableClient","summary":"Disable a client's account","description":"Sets `users.active = false`. The client can no longer log into the panel.\n\nThis is **account** suspension, and is distinct from **domain**\ndisabling (`POST /domains/{domainId}/disable`), which is what stops a\nsite from being served. Disabling the account alone does not stop\ntraffic, and therefore does not stop billing. To stop the meter, disable\nthe domains.\n","responses":{"200":{"description":"Disabled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/clients/{clientId}/enable":{"parameters":[{"$ref":"#/components/parameters/ClientId"}],"post":{"tags":["Clients"],"operationId":"enableClient","summary":"Restore a client's account","description":"Sets `users.active = true`, and that is the whole effect. The client can sign in again — login and OTP request stop refusing him, and the routes that answer `account_suspended` let him through — but the sessions revoked when you disabled him are **not** restored; he logs in fresh.\n\nIt does not touch his domains. Anything you stopped with `POST /domains/{domainId}/disable`, or the system stopped for quota or balance, is still stopped and needs its own enable. Enabling a client who is already enabled rewrites the same value and returns the same `Client`, so a retry is safe.\n","responses":{"200":{"description":"Restored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/clients/{clientId}/password":{"parameters":[{"$ref":"#/components/parameters/ClientId"}],"post":{"tags":["Clients"],"operationId":"setClientPassword","summary":"Set a client's password","description":"Writes a new bcrypt hash and then revokes **every session the client holds**. The auth middleware checks the session row on each request, so whoever was signed in as him is thrown out on his next call — which is the point: a password is reset to take an account back, and leaving the old sessions alive would achieve nothing.\n\n**Nobody tells the client.** No SMS, no email and no panel notice is sent, so the new password exists only where you put it and delivering it is your job. Minimum 8 characters, counted in runes — stricter on purpose than account creation, which accepts any non-empty password, because you are choosing this one on somebody else's behalf.\n\nOne trap: if the hash is written but the session revocation then fails, the call still answers `204` and only logs the failure. The password really did change; the old sessions may have survived it.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["password"],"properties":{"password":{"type":"string","minLength":8,"format":"password"}}}}}},"responses":{"204":{"description":"Password set"},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/clients/{clientId}/credit":{"parameters":[{"$ref":"#/components/parameters/ClientId"}],"post":{"tags":["Wallet"],"operationId":"creditClient","summary":"Transfer credit from my wallet to a client's wallet","description":"Moves Rials from the calling reseller's wallet into the client's wallet.\n\n**This call is optional.** `POST /services` performs the same transfer\nitself, for exactly the shortfall the purchase needs, inside the purchase\ntransaction — a client never has to be pre-funded to be provisioned.\nPre-crediting him only makes that automatic top-up smaller, or a no-op.\nUse it if you want the balance sitting there in advance; skip it and\nprovisioning still works.\n\nAtomic: one debit and one credit, in a single transaction, or neither.\nBoth wallet rows are locked `FOR UPDATE` in ascending id order to avoid\ndeadlock under concurrent transfers.\n\n**This call cannot overdraw you.** It debits your wallet only if the full\namount is already there, and answers 402 otherwise. The overdraft an\nadmin may have granted you (`reseller_max_negative_rials`, default `0`)\nis reachable through a PURCHASE, not through this transfer.\n\nSo a reseller's wallet CAN go negative, and there IS a reseller-side\nsuspension policy: stay below zero past the configured grace window\n(`reseller_negative_grace_days`) and every domain in your tenancy — your\nown and all your clients' — is suspended. Bringing the balance back to\nzero or above resumes the domains that ladder suspended; a domain you\ndisabled by hand stays disabled.\n\nA rejected transfer writes **no** ledger row. Not a reversed one. None.\n\nThe pair of rows this writes carries `ref_type: reseller_transfer`; the\nautomatic top-up under `POST /services` writes `reseller_autofund`. That\nis how you tell the two apart when reconciling a ledger.\n","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amount_rials"],"properties":{"amount_rials":{"type":"integer","format":"int64","minimum":1,"description":"Must be positive. To claw credit back, see `debit`.","examples":[5000000]},"description":{"type":"string","maxLength":191,"description":"Shown on both ledger rows."}}}}}},"responses":{"200":{"description":"Transferred — or, if this `Idempotency-Key` was already used for this\nexact request, the first call's response replayed unchanged.\n","headers":{"Idempotent-Replay":{"description":"Sent as `true` only on a replay: the body is the stored answer\nfrom the first call with this `Idempotency-Key`, and the\noperation did NOT run a second time. Absent on the call that\nactually did the work — which is how a retrying client tells\nwhich of its attempts moved the money.\n","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TransferResult"}}}},"400":{"description":"A malformed body, or a value the handler rejects outright.\n\nAlso the answer to a **missing `Idempotency-Key`**: the generated\nwrapper rejects that before the handler is entered, so the body\ncarries the wrapper's message rather than the handler's. A key longer\nthan 191 bytes is rejected by the handler, with a message saying so.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Insufficient funds in the reseller's wallet: this transfer is checked\nagainst the balance you actually hold, with no overdraft. No ledger\nrow was written, and no idempotency key was recorded — retry with the\nsame `Idempotency-Key` once you have topped up.\n\nThe body has no `code`. A 402 from `POST /services` is a different\nfailure and carries `\"code\": \"credit_limit_reached\"`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/IdempotencyConflict"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/clients/{clientId}/debit":{"parameters":[{"$ref":"#/components/parameters/ClientId"}],"post":{"tags":["Wallet"],"operationId":"debitClient","summary":"Withdraw credit from a client's wallet back into mine","description":"The reverse of `credit`: moves Rials out of the client's wallet and back\ninto the calling reseller's. Use it to reclaim funds you advanced — a\nclient who cancels, a top-up sent to the wrong account, a trial you are\nwinding down.\n\nMechanically it is the same transfer as `credit` with the two parties\nswapped, so it inherits the same guarantees: one debit and one credit in a\nsingle transaction or neither, both wallet rows locked `FOR UPDATE` in\nascending id order so concurrent transfers in opposite directions cannot\ndeadlock, and **no ledger row at all** when it is refused.\n\n**It cannot take a client's balance below zero.** There is no overdraft on\nthis path in either direction. Asking for more than the client holds is\n`402` and nothing moves — so a client whose wallet is already negative\nfrom historical traffic billing cannot be drained further.\n\n**It does not check where the money came from.** If a client funded his\nown wallet, this will take it. That is a deliberate limitation, not an\noversight: NSIN does not track which Rial came from whom, and refusing on\nthat basis would need a provenance ledger that does not exist. Treat the\nendpoint as the privileged operation it is.\n\n**It does not cancel anything.** Withdrawing the money behind an active\nsubscription does not end that subscription — the plan is already paid\nfor and runs to its expiry. Cancel it with `DELETE /services/{serviceId}`\nif that is what you mean.\n\nBoth ledger rows carry `ref_type: reseller_transfer`, the same as `credit`.\nThe direction is the sign on `amount_rials`.\n","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amount_rials"],"properties":{"amount_rials":{"type":"integer","format":"int64","minimum":1,"description":"Must be positive — the direction is the endpoint, not the sign.\nA negative amount is rejected rather than quietly treated as a\ncredit, because a sign flip in a caller's code must not be able\nto turn a withdrawal into a payment.\n","examples":[2000000]},"description":{"type":"string","maxLength":191,"description":"Shown on both ledger rows."}}}}}},"responses":{"200":{"description":"Withdrawn — or, if this `Idempotency-Key` was already used for this\nexact request, the first call's response replayed unchanged.\n","headers":{"Idempotent-Replay":{"description":"Sent as `true` only on a replay: the body is the stored answer from\nthe first call with this `Idempotency-Key`, and the operation did\nNOT run a second time.\n","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TransferResult"}}}},"400":{"description":"A malformed body, a non-positive amount, or a missing `Idempotency-Key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"The client does not hold that much. Nothing moved, no ledger row was\nwritten, and no idempotency key was recorded — retry with the same key\nfor a smaller amount, or read `GET /clients/{clientId}/wallet` for the\nbalance first.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/IdempotencyConflict"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/clients/{clientId}/wallet":{"parameters":[{"$ref":"#/components/parameters/ClientId"}],"get":{"tags":["Wallet"],"operationId":"getClientWallet","summary":"A client's wallet balance and ledger","description":"Visible to the **reseller only**. The client themselves gets 403 on every\nwallet endpoint — they are never shown a balance, a price, or an invoice.\n","parameters":[{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/PerPage"}],"responses":{"200":{"description":"Balance and a page of transactions","content":{"application/json":{"schema":{"type":"object","required":["balance_rials","negative_since","transactions","meta"],"properties":{"balance_rials":{"type":"integer","format":"int64","description":"What has been transferred in, less what his plans cost.\n\n**Traffic cannot push it below zero.** A reseller's client\nhas no pay-as-you-go: his overage is recorded with\n`charged_rials = 0`, no wallet is debited, and the quota\nsweep suspends his domain at the plan's limit instead.\n\nA negative number here is therefore not a traffic bill and\nnot a normal state — nothing in the reseller flow debits a\nclient wallet it has not just funded. Read `negative_since`\nfor what happens next.\n","examples":[4500000]},"negative_since":{"type":"string","format":"date-time","nullable":true,"description":"When the balance first went below zero. A reseller's client\ngets **no grace at all** — he has no wallet he can reach and\nno way to settle — so the next negative-balance pass stops\nevery domain he owns, and **the reseller gets the SMS**, not\nthe client. Credit him back to zero (or let a purchase\nauto-fund him) and the same ladder resumes what it stopped.\n"},"transactions":{"type":"array","items":{"$ref":"#/components/schemas/WalletTransaction"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/wallet":{"get":{"tags":["Wallet"],"operationId":"getMyWallet","summary":"My own wallet balance","description":"The reseller's own wallet. Topped up at **face value** through the\nexisting Zarinpal flow in the panel — there is no OFF, no bonus, and no\ncredit limit in v1. Topping up is not part of this API.\n","responses":{"200":{"description":"Balance","content":{"application/json":{"schema":{"type":"object","required":["balance_rials"],"properties":{"balance_rials":{"type":"integer","format":"int64","examples":[48500000]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/wallet/transactions":{"get":{"tags":["Wallet"],"operationId":"listMyWalletTransactions","summary":"My own wallet ledger","description":"The **caller's own** ledger (`wallet_transactions WHERE user_id = <caller>`) — never a client's. For a client's balance and ledger use `GET /clients/{clientId}/wallet`, which returns both in one call.\n\nWhat lands here is `topup` rows from the reseller's own Zarinpal top-ups, and one `transfer_out` row per successful `POST /clients/{clientId}/credit` whose `amount_rials` is **negative**; the matching `transfer_in` row is written to the client's ledger, not this one. Newest first (`id DESC`), `per_page` defaults to 25 and is clamped to 100, and there is no date filter — page back through it and count against `meta.total`.\n","parameters":[{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/PerPage"}],"responses":{"200":{"description":"A page of transactions","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WalletTransaction"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/clients/{clientId}/domains":{"parameters":[{"$ref":"#/components/parameters/ClientId"}],"get":{"tags":["Domains"],"operationId":"listClientDomains","summary":"List a client's domains","description":"Every domain the client owns, newest first, as a bare array — no pagination, no envelope, and nothing joined on, so a client with four hundred domains returns four hundred rows and no subscription or traffic figures.\n\nThe scope is the owner: `WHERE user_id = <clientId>`, on a client `tenantGuard` has already proved is yours. Note the asymmetry with `GET /domains/{domainId}`, which additionally requires the denormalized `domains.reseller_id` to agree with the owner's `users.reseller_id` — a domain whose two columns have drifted apart still appears in this list and answers **404** when fetched directly.\n\nSoft-deleted domains are excluded, and a client with no domains gets `[]`, never `null`.\n","responses":{"200":{"description":"The client's domains","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Domain"}}}}},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Domains"],"operationId":"addClientDomain","summary":"Add a domain for a client","description":"**Only the reseller can do this.** A client with a `reseller_id` gets 403\nfrom `POST /domains/` in the panel, and the \"add domain\" button is hidden.\n\nThe reason is money: domains generate billable traffic against a wallet\nthe *reseller* funded. Letting the client add domains freely would let\nhim spend his reseller's money on decisions the reseller never approved.\n\nSets `domains.reseller_id = <caller>` to match the owner's\n`users.reseller_id`. **These two must never disagree.**\n\n### The domain arrives with NO plan\n\nA domain added through the panel gets a free 10-day Enterprise trial. One\nadded here does **not**, and you should be glad: the trial is an *active\nsubscription*, so it would make your very next call — `POST /services` —\nfail with `409 already_has_active_plan`. Provisioning would break on step\ntwo, every time.\n\nSo the normal flow is two calls, in this order:\n\n1. `POST /clients/{clientId}/domains`\n2. `POST /services` — buy the plan\n\nUntil step 2 the domain has a **zero traffic allowance**, and because a\nreseller's client has no pay-as-you-go, the quota sweep will stop it as\nsoon as it serves a byte. Buy the plan.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","examples":["example.com"]},"dns_mode":{"type":"string","enum":["managed","external"],"default":"managed"}}}}}},"responses":{"201":{"description":"Created. The domain starts in `pending` until NS delegation is confirmed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Domain already exists.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}":{"parameters":[{"$ref":"#/components/parameters/DomainId"}],"get":{"tags":["Domains"],"operationId":"getDomain","summary":"Get one domain","description":"The domain row as stored, handed straight from what `tenantGuard` already resolved — no second query, and therefore no plan, subscription or traffic data; those are `GET /services?client_id=` and the analytics endpoints. Remember that `suspended`, not `status`, is what decides whether the site is being served.\n\n`{domainId}` is **numeric on this whole surface**. The guard parses it as an integer before any handler runs, so `GET /reseller/v1/domains/example.com` is a 404 rather than a lookup — even though the delegated per-domain routes below are the customer panel's own handlers, which address a domain by name and would happily resolve one.\n\nA domain that is not in your tenancy answers **404**, deliberately indistinguishable from one that does not exist; the 403 is for a caller who is not a reseller at all.\n","responses":{"200":{"description":"The domain","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"put":{"tags":["Domains"],"operationId":"updateDomainSettings","summary":"Change a domain's settings","description":"DNS mode, developer mode, cache cap, proxy defaults — the domain's own\nconfiguration, as opposed to its subscription.\n\nServed by the same handler the customer panel calls, so a setting means\nthe same thing on both surfaces. Body shape: see the customer reference.\n\nNot the off switch. Use `POST /domains/{domainId}/disable` for that.\n","requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"tags":["Domains"],"operationId":"deleteDomain","summary":"Delete a domain","description":"Deletes the domain and its records. Any active service on it is cancelled.","responses":{"204":{"description":"Deleted"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/verify":{"parameters":[{"$ref":"#/components/parameters/DomainId"}],"post":{"tags":["Domains"],"operationId":"verifyDomain","summary":"Force an NS delegation check","description":"Normally the checker confirms delegation on its own schedule. This forces\nan immediate check, for a reseller who has just pointed the nameservers\nand does not want to wait.\n\nOnly applies to a **managed-DNS** domain in the `pending` or `moved`\nstate. Anything else — an already-active domain, or one on external DNS —\nhas nothing to check and returns **400**.\n","responses":{"200":{"description":"Check performed. Read `status` for the result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"description":"The domain is not awaiting nameserver changes, so there is nothing to\ncheck.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/disable":{"parameters":[{"$ref":"#/components/parameters/DomainId"}],"post":{"tags":["Domains"],"operationId":"disableDomain","summary":"Disable a domain (stop serving it)","description":"Stops the domain being served and pushes a reload to the edge, so it\ntakes effect within seconds. **This is what actually stops traffic — and\ntherefore stops the meter.**\n\nDNS records are left completely untouched, so enabling restores service\nimmediately with no reconfiguration. The domain is left read-only while\ndisabled: records, rules and cache settings cannot be changed, because\nediting a site that is not being served only looks like it worked.\n\nDisable/enable is the **only** on/off vocabulary a reseller has. The\nunderlying column is shared with NSIN's own billing stops (an unpaid\nwallet, an exhausted traffic allowance) — those are set by the system,\nnot by you, and `suspended_reason` on the Domain object tells them apart.\n\nWHMCS `SuspendAccount` maps here.\n","responses":{"200":{"description":"Disabled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/enable":{"parameters":[{"$ref":"#/components/parameters/DomainId"}],"post":{"tags":["Domains"],"operationId":"enableDomain","summary":"Enable a domain (resume serving it)","description":"Lifts a stop **you** made with `POST /domains/{domainId}/disable`.\n\n> ### It will not lift a stop the system made.\n>\n> If the domain was stopped because the client **exhausted his plan's\n> traffic** (`quota_exceeded`), this returns **409**. Your client is\n> never billed for overage, so resuming him here would simply hand him\n> unlimited free traffic.\n>\n> The fix is to **buy him a bigger plan**. The quota sweep then resumes\n> the domain by itself, with no further call from you.\n>\n> A stop for `negative_balance` is nearly always **your** wallet, not\n> your client's: nothing in this API drives a client's balance below\n> zero, while a reseller who stays in debt past his grace window has his\n> whole tenancy — his own domains and every client's — suspended. Top\n> your own wallet up and billing resumes, by itself, everything that\n> ladder stopped.\n\nEnabling a domain that is not disabled is a no-op, so a retry is safe.\n","responses":{"200":{"description":"Enabled (or was never disabled)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Stopped by the system, not by you. Fix the underlying cause instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/services":{"get":{"tags":["Services"],"operationId":"listServices","summary":"List services across all my clients","description":"Every subscription belonging to every one of the caller's clients — the selling surface, so each row carries `plan_term_id`, the `price_rials` the client's wallet was debited, and the `id` you hand to `change-plan` and `DELETE /services/{serviceId}`. (`GET /subscriptions` is the same rows shaped for operating: expiry-sorted, filterable by days-to-expiry, no prices.)\n\n`client_id` is intersected with your own clients rather than trusted, so naming another reseller's client returns an empty page, not a 403 — and so does a `status` value outside the enum.\n\nEach row also reports the state of the domain behind the service (`domain_name`, `domain_disabled`, `domain_disabled_reason`, `domain_deleted`), read unscoped so a soft-deleted domain still resolves to a name instead of a bare id. **An `active` service can sit on a domain that is switched off, or on one that has been deleted**; read those fields before you report a customer as healthy. Newest first, `per_page` default 25, clamped to 100.\n","parameters":[{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/PerPage"},{"name":"client_id","in":"query","schema":{"type":"integer"}},{"name":"status","in":"query","schema":{"$ref":"#/components/schemas/ServiceStatus"}}],"responses":{"200":{"description":"A page of services","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Service"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Services"],"operationId":"createService","summary":"Buy a plan for a client's domain","description":"A **service is a subscription** — the existing `subscriptions` row. There\nis no separate entity, which is why WHMCS maps 1:1.\n\n**You pay; the client's wallet is only where the money lands.** The\npurchase debits the CLIENT's wallet at NSIN list price, and whatever that\nwallet is short by is moved out of YOUR wallet into his first, inside\nthis same transaction (`ref_type: reseller_autofund` on both ledger\nrows). Top-up and purchase commit or roll back together, so a failed\npurchase can never leave you charged for a plan the client did not get.\n\nYou do **not** have to pre-fund the client: `POST /clients/{clientId}/credit`\nis optional and only makes that automatic top-up smaller, or a no-op.\n\nThere is no reseller branch anywhere in the pricing path —\na reseller's client pays exactly what a direct customer pays. NSIN never\nlearns, stores, or asks for the reseller's own retail price.\n\nPurchase is keyed on **`plan_term_id`**, not `plan_id`: a plan has several\nterms (30/90/365 days) at different prices. Get them from `GET /plans`.\n\nWHMCS `CreateAccount` maps here.\n","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["domain_id","plan_term_id"],"properties":{"domain_id":{"type":"integer","examples":[412]},"plan_term_id":{"type":"integer","description":"From `GET /plans` → `terms[].id`. Not the plan id.","examples":[9]},"auto_renew":{"type":"boolean","default":false}}}}}},"responses":{"201":{"description":"Purchased. The client's wallet was debited, having first been topped\nup from yours for whatever it was short — or, if this\n`Idempotency-Key` was already used for this exact request, the first\ncall's response replayed unchanged, with no second purchase.\n","headers":{"Idempotent-Replay":{"description":"Sent as `true` only on a replay: the body is the stored answer\nfrom the first call with this `Idempotency-Key`, and the\noperation did NOT run a second time. Absent on the call that\nactually did the work — which is how a retrying client tells\nwhich of its attempts moved the money.\n","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Service"}}}},"400":{"description":"A malformed body, or a value the handler rejects outright.\n\nAlso the answer to a **missing `Idempotency-Key`**: the generated\nwrapper rejects that before the handler is entered, so the body\ncarries the wrapper's message rather than the handler's. A key longer\nthan 191 bytes is rejected by the handler, with a message saying so.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"**Your** wallet came up short, not the client's. Funding him for this\npurchase would take you past your credit limit — which, with the\ndefault `reseller_max_negative_rials` of `0`, means past zero. The\nbody carries `\"code\": \"credit_limit_reached\"`.\n\nCrediting the client does not help: `POST /clients/{clientId}/credit`\ndraws on the same wallet and fails for the same reason. Top your own\nwallet up through the panel's Zarinpal flow, or ask NSIN to raise the\nlimit, then retry with the same `Idempotency-Key` — a failed purchase\nrecords no key.\n\nNothing was written: no transfer, no ledger row, no subscription. (A\n402 with no `code` would be the client's own balance, which the\nautomatic top-up makes unreachable on this path.)\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The domain already has an active service.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"$ref":"#/components/responses/IdempotencyConflict"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/services/{serviceId}":{"parameters":[{"$ref":"#/components/parameters/ServiceId"}],"get":{"tags":["Services"],"operationId":"getService","summary":"Get one service","description":"`serviceId` is the numeric `subscriptions.id` — not a domain id and not a plan id. It is the one path parameter the tenancy middleware does not resolve, because a service is a billing row, so the handler proves ownership itself by joining `users.reseller_id`; a service that belongs to another reseller and one that never existed are the same **404**, on purpose.\n\nThe body is the `Service` a row of `GET /services` carries, plan name, term and price included, plus `domain_disabled` / `domain_deleted` — the only place this call will tell you that the site behind a paid, `active` subscription is not actually being served.\n","responses":{"200":{"description":"The service","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Service"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"tags":["Services"],"operationId":"cancelService","summary":"Terminate a service","description":"Sets the subscription to `cancelled`, after closing the current billing\nperiod and invoicing the traffic already charged on it. No refund is\nissued for the unused remainder of the term — NSIN has no proration\nanywhere.\n\n> ### ⚠️ This is NOT the off switch.\n>\n> Cancelling leaves the domain **serving normally**, with no plan behind\n> it. It is a billing act, not a stop.\n>\n> It does **not**, however, run up a pay-as-you-go bill. A reseller's\n> client has **no PAYG at all** — traffic billing skips him entirely, and\n> the quota sweep stops a domain that has exhausted its allowance. A\n> domain with no subscription has an allowance of **zero**, so it is\n> stopped as soon as it serves a byte.\n>\n> So cancelling gives you a domain that **stops shortly**, at the next\n> sweep, with no bill. It does not give you a clean, immediate stop.\n>\n> **To stop a client immediately and unambiguously:\n> `POST /domains/{domainId}/disable`.**\n\nWHMCS `TerminateAccount` should **disable the domain first**, then call\nthis.\n\n**No `Idempotency-Key` here, deliberately.** It is the one write in the\nServices family that does not take one, because it cannot benefit from\none: cancelling is already idempotent — a subscription that is already\n`cancelled` short-circuits and answers `204` again without touching\nanything — so a retry after a dropped connection can neither cancel twice\nnor invoice twice. Requiring a key would be ceremony that buys nothing.\nSend one anyway if your client always does; it is ignored, not rejected.\n","responses":{"204":{"description":"Cancelled. The domain is still being served until the next quota sweep stops it."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/services/{serviceId}/change-plan":{"parameters":[{"$ref":"#/components/parameters/ServiceId"}],"post":{"tags":["Services"],"operationId":"changeServicePlan","summary":"Upgrade or downgrade a service","description":"The same money path as `POST /services`: the new term is debited from the\nCLIENT's wallet at list price, and the shortfall is moved out of YOUR\nwallet into his inside the same transaction. You are the one who pays,\nand you do not have to pre-fund him.\n\nThe full price of the new term is charged. NSIN has no proration\nanywhere, so nothing is refunded for the unused remainder of the term\nbeing left — a downgrade costs the price of the smaller plan, it does not\npay anything back.\n\nWHMCS `ChangePackage` maps here.\n\n> **Not in v1:** the once-per-calendar-month plan-change cap. It is\n> deferred, so a reseller can currently change a plan as often as he\n> likes. See the Deferred table in `RESELLER_PLAN_V2.md`.\n","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["plan_term_id"],"properties":{"plan_term_id":{"type":"integer"}}}}}},"responses":{"200":{"description":"Plan changed — or, if this `Idempotency-Key` was already used for this\nexact request, the first call's response replayed unchanged, with no\nsecond plan change and no second debit.\n","headers":{"Idempotent-Replay":{"description":"Sent as `true` only on a replay: the body is the stored answer\nfrom the first call with this `Idempotency-Key`, and the\noperation did NOT run a second time. Absent on the call that\nactually did the work — which is how a retrying client tells\nwhich of its attempts moved the money.\n","schema":{"type":"string","enum":["true"]}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Service"}}}},"400":{"description":"A malformed body, or a value the handler rejects outright.\n\nAlso the answer to a **missing `Idempotency-Key`**: the generated\nwrapper rejects that before the handler is entered, so the body\ncarries the wrapper's message rather than the handler's. A key longer\nthan 191 bytes is rejected by the handler, with a message saying so.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"**Your** wallet came up short, not the client's: funding the new term\nwould take you past your credit limit (by default, past zero). The\nbody carries `\"code\": \"credit_limit_reached\"`. Top your own wallet up,\nor ask NSIN to raise the limit, then retry with the same\n`Idempotency-Key`. Nothing was written — the old plan is untouched and\nthere is no partial ledger row.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/IdempotencyConflict"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/plans":{"get":{"tags":["Catalog"],"operationId":"listPlans","summary":"The plan catalog, with list prices","description":"The **reseller** sees list prices here — he needs them to know what he is\nbeing charged, and to set his own retail price on his own website.\n\nHis **clients** never see a price: the panel's plan endpoints strip every\nprice field for a user with a `reseller_id`, and show plan name and\nfeatures only.\n","responses":{"200":{"description":"Active plans","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Plan"}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/usage":{"get":{"tags":["Usage"],"operationId":"getUsage","summary":"Traffic and request usage","description":"Aggregate, or per client via `client_id`.\n\n**This endpoint must stay bulk.** WHMCS calls `UsageUpdate` daily for\n*every* service; a per-service round trip does not scale and will time\nout on a reseller with a few hundred clients.\n","parameters":[{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"to","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"client_id","in":"query","schema":{"type":"integer"}}],"responses":{"200":{"description":"Usage rows","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UsageRow"}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/tokens":{"get":{"tags":["Tokens"],"operationId":"listApiTokens","summary":"List my API tokens","description":"**Session (JWT) auth only.** An API token cannot list, mint, or revoke\ntokens — a leaked token must not be able to mint a second, quieter one.\n","responses":{"200":{"description":"Tokens. The secret is never included.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiToken"}}}}},"403":{"description":"Called with an API token instead of a session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Tokens"],"operationId":"createApiToken","summary":"Mint an API token","description":"**Session (JWT) auth only.**\n\nThe full secret is returned **exactly once**, in this response, and is\nnever recoverable. Only a bcrypt hash of it is stored. It must not appear\nin any other response body, and it must not appear in any log line.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","examples":["WHMCS production"]},"expires_at":{"type":"string","format":"date-time","nullable":true}}}}}},"responses":{"201":{"description":"Minted. Store `secret` now — you will not see it again.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiToken"},{"type":"object","required":["secret"],"properties":{"secret":{"type":"string","description":"Shown once. Never again.","examples":["nsin_live_7f3a9c_9d2b41e8a7c04f16b5e3d8a91c6f2e70"]}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/tokens/{tokenId}":{"parameters":[{"name":"tokenId","in":"path","required":true,"schema":{"type":"integer"}}],"delete":{"tags":["Tokens"],"operationId":"revokeApiToken","summary":"Revoke an API token","description":"**Session (JWT) auth only.** Takes effect immediately; the next call with it gets 401.","responses":{"204":{"description":"Revoked"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/activity":{"get":{"tags":["Diagnostics"],"operationId":"listApiActivity","summary":"List my own API calls","description":"Every request **you** have made to this API, newest first, with the status\nand error it was answered with.\n\nThis is your integration's log, not your clients' traffic — for the requests\nthe edge served for their domains, see `/logs`. The two are unrelated, and\nthis is the one to read when the *API itself* is not behaving.\n\nFailed authentication is recorded too, whenever the credential named a\n`key_id` that exists. That is deliberate and is the main reason this\nendpoint exists: a rejected token is the most common thing to get stuck on,\nand it is invisible everywhere else.\n\nEach row carries the `request_id` that was returned in the `X-Request-Id`\nresponse header of the call it describes. Quote it in a support ticket and\nit identifies the exact request.\n\nRows are kept for `RESELLER_ACTIVITY_RETENTION_DAYS` (30 by default).\n","parameters":[{"name":"page","in":"query","schema":{"type":"integer","minimum":1}},{"name":"per_page","in":"query","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"failed_only","in":"query","description":"Only calls answered 4xx or 5xx. The usual starting point.","schema":{"type":"boolean"}},{"name":"status","in":"query","description":"Exact HTTP status, e.g. `401`.","schema":{"type":"integer"}},{"name":"method","in":"query","schema":{"type":"string","enum":["GET","POST","PUT","PATCH","DELETE"]}},{"name":"token_id","in":"query","description":"Only calls made with one credential — which of your integrations is misbehaving.","schema":{"type":"integer"}},{"name":"request_id","in":"query","description":"The exact call, by the id from its X-Request-Id header.","schema":{"type":"string"}},{"name":"since_hours","in":"query","description":"Only calls from the last N hours.","schema":{"type":"integer","minimum":1,"maximum":8760}}],"responses":{"200":{"description":"Your API calls, newest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiActivityPage"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/diagnostics":{"get":{"tags":["Diagnostics"],"operationId":"getDiagnostics","summary":"Check what my credential actually reaches","description":"One authenticated round trip that reports what arrived: which credential,\nwhich reseller it resolved to, and what budget it is spending.\n\nPoint a new integration here first. Reaching it at all proves four separate\nthings that are otherwise diagnosed one painful step at a time — that the\nbase URL is right, that TLS completed, that the credential parsed, and that\nit maps to the tenancy you expected.\n\n`base_url` is the origin this API is served from. If the host you called is\nnot the one it names, that difference is the bug: the marketing site answers\n`/api/...` with a redirect and then an HTML 404, which reaches a JSON client\nas a parse error rather than as anything about the URL.\n","responses":{"200":{"description":"What the server sees.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Diagnostics"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/support-contact":{"get":{"tags":["Support"],"operationId":"getSupportContact","summary":"Get the support contact my clients see","description":"Your own block, read from `users.support_contact` on the caller's row — not a client's, and there is no way to read another reseller's. It is what your clients receive on `GET /users/me` as `support_contact`, rendered by the panel in place of the ticket UI they do not have.\n\nThis never 404s: a contact that was never configured comes back as `{}`, since every field is omitted when empty. That empty case is worth catching before you provision your first client — an empty block is dropped from the client's `/users/me` entirely, which leaves him with a Support section naming nobody to contact.\n","responses":{"200":{"description":"The contact block","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SupportContact"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}},"put":{"tags":["Support"],"operationId":"setSupportContact","summary":"Set the support contact my clients see","description":"A reseller's clients have **no tickets** — the ticket endpoints return 403\nfor them. This block is rendered in the panel's Support section in place\nof the ticket UI, so the client contacts **his reseller**, not NSIN.\n\nStored as JSON in `users.support_contact`.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SupportContact"}}}},"responses":{"200":{"description":"Saved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SupportContact"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains":{"get":{"tags":["Domains"],"operationId":"listDomains","summary":"List every domain across all my clients","description":"The fleet view. Note that the customer API's `GET /domains` deliberately\ndoes NOT include the domains you provide for your clients — it would bury\nyour own domains under hundreds of theirs. This is where they live.\n","parameters":[{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/PerPage"},{"name":"client_id","in":"query","description":"Narrow to one client. Still intersected with your tenancy.","schema":{"type":"integer"}},{"name":"status","in":"query","schema":{"type":"string","enum":["pending","active","moved","failed","disabled"]}},{"name":"q","in":"query","description":"Substring match on the domain name.","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of domains","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Domain"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/certs":{"get":{"tags":["SSL"],"operationId":"listCerts","summary":"List TLS certificates across all my clients","description":"Sorted by expiry ascending: the only question this page is opened to\nanswer is \"what is about to break\", and that belongs at the top.\n\nCertificate PRIVATE KEYS are never returned here, on any plan.\n","parameters":[{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/PerPage"},{"name":"status","in":"query","schema":{"type":"string","enum":["pending","active","failed"]}},{"name":"expiring_days","in":"query","description":"Only certificates expiring within this many days.","schema":{"type":"integer","minimum":1,"maximum":3650}}],"responses":{"200":{"description":"A page of certificates","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Cert"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/subscriptions":{"get":{"tags":["Services"],"operationId":"listSubscriptions","summary":"List domain subscriptions across all my clients","description":"The OPERATING view of the same rows `GET /services` sells: what is live,\nwhat expires when, and whose it is. Kept separate rather than overloading\none list with two sets of filters.\n\nPrices are absent by design — what you charge your customer is not\nsomething NSIN stores, and your wholesale cost belongs on your wallet\nrather than on every row of an operations table.\n","parameters":[{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/PerPage"},{"name":"client_id","in":"query","schema":{"type":"integer"}},{"name":"status","in":"query","schema":{"$ref":"#/components/schemas/ServiceStatus"}},{"name":"expiring_days","in":"query","description":"Only subscriptions expiring within this many days.","schema":{"type":"integer","minimum":1,"maximum":3650}}],"responses":{"200":{"description":"A page of subscriptions","content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DomainSubscription"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/analytics/overview":{"get":{"tags":["Analytics"],"operationId":"getAnalyticsOverview","summary":"Traffic across my whole tenancy","description":"Per-domain traffic for every domain across every client, plus the\nroll-up over all of them.\n\nBoth halves come back in one response on purpose: the dashboard shows\nthe totals as headline figures and the rows as the table underneath, and\ntwo endpoints would guarantee the two disagree whenever a request lands\nbetween the calls.\n\n`totals.unique_visitors` is **not** the sum of the per-domain figures.\nDistinct visitor counts do not add — one person browsing three of your\nsites is one visitor — so it is computed over the whole tenancy.\n","parameters":[{"$ref":"#/components/parameters/Period"}],"responses":{"200":{"description":"Per-domain rows and the tenancy roll-up","content":{"application/json":{"schema":{"type":"object","required":["data","totals"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DomainTraffic"}},"totals":{"$ref":"#/components/schemas/TenancyTraffic"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/analytics/requests-compare":{"get":{"tags":["Analytics"],"operationId":"getAnalyticsRequestsCompare","summary":"Request counts bucketed for day-over-day comparison","description":"Request counts in whole-day-aligned buckets, for answering \"is today\nworse than yesterday\" — the question `/analytics/overview` cannot answer\nbecause its window is a rolling look-back, so today and yesterday are\nnever the same shape.\n\n`granularity=hour` returns hourly buckets across the last `days` days,\nfor overlaying one line per day. `granularity=day` returns one bucket\nper day. The window always starts at a **local midnight** so days are\ncompared whole-day to whole-day; the newest day is partial by nature and\nthe caller is expected to say so.\n\nScope is the caller's whole tenancy, optionally narrowed to one domain\nwith `domain_id`. The narrowing is an intersection: naming a domain that\nis not yours yields an empty series, never someone else's traffic. There\nis deliberately no \"every request the edge saw\" scope — that exists on\nthe admin surface and includes hostnames belonging to nobody in this\ntenancy.\n","parameters":[{"name":"granularity","in":"query","description":"Bucket size. Anything but `day` is treated as `hour`.","schema":{"type":"string","enum":["hour","day"],"default":"hour"}},{"name":"days","in":"query","description":"How many days back the window reaches, today included. Clamped to\n1..14 for hourly buckets (more lines than that is unreadable) and\n1..35 for daily — 35 is the ClickHouse row TTL, so nothing older\nexists to return.\n","schema":{"type":"integer","minimum":1,"maximum":35}},{"name":"domain_id","in":"query","description":"Narrow to one domain. Still intersected with your tenancy, so naming\nanother reseller's domain returns an empty series rather than his\ntraffic.\n","schema":{"type":"integer"}}],"responses":{"200":{"description":"One bucket per hour or per day, oldest first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestSeries"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/analytics":{"get":{"tags":["Analytics"],"operationId":"getDomainAnalytics","summary":"Traffic summary for one domain","description":"Volume, error rate and response-time percentiles for a single domain.\nServed by the same handler the customer panel calls, so the two surfaces\ncannot report different numbers for the same domain.\n\nLatency figures exclude WebSocket requests: a WS `duration` spans the\nwhole upgraded connection, so long-lived sockets would swamp the average\nand make it meaningless as a measure of how fast the site responds.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/Period"}],"responses":{"200":{"description":"The summary","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DomainAnalyticsSummary"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/analytics/requests":{"get":{"tags":["Analytics"],"operationId":"getDomainAnalyticsRequests","summary":"Requests over time for one domain","description":"One bucket per hour (up to 24h) or per day (beyond), oldest first.\nBuckets with no traffic are absent rather than zero — the series is what\nthe edge logged, not a padded calendar.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/Period"}],"responses":{"200":{"description":"The request series","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestSeries"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/analytics/bandwidth":{"get":{"tags":["Analytics"],"operationId":"getDomainAnalyticsBandwidth","summary":"Bandwidth over time for one domain","description":"Bytes in and out per bucket, on the same time ladder as\n`/analytics/requests`. Outbound is what was served to visitors — the\nfigure the traffic bill is computed from.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/Period"}],"responses":{"200":{"description":"The bandwidth series","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BandwidthSeries"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/analytics/status-codes":{"get":{"tags":["Analytics"],"operationId":"getDomainAnalyticsStatusCodes","summary":"Response status breakdown for one domain","description":"Every status the domain returned in the period, with counts, busiest\nfirst. This is the detail behind the summary's single `error_rate`: a\n5% error rate that is all 404s and a 5% error rate that is all 502s are\nthe same number and completely different problems.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/Period"}],"responses":{"200":{"description":"The status breakdown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusCodeBreakdown"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/analytics/cache":{"get":{"tags":["Analytics"],"operationId":"getDomainAnalyticsCache","summary":"Cache hit/miss/bypass for one domain","description":"How much of the traffic the edge served without touching the origin.\n\nRequests with no cache status at all are excluded, so a domain that is\nentirely proxied reports zeros across the board rather than a\n100%-bypass rate it never measured.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/Period"}],"responses":{"200":{"description":"The cache breakdown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CacheBreakdown"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/analytics/markdown-tester":{"get":{"tags":["Analytics"],"operationId":"testDomainMarkdown","summary":"Preview the Markdown-for-Agents conversion for one domain","description":"Fetches one page on the domain twice — once as a browser\n(`Accept: text/html`) and once as an agent (`Accept: text/markdown`) —\nthrough the public edge, and returns both responses so the panel can\nshow a before/after diff and the token saving.\n\nThis is the reseller's answer to \"will turning this on break my\ncustomer's site\". Without it the only way to find out is to enable\n`markdown_for_agents` on a live domain and look, which is a change to\nsomeone else's production site made in order to ask a question.\n\nThere is no plan gate, matching the customer surface: previewing the\nconversion must be possible *before* the feature is bought.\n\nThe fetch target is pinned to the authorized domain — `hostname` must be\nthe domain itself or one of its subdomains, `path` must be a path and not\na URL, redirects may not leave the domain, and bodies are capped at\n256 KiB. That containment is what keeps a preview endpoint from being an\nSSRF hole pointed at our own network.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"name":"hostname","in":"query","description":"Which host to fetch. Defaults to the domain apex. Must be the domain\nor one of its subdomains; anything else is a 400 rather than a fetch.\n","schema":{"type":"string"}},{"name":"path","in":"query","description":"Path to fetch, starting with `/`. A full URL is rejected.","schema":{"type":"string","default":"/"}}],"responses":{"200":{"description":"Both fetches, side by side","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarkdownTesterResult"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/logs":{"get":{"tags":["Logs"],"operationId":"listLogs","summary":"Raw request logs across all my clients","description":"Newest first. Every row is one request served at the edge, with the\nend-user's IP, user agent, headers, cache status, WAF score and origin\nresponse — the same rows the domain's owner sees.\n\nBeyond the parameters listed here the customer API's full filter set is\naccepted verbatim (`status`, `method`, `uri`, `cache`, `reqStatus`,\n`rayId`, `hostname`, `country`, `node`, `threat`, `botKind`,\n`wafRuleId`, ...); see the `/analytics/logs` entry in the customer\nreference. They are not repeated here because a second copy of a\nthirty-filter list is a second thing to keep in step.\n","parameters":[{"$ref":"#/components/parameters/Period"},{"$ref":"#/components/parameters/LogLimit"},{"$ref":"#/components/parameters/LogOffset"},{"name":"domain_id","in":"query","description":"Narrow to one domain. Still intersected with your tenancy, so naming\nanother reseller's domain returns an empty page rather than his logs.\n","schema":{"type":"integer"}}],"responses":{"200":{"description":"A page of request logs","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogPage"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/logs":{"get":{"tags":["Logs"],"operationId":"listDomainLogs","summary":"Raw request logs for one domain","description":"`GET /logs` narrowed to a single domain by path rather than by query, so\na 404 tells an integrator he asked for a domain that is not his instead\nof an empty page that looks like a quiet site.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/Period"},{"$ref":"#/components/parameters/LogLimit"},{"$ref":"#/components/parameters/LogOffset"}],"responses":{"200":{"description":"A page of request logs","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogPage"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/waf-logs":{"get":{"tags":["Logs"],"operationId":"listWafLogs","summary":"WAF audit events across all my clients","description":"Every request the WAF scored, blocked or would have blocked, newest\nfirst, with the matched rule ids, messages and the offending data.\n\nSeparate from `/logs` because it answers a different question: `/logs`\nis \"what happened\", this is \"what the firewall thought about it\", and\nthe row carries the per-rule detail that has nowhere to live in a\nrequest row.\n\nAdditional filters are accepted verbatim from the customer API\n(`hostname`, `action`, `ruleId`, `clientIp`, `country`, `rayId`,\n`recordId`, `blocked`).\n","parameters":[{"$ref":"#/components/parameters/Period"},{"$ref":"#/components/parameters/LogLimit"},{"$ref":"#/components/parameters/LogOffset"},{"name":"domain_id","in":"query","description":"Narrow to one domain. Still intersected with your tenancy.","schema":{"type":"integer"}}],"responses":{"200":{"description":"A page of WAF events","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WafLogPage"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/waf-logs":{"get":{"tags":["Logs"],"operationId":"listDomainWafLogs","summary":"WAF audit events for one domain","description":"The same WAF rows `GET /waf-logs` returns, pinned to one domain by the path.\n\nThe scope is a single equality on the `domain_id` the guard resolved — a `domain_id` query parameter is not read on this route and cannot widen it — and `domain_id` covers **every hostname under the domain**, subdomains included, so narrow with `hostname` (substring match) or `recordId` (one proxied record) rather than expecting apex-only rows.\n\n`limit` defaults to 100 and **anything outside 1..500 falls back to 100, not to the nearest bound**, so `limit=1000` quietly returns a hundred rows; a negative `offset` becomes 0. `total` is the match count before paging, which is what you page through. `period` defaults to `24h` and an unrecognised value is treated as `24h` rather than rejected. Event timestamps are RFC 3339 UTC with a literal `Z`.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/Period"},{"$ref":"#/components/parameters/LogLimit"},{"$ref":"#/components/parameters/LogOffset"}],"responses":{"200":{"description":"A page of WAF events","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WafLogPage"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/records":{"get":{"tags":["Records"],"operationId":"listDomainRecords","summary":"List a domain's DNS records","description":"The customer panel's `listRecords` (`GET /domains/{domain}/records/`), reached by numeric domain id — the identical Go function, so the row shape is defined in the customer reference and deliberately not restated here.\n\nEvery record in one array, newest first; there is no pagination. It needs only `domain.view`, so it keeps answering on a **disabled** domain: reads stay open on purpose, because hiding a stopped customer's records would make a billing stop look like data loss.\n\nWatch `origin_rules` on proxied rows. When an origin route or an origin pool overrides a record, traffic does not go to the `destination` the row shows, and routes are listed before pools because that is the order the edge applies them.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Records"],"operationId":"createDomainRecord","summary":"Create a DNS record","description":"The customer panel's `createRecord`, reached by numeric domain id — same validation, same plan limits, same PowerDNS publish — so the body is the customer reference's `RecordCreate` and is not repeated here.\n\n**A domain with no active plan answers 402.** That is the trap in a provisioning script: a domain added through `POST /clients/{clientId}/domains` deliberately arrives without a plan, so records have to wait until `POST /services` has bought one. A full record quota is 403, a name and type that already exist is 400 `record exists` (not 409), and success is **200** with the created record, not 201.\n\nThere is no `ttl` field: every record created here is written with a 120-second TTL, and `PUT` cannot change it either — only the import endpoints carry a TTL. If the zone push to PowerDNS fails the new row is rolled back and you get 502, so a failed create leaves nothing behind; a retry of a create that actually succeeded, on the other hand, comes back as `record exists` rather than repeating.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/records/{recordId}":{"put":{"tags":["Records"],"operationId":"updateDomainRecord","summary":"Update a DNS record","description":"The customer panel's `updateRecord`. A partial update — omitted fields keep their current value — so a client that reads the whole record and posts it back also re-posts every field it did not mean to touch. Body shape: the customer reference's `RecordUpdate`.\n\n`{recordId}` is resolved together with `{domainId}` in one query, so a record id belonging to another domain is a 404 rather than a cross-domain edit. There is no `ttl` field here; TTL is fixed when the record is created and only the import endpoints set it.\n\nFlipping `proxied` is not a metadata change: it rewrites what the zone publishes (the proxy IP replaces the destination) and pulls the hostname into the certificate coverage check. Repeating the same PUT is safe.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/RecordId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"tags":["Records"],"operationId":"deleteDomainRecord","summary":"Delete a DNS record","description":"The customer panel's `deleteRecord`. It answers **200 with a message body, not 204**, and the delete is unscoped — the row is genuinely gone rather than tombstoned, so a second call answers 404 `record not found` and re-creating the name later gives a new record, not a restore.\n\nRemoval from PowerDNS is best effort: if that push fails it is logged and you still get 200. Treat the call as accepted rather than confirmed in DNS, and read the record list back when it matters.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/RecordId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/records/scan":{"get":{"tags":["Records"],"operationId":"scanDomainRecords","summary":"Discover a domain's existing records at its current DNS host","description":"Probes the domain's live DNS and returns what it finds, without writing\nanything. This is the migration tool: it is what turns \"point your NS at\nus\" from a data-entry exercise into one click.\n\n**This is the slowest operation in the API, by a wide margin.** It makes\nroughly 1,450 live DNS queries — 290 candidate names against five record\ntypes — and how long that takes depends entirely on how fast the domain's\ncurrent nameservers answer. Budget **seconds, not milliseconds**, and give\nthis one call a client timeout far longer than you use for everything else\nhere. A 5-second timeout will fail against a slow DNS host every time,\nwhile every other endpoint in this document answers in well under 100 ms\nof server time.\n\nThe scan is bounded server-side (`DNS_SCAN_BUDGET_SEC`, 20s by default).\nIf the nameservers are slow enough that the budget runs out, you get back\n**whatever was discovered by then** rather than an error — a partial list\nis useful, and the alternative was a request that ran for two and a half\nminutes into a connection the proxy had already closed.\n\nNothing is written either way, so re-running it is free and is the right\nresponse to a result that looks short.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/records/scan-import":{"post":{"tags":["Records"],"operationId":"scanImportDomainRecords","summary":"Scan the domain's current DNS host and import what is found","description":"Scans the domain's DNS **at the nameservers it is delegated to right now** and imports what it finds in one shot, with no review step; `GET .../records/scan` is the same scan without the writing.\n\nWhat it scans is worth knowing before you trust the result. It tries an AXFR zone transfer against each authoritative nameserver first, and if none allows one — almost none do — it falls back to guessing: the apex plus roughly two hundred common labels (`www`, `mail`, `_dmarc`, `selector1._domainkey` and so on) queried for A, AAAA, CNAME, MX and TXT. So it finds the names it can guess, not everything in the zone, and a customer with unusual hostnames will arrive short of records. A wildcard zone is detected and collapsed into one `*` record instead of hundreds of synthesized ones.\n\nNS records are never imported, existing records are **skipped rather than overwritten** — that is the difference from `POST .../records/import` — and everything lands unproxied, so nothing moves to the edge until you flip `proxied`. The plan's record limit is checked first, so a domain with no plan answers 402. The handler reads nothing from the request body; send `{}`. You get back `created`, a per-record `failed` list, and the domain's full record list afterwards.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/records/import":{"post":{"tags":["Records"],"operationId":"importDomainRecords","summary":"Import records from a zone file","description":"This does not take a zone file. It takes a JSON `{\"records\": [...]}` list — normally exactly what `POST .../records/import/parse` handed back after you uploaded the file there. An empty list is 400.\n\nAn entry whose name and type already exist **overwrites** it: destination, content, TTL and MX priority are refreshed while the existing `proxied` flag is deliberately preserved, so an import can never silently take a live record off the edge. TXT is the exception and is always additive, which means re-running the same import piles up duplicate TXT rows even though every other type simply turns from a create into an overwrite.\n\nEach item is processed independently — read `created`, `overwritten` and `failed[]` rather than the status code — and the plan's record limit is checked before anything is written, so a domain with no active plan answers 402.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/records/import/parse":{"post":{"tags":["Records"],"operationId":"parseDomainRecordImport","summary":"Preview a zone file without importing it","description":"Parses and validates, writes nothing. Call this first and show the user\nwhat is about to change — an import that silently replaces a live MX\nrecord is an outage the reseller gets blamed for.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/records/batch-update":{"post":{"tags":["Records"],"operationId":"batchUpdateDomainRecords","summary":"Update many DNS records in one request","description":"One request instead of one per record.\n\nThis is not only a convenience. A per-record fan-out trips the edge rate\nlimiter on any zone with a real number of records, and an integration\nmigrating a customer in bulk hits that far harder than a human editing\nrows in a panel does — so the loop that looks obvious is the one that\nfails in production.\n\nPartial success is reported per record rather than rolled back: a zone\nwhere 48 of 50 updates applied is a state the caller needs to see\ndescribed, not undone.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The domain is disabled, so its configuration cannot be changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/records/batch-delete":{"post":{"tags":["Records"],"operationId":"batchDeleteDomainRecords","summary":"Delete many DNS records in one request","description":"POST rather than DELETE: the record ids travel in a body, and a DELETE\nwith a body is not reliably forwarded by every proxy between a partner\nand this API.\n\nSame rate-limit reasoning as batch-update, and the same per-record\nresult reporting.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The domain is disabled, so its configuration cannot be changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/ssl":{"get":{"tags":["SSL"],"operationId":"getDomainSsl","summary":"Certificate status for a domain","description":"The customer panel's `getSslInfo`, so the body is defined in the customer reference: issuer, subject, SANs, key size, `status` (`none`, `pending`, `failed`, `expired`, `valid`), `days_remaining`, and `issued_at` / `expires_at` as RFC 3339.\n\nA read needs only `domain.view`, so it answers on a disabled domain, and the private key is never in the response — `has_private_key` is a boolean and that is the whole of it.\n\nThe field worth automating on is `coverage`: proxied hostnames not yet on the certificate, with failure count and next retry. A `*.example.com` wildcard matches exactly one label, so `a.b.example.com` needs a name of its own and shows up here until it has one. On an external-DNS domain `coverage` is not computed at all and `can_manual_issue` is false with a reason — an empty `coverage` there means \"not measured\", not \"fully covered\".\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["SSL"],"operationId":"uploadDomainSsl","summary":"Upload a customer-supplied certificate","description":"Requires the domain's plan to include custom SSL. The private key is\nstored for the edge to fetch and is never returned by any endpoint in\nthis document.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/ssl/parse":{"post":{"tags":["SSL"],"operationId":"parseDomainSsl","summary":"Inspect a certificate PEM before uploading it","description":"Returns the subject, SANs and validity window so the caller can show\nwhat he is about to install. Stores nothing.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/ssl/issue":{"post":{"tags":["SSL"],"operationId":"issueDomainSsl","summary":"Force a certificate issuance now","description":"Certificates are issued and renewed automatically. This is the escape\nhatch for the case that matters to support: the customer just fixed his\ndelegation and does not want to wait for the next sweep.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/cache":{"delete":{"tags":["Cache"],"operationId":"purgeDomainCache","summary":"Purge every cached entry for a domain","description":"The blunt instrument, and the one a support call actually asks for: \"I\ndeployed and it still shows the old page\".\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/cache/keys":{"get":{"tags":["Cache"],"operationId":"listDomainCacheKeys","summary":"Browse a domain's cached entries","description":"The customer panel's `listCacheKeys`: a page of the ClickHouse `cache_keys` registry — the index of what the edge wrote to L2, collapsed to each key's latest state and filtered to entries that are neither tombstoned nor expired. It is an index, not the cache; Kvrocks stays authoritative for what a purge actually removes.\n\nEvery query parameter the customer endpoint takes passes straight through (`limit`, `offset`, `sort`, `dir`, `hostname`, `host`, `node`, `path`). `limit` defaults to 100 and anything outside 1..500 falls back to 100 rather than to the bound.\n\nRead the two host/path pairs carefully: `host` and `path` are the human-readable request URL, while `hostname`, `store_path`, `key_hash` and `node` are the stored identity that `POST /domains/{domainId}/cache/keys/purge` must have echoed back verbatim. `size` is bytes.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/cache/keys/summary":{"get":{"tags":["Cache"],"operationId":"getDomainCacheSummary","summary":"Cached-entry totals and per-node breakdown","description":"The customer panel's `getCacheKeysSummary`: entry count and `size_bytes` for the domain, broken down by the edge node that cached each object. `by_node` is a PoP, not a Kvrocks storage node, and the grand totals are summed from those rows so the two can never disagree.\n\n**It ignores the listing's filters.** The handler builds its registry filter from the domain id alone, so `hostname`, `host`, `node` and `path` change nothing here and a filtered browse cannot be totalled with this call — the customer reference claims otherwise and is wrong about it.\n\nSame registry as `GET /cache/keys`, and therefore the same 503 when that registry is not wired.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/cache/keys/purge":{"post":{"tags":["Cache"],"operationId":"purgeDomainCacheKeys","summary":"Purge or refresh specific cached entries","description":"Targeted invalidation by exact row or wildcard filter — the surgical\nversion of `DELETE /cache`, for when dropping a whole site's cache to\nfix one stale asset would cost a traffic spike nobody budgeted for.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/AnalyticsUnavailable"}}}},"/reseller/v1/domains/{domainId}/members":{"get":{"tags":["Members"],"operationId":"listDomainMembers","summary":"List who has access to a domain","description":"Your client cannot manage his own domain's members — a managed owner\nmust not sub-grant access to a domain his provider is funding — so you\nare the party who does it for him. His agency or developer is added\nhere.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/members/{userId}":{"patch":{"tags":["Members"],"operationId":"updateDomainMember","summary":"Change a member's role","description":"Partial patch of one membership row — the customer API's `MemberUpdate` body: a `role`, and the three notification switches `notify_domain`, `notify_uptime`, `notify_ssl`. A body carrying none of them is 400. Only `admin`, `editor` and `viewer` may be granted: `viewer` reads the domain and its analytics, `editor` adds records, rules and cache, and `admin` adds settings, SSL **and** `members.manage` — so an admin member can invite and remove members himself. That last one is the power your client was denied; granting it hands it to a third party instead.\n\nNeither the owner nor you can be re-roled here. The owner's access comes from `domains.user_id` rather than a membership row (400), and your own `provider` row is a projection of `users.reseller_id` that the reconciler would restore, so changing its role is 409.\n\nSubscriptions start off for everyone who accepts an invite, which makes this the only way to switch a client's agency on to domain, uptime or SSL alerts. Switching your own on changes nothing you would notice: the owner's copy of those notifications is already redirected to you, and the fan-out collapses by resolved recipient. The response is the bare membership row, not the richer entry the member list returns.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/MemberUserId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"tags":["Members"],"operationId":"removeDomainMember","summary":"Remove a member's access","description":"Deletes the membership row and nothing else — the person keeps his NSIN account and whatever access he holds on other domains.\n\nTwo rows are unreachable. The owner cannot be removed (400), because his access is `domains.user_id` and not a membership; and neither can your own `provider` row — the delete is scoped `role <> provider`, so it comes back 404 \"member not found\". Detaching a client from you is an admin action (`PUT /admin/users/:id/reseller`), not a member removal.\n\nNot idempotent: a second call is 404, because nothing was deleted. Unlike records, rules, SSL and settings, this works on a disabled domain — managing members is not a configuration write, so nothing in the members and invites family answers 409.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/MemberUserId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/invites":{"get":{"tags":["Members"],"operationId":"listDomainInvites","summary":"List outstanding invitations for a domain","description":"Pending email invitations only, newest first, under an `invites` key — no pagination and no filter, so the entire pending set arrives in one response. Revoked, expired and consumed rows are excluded by the query, and an invite whose address already belongs to the owner or to a member is filtered out on top of that. This is therefore never a history of who was invited: accepting an invite makes it vanish from here and appear in the member list instead.\n\nEach entry carries the accept `link`, which is what you hand over when the invitation email does not arrive. It stays bound to the invited address, so forwarding it grants nobody anything.\n\nInvitations are created by `POST /reseller/v1/domains/{domainId}/invites` and live seven days by default (`DOMAIN_INVITE_TTL_HOURS` moves that; a per-invite `expires_in_hours` is silently clamped to 30 days).\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Members"],"operationId":"createDomainInvite","summary":"Invite someone to a domain","description":"Adding a member is not \"insert a row\": the person may not have an NSIN\naccount yet, and this flow is what creates one. A member list you can\nread and prune but not add to is not member management.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/invites/{inviteId}":{"delete":{"tags":["Members"],"operationId":"revokeDomainInvite","summary":"Revoke an invitation","description":"Stamps `revoked_at` on the invitation, which kills its token immediately and drops it out of the pending list. Idempotent — revoking twice answers 200 both times.\n\nThe trap is an invitation that was already accepted. The row is looked up by id regardless of its state, so you get the same cheerful `{\"message\": \"revoked\"}` while the membership it created is untouched and the person still has full access. Taking access away is `DELETE /reseller/v1/domains/{domainId}/members/{userId}`.\n\n`inviteId` is numeric and must belong to the domain in the path; anything else is 404.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/InviteId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/invites/{inviteId}/resend":{"post":{"tags":["Members"],"operationId":"resendDomainInvite","summary":"Resend an invitation","description":"Sends the invitation email again — email only, never SMS, and always to the invited address. It is one of the few messages about a client's domain that does **not** reach you: the invite mail bypasses the notification choke point where the reseller redirect lives, so the third party you are inviting receives it directly.\n\nThis is not a re-send of the same link. The token is rotated and the expiry reset to the default TTL (seven days unless `DOMAIN_INVITE_TTL_HOURS` says otherwise, discarding whatever `expires_in_hours` the invite was created with), and both are saved *before* the mail is attempted. An SMTP failure therefore answers 500 with the previously mailed link already dead — recover by listing the invites again and handing over the fresh `link`, not by assuming nothing happened.\n\nOnly a live invitation can be resent: revoked, expired or already accepted is 400.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/InviteId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/rules/{ruleType}":{"get":{"tags":["Rules"],"operationId":"listDomainRules","summary":"List a domain's rules of one type","description":"Every rule of one type on the domain, in evaluation order — `priority` ascending, ties broken by id, so rules left at the default priority evaluate in creation order. The response is a bare JSON array rather than an object, unpaginated, and `[]` when there is nothing.\n\n`ruleType` is one of `bot-route`, `cache`, `captcha`, `drop`, `error-page`, `fingerprint`, `origin_pool`, `origin_route`, `rate-limit`, `redirect`, `rewrite`, `waf`. Two of them are spelled with an underscore because these are the URL spellings the customer API has always used. Anything else is 404 `unknown_rule_type`, never an empty array.\n\nListing needs only `domain.view`, so it keeps working on a disabled domain. Every write in this family — create, update, delete, toggle, reorder — needs `rules.edit` and answers 409 there instead.\n\n`error-page` is the one type whose list is lossy: it reports which status codes each rule has a page for, but not the HTML, which would otherwise turn a rules listing into a multi-megabyte response. Fetch a single rule for the bodies.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/RuleType"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Rules"],"operationId":"createDomainRule","summary":"Create a rule","description":"The body is the per-type one. This route runs the customer API's own create handler for the type, so the `POST /domains/{domain}/rules/<type>/` family in `openapi.yaml` is the reference for the shape. Every type shares `enabled`, `priority`, `record_id`, `host_pattern` with `host_match_type`, and `action_mode` — `enforce` or `dry_run`, where a dry-run rule matches and logs but lets the request through to the origin unchanged.\n\n`priority` defaults to 100, and 0 is read as \"absent\" rather than as the highest priority: a rule created with `\"priority\": 0` lands at 100 alongside everything else. Order it afterwards with `/reorder`.\n\nThe plan is checked before anything is validated. A domain with no active plan is 402, and a plan that does not include rules — or whose rule quota for **this type** is already full, the quota being per type rather than per domain — is 403.\n\nAnswers 200 with the created rule, not 201, and pings every active edge node to re-sync asynchronously; the 200 means the rule is stored, not that every node has already reloaded it.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/RuleType"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/rules/{ruleType}/reorder":{"patch":{"tags":["Rules"],"operationId":"reorderDomainRules","summary":"Re-prioritise a domain's rules of one type","description":"Rules of a type are evaluated in priority order, so reordering is a\nbehaviour change, not a display preference.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/RuleType"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/rules/{ruleType}/{ruleId}":{"get":{"tags":["Rules"],"operationId":"getDomainRule","summary":"Get one rule","description":"One rule, in the same shape the list returns for that type.\n\nThe lookup is scoped to the domain **and** the `ruleType` in the path. Rule ids are unique across all twelve types because they share one `rules` table, so an id taken from one type's list and fetched under another type's path is a 404 that reads like a deleted rule. Carry the type alongside the id.\n\nFor `error-page` this is the only endpoint that returns the stored HTML and its sizes — the list gives you the status codes alone. Reading needs only `domain.view`, so a disabled domain still answers.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/RuleType"},{"$ref":"#/components/parameters/RuleId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"put":{"tags":["Rules"],"operationId":"updateDomainRule","summary":"Update a rule","description":"PUT, but a merge rather than a replace: only the keys present in the body are applied, and anything omitted keeps its stored value. You cannot clear a field by leaving it out, and you do not need to read the rule first in order to change one setting.\n\nTwo differences from create are easy to trip over. There is no plan check here, so a rule can still be edited on a plan that would no longer let you create it; and `priority: 0` is honoured instead of being promoted to 100, which makes an update the only way to put a rule ahead of everything sitting at the default.\n\nThe shared row and the per-type spec are saved in one transaction, so a rejected spec leaves neither changed. Validation failures are 400 with the reason in `error`.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/RuleType"},{"$ref":"#/components/parameters/RuleId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"tags":["Rules"],"operationId":"deleteDomainRule","summary":"Delete a rule","description":"Removes the shared rule row and its per-type spec in one transaction. Permanent — rules have no soft delete and no undo, so `/toggle` is what you want when the rule may come back.\n\nNot idempotent: a second call is 404. Surviving rules keep their `priority` values, so a deletion leaves a gap and changes nothing else about evaluation order.\n\n409 on a disabled domain, unlike deleting the *domain*, which stays allowed while it is off. A rule edit changes what the edge serves, and accepting configuration for something nobody is serving would be a lie about what happened.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/RuleType"},{"$ref":"#/components/parameters/RuleId"}],"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/rules/{ruleType}/{ruleId}/toggle":{"patch":{"tags":["Rules"],"operationId":"toggleDomainRule","summary":"Enable or disable a rule","description":"The safe way to take a rule out of the path while investigating: the\nspecification is kept, so putting it back is one call rather than a\nre-entry from memory.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"$ref":"#/components/parameters/RuleType"},{"$ref":"#/components/parameters/RuleId"}],"requestBody":{"$ref":"#/components/requestBodies/CustomerApiPassthrough"},"responses":{"200":{"$ref":"#/components/responses/CustomerApiPassthrough"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/DomainDisabled"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/uptime":{"get":{"tags":["Uptime"],"operationId":"listDomainOutageIncidents","summary":"Outage incident history for one domain","description":"Sustained origin-outage incidents, most recent first. An incident opens\nwhen a subdomain's origin-error rate stays above this domain's threshold\nfor the whole detection window, and resolves after `recover_min` clear\nminutes.\n\nOnly sustained outages appear here. A site that 502s for ninety seconds\nand recovers is real but is not an incident — use `/uptime/live` for the\ncurrent picture, including hosts that are erroring without having crossed\nthe threshold.\n","parameters":[{"$ref":"#/components/parameters/DomainId"},{"name":"limit","in":"query","description":"Rows to return, 1–200. Anything outside that is treated as 50.","schema":{"type":"integer","default":50,"minimum":1,"maximum":200}}],"responses":{"200":{"description":"The incident history","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutageIncidentList"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/reseller/v1/domains/{domainId}/uptime/live":{"get":{"tags":["Uptime"],"operationId":"getDomainUptimeLive","summary":"Current origin-error status per subdomain","description":"What is happening right now, per subdomain, over this domain's detection\nwindow — including hosts that are erroring but have not (yet) crossed the\nalert thresholds. Most alarming first: `down` hosts, then by error rate.\n\n`down` here means exactly what would fire an alert, computed the same way\nthe detector computes it, so the panel cannot show \"up\" next to an open\nincident.\n\nWith no analytics store behind it this answers `hosts: []` rather than an\nerror, because \"no host is erroring\" and \"we cannot see\" are reported\ndifferently by `window_min` alone — see the note on the field.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"description":"Live per-host status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UptimeLive"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"description":"The analytics store could not be queried.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/reseller/v1/domains/{domainId}/uptime/settings":{"get":{"tags":["Uptime"],"operationId":"getDomainUptimeSettings","summary":"Get outage-detection settings for one domain","description":"This domain's detection thresholds, with the valid range for each in\n`bounds`.\n\nThe values are the CLAMPED ones — what the detector will actually use —\nnot the raw column values. A window stored above the deployment's cap is\nreported at the cap, so the panel never shows a setting that is not in\nforce.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"responses":{"200":{"description":"Current settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UptimeSettings"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"put":{"tags":["Uptime"],"operationId":"updateDomainUptimeSettings","summary":"Update outage-detection settings for one domain","description":"Partial update — omitted fields keep their current value. Out-of-range\nvalues are REJECTED with a 400 naming the field, not silently clamped: a\nthreshold that was quietly changed to something else is a setting the\noperator believes he made and did not.\n\nUnlike records, rules, cache and SSL, this is accepted on a DISABLED\ndomain. Alert configuration is not edge configuration — nothing here\nchanges what is served — and a provider settling a domain's alerting\nwhile it is switched off is doing something reasonable.\n","parameters":[{"$ref":"#/components/parameters/DomainId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UptimeSettingsUpdate"}}}},"responses":{"200":{"description":"The settings as they now stand","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UptimeSettings"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"`Authorization: Bearer <credential>` — **two kinds of credential, one header.**\n\nThe middleware distinguishes them by prefix:\n\n| Credential | Used by | How it's checked |\n|---|---|---|\n| `nsin_live_<keyid>_<secret>` | machine clients (WHMCS, the reseller's own site) | look up `key_id`, verify the secret against its bcrypt hash |\n| anything else | the reseller dashboard | parsed as a session JWT, exactly like the admin panel |\n\nEither way the handler ends up with the same `callerID`, and every handler\nthen calls `mustOwnClient` / `mustOwnDomain` before touching a row.\n\nToken endpoints are the one exception: they require the **JWT** form, so a\nleaked API token cannot mint another one.\n"}},"parameters":{"Page":{"name":"page","in":"query","schema":{"type":"integer","minimum":1,"default":1}},"PerPage":{"name":"per_page","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},"ClientId":{"name":"clientId","in":"path","required":true,"description":"Must be a client of the caller, or the response is 403.","schema":{"type":"integer"}},"DomainId":{"name":"domainId","in":"path","required":true,"description":"Must belong to a client of the caller, or the response is 403.","schema":{"type":"integer"}},"ServiceId":{"name":"serviceId","in":"path","required":true,"description":"Must belong to a client of the caller, or the response is 403.","schema":{"type":"integer"}},"RecordId":{"name":"recordId","in":"path","required":true,"description":"Must belong to the domain in the path.","schema":{"type":"integer"}},"RuleType":{"name":"ruleType","in":"path","required":true,"description":"Which rule subsystem. Note the mixed separators: they are the URL\nspellings the customer API has always used, and changing them here to\nlook tidier would mean two spellings for one thing.\n","schema":{"type":"string","enum":["bot-route","cache","captcha","drop","error-page","fingerprint","origin_pool","origin_route","rate-limit","redirect","rewrite","waf"]}},"RuleId":{"name":"ruleId","in":"path","required":true,"description":"Must belong to the domain and rule type in the path.","schema":{"type":"integer"}},"MemberUserId":{"name":"userId","in":"path","required":true,"description":"The member's user id, as returned by the member list.","schema":{"type":"integer"}},"InviteId":{"name":"inviteId","in":"path","required":true,"schema":{"type":"integer"}},"Period":{"name":"period","in":"query","description":"Look-back window. Anything unrecognised is treated as 24h rather than\nrejected, so a dashboard cannot break itself with a stale value.\n","schema":{"type":"string","enum":["3h","6h","12h","24h","7d","30d"],"default":"24h"}},"LogLimit":{"name":"limit","in":"query","description":"Rows per page. Values outside 1..500 fall back to 100.","schema":{"type":"integer","minimum":1,"maximum":500,"default":100}},"LogOffset":{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}},"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":true,"description":"**Required on every endpoint that moves money.**\n\nA client-generated unique string (a UUID is fine). Replaying the same key\nreturns the **original** response byte for byte and does **not** repeat\nthe operation. A replayed response is marked with the response header\n`Idempotent-Replay: true`.\n\nThis is not optional politeness. Without it, a network timeout on a\n`credit` or `createService` call leaves the caller unable to tell whether\nthe money moved, and a naive retry double-charges.\n\n**A key never expires.** There is no TTL and nothing prunes the table: the\nrecord is written inside the same transaction as the money movement and\nthen kept forever, so a key is burned permanently. The same key sent a\nyear later still replays the original answer instead of transferring\nagain. Generate one key per operation — never one per process, per day, or\nper client.\n\nKeys are scoped to the calling reseller, so two partners may pick the same\nUUID without colliding, and neither can probe the other's key space.\n\nReuse with a **different** request is refused rather than replayed: same\nkey, different method, path or body is `422`. See the\n`IdempotencyConflict` response.\n\nA **failed** operation records nothing, because the record and the money\nshare one transaction and roll back together. A transfer that bounced for\ninsufficient funds is therefore freely retryable with the *same* key once\nthe wallet is funded.\n\nThe limit is **191 characters**, not the 128 this document used to claim —\nit is the width of the database column, and it is the only validation\nperformed on the value. The check runs on the trimmed string and counts\nbytes, so a non-ASCII key runs out before 191 characters; over-length is\n`400`.\n","schema":{"type":"string","maxLength":191}}},"responses":{"BadRequest":{"description":"Malformed or invalid input.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"Missing, malformed, expired, or revoked credential.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"Authenticated, but the target does not belong to you — or you are not a\nreseller at all.\n\nThis is the response the tenant-isolation matrix asserts on. Every route\nin this document is called with reseller B's credential against reseller\nA's ids, and must answer 403 or 404 with **none** of A's data in the body.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"AnalyticsUnavailable":{"description":"The analytics store (ClickHouse) is unreachable.\n\nDeliberately an error rather than a zeroed page: a page of zeros is\nindistinguishable from \"your customers' traffic stopped\", and someone\nwould act on it.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"DomainDisabled":{"description":"The domain is switched off and is not being served, so its configuration\ncannot be changed. It can still be read, and writes work again once it is\nenabled.\n\n409 rather than 403 on purpose: you have every right to do this, the\ndomain is simply in a state that cannot accept it, and telling a partner\n\"you don't have permission\" would send him to entirely the wrong\nconclusion. The body carries `\"code\": \"domain_disabled\"`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"CustomerApiPassthrough":{"description":"The customer API's response for the equivalent endpoint, unchanged.\n\nThese operations mount the SAME Go handlers the customer panel calls, so\nthe body shape is defined there and is deliberately not restated here — a\nsecond copy of the DNS record or rule schema is a second thing to keep in\nstep, and the copy is always the one that goes stale. See `openapi.yaml`.\n","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"InternalError":{"description":"Something failed on our side. The body carries a human sentence and never\nan internal detail — a live sweep of this API once returned\n`dial tcp 127.0.0.1:9000: connect: connection refused`, which is our\ntopology rather than an error message. That is now impossible.\n\n**Distinguish it from `503`.** A 503 means a dependency is down and the\nidentical request will succeed later, so retry it. A 500 means the request\nhit a genuine fault: retrying it unchanged will fail the same way, and it\nshould be reported with the `X-Request-Id` from the response header.\n\nDeclared on every operation because every operation can reach it. It was\npreviously declared on exactly one, which left a generated client with no\nbranch for the answer it is most likely to be surprised by.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"No such resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"TooManyRequests":{"description":"The caller's request budget is spent. **300 requests per minute** by\ndefault (`RESELLER_RATE_LIMIT`), and the same limiter covers every one of\nthe 83 operations in this document.\n\nCounted per **credential**, not per reseller: each `nsin_live_` token has\nits own budget and a dashboard session has another, so one runaway\nintegration cannot lock its owner out of his own panel, and revoking that\ntoken is enough to stop it.\n\nBranch on `\"code\": \"rate_limited\"` in the body. The message beside it is\nwritten for a human and may be reworded.\n\n`Retry-After` is set, in seconds until the window resets — wait that long\nrather than retrying at once. The `X-RateLimit-*` headers are **not** on\nthis response; they appear only on the responses the limiter let through,\nso a client that reads its remaining budget from the 429 alone will never\nsee one.\n","headers":{"Retry-After":{"description":"Seconds until the current window resets.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"IdempotencyConflict":{"description":"This `Idempotency-Key` has already been used, for a **different** request.\n\nA replay is only a replay if the method, path and body all match: they are\nhashed together into a fingerprint stored beside the recorded response,\nand a key that comes back with a different fingerprint is a caller bug —\nmost often a key generated once per process instead of once per operation.\n\n422 rather than 409 because the request is well-formed and authorized and\nno resource is in a conflicting state; it is the key that cannot be\nprocessed. Replaying the stored answer instead would tell the caller his\nSECOND transfer succeeded when it never ran, and he would report a client\nas funded who is not.\n\nNothing here is retryable until the key changes. The original operation\nstands, the new one never ran, and the fix is a fresh key.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBodies":{"CustomerApiPassthrough":{"description":"The customer API's request body for the equivalent endpoint, unchanged.\nSee `openapi.yaml`; it is not restated here for the same reason the\nresponse is not.\n","required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"schemas":{"Error":{"type":"object","description":"Every error body carries `error`, a human-readable sentence. Some also\ncarry `code`, a stable machine-readable reason — branch on that, never on\nthe sentence, which is prose and gets reworded.\n","required":["error"],"properties":{"error":{"type":"string","examples":["forbidden"]},"code":{"type":"string","description":"Present only on the failures worth branching on, and deliberately not\nan exhaustive enum: treat a code you do not recognise as if it were\nabsent and fall back to the status code.\n\nThe ones that exist today:\n\n* `credit_limit_reached` — 402 from `POST /services` and\n  `POST /services/{serviceId}/change-plan`. **Your** wallet, not the\n  client's: funding the purchase would take you past your overdraft\n  (default `0`, i.e. past zero). Top up; do not credit the client.\n* `domain_disabled` — 409 from a configuration write on a disabled\n  domain (records, rules, cache, SSL, members, settings). The domain\n  is read-only until `POST /domains/{domainId}/enable`.\n* `unknown_rule_type` — 404 from the rules paths when `ruleType` is\n  not one of the documented values. You get this rather than an empty\n  array, so a typo cannot read as a rule set that happens to be empty.\n* `rate_limited` — 429 from any route. Keyed per credential, so one\n  runaway integration cannot lock its owner out of the dashboard.\n* `session_check_failed` — 503, and only on calls made with a\n  dashboard session JWT. The session could not be verified, which is\n  not the same as knowing it is revoked, so it is retryable and the\n  session survives. Machine tokens never see this.\n","examples":["credit_limit_reached"]}}},"PageMeta":{"type":"object","required":["page","per_page","total"],"properties":{"page":{"type":"integer"},"per_page":{"type":"integer"},"total":{"type":"integer"}}},"Client":{"type":"object","description":"An end user owned by the calling reseller.","required":["id","email","phone_number","active","phone_verified","created_at"],"properties":{"id":{"type":"integer"},"name":{"type":"string","nullable":true},"email":{"type":"string","format":"email"},"phone_number":{"type":"string","examples":["09123456789"]},"phone_verified":{"type":"boolean","description":"Whether the panel will accept this number without challenging it.\n`true` for a freshly provisioned client — you vouched for it. See\n`phone_verified_by` for the basis.\n"},"phone_verified_by":{"type":"string","enum":["","otp","provider","admin"],"description":"How the number came to be verified.\n\n* `otp` — the client consumed a one-time code sent to this exact\n  number. Proof that somebody holds the handset.\n* `provider` — you entered it and vouched for it. Trust in you, not\n  proof of the handset.\n* `admin` — set by NSIN staff.\n* `\"\"` — not verified.\n\nChanging a client's phone via `PATCH /clients/{clientId}` re-bases\nthis on `provider`: an OTP consumed against the old number proves\nnothing about the new one, but your vouching still applies.\n"},"national_code":{"type":"string","examples":["0012345678"]},"birth_date":{"type":"string","format":"date","nullable":true},"active":{"type":"boolean","description":"`false` = account disabled; cannot log in."},"wallet_balance_rials":{"type":"integer","format":"int64","description":"Visible to the reseller. The client is never shown this, and gets 403\nfrom every wallet endpoint.\n"},"created_at":{"type":"string","format":"date-time"}}},"CreateClientRequest":{"type":"object","description":"These are **exactly** the fields the public `POST /auth/register` requires\nand validates ([auth.go:251-267]), minus `otp_code`. The reseller must\nsupply real identity data — the validators are checksum-level, not\nlength checks, so placeholders are rejected.\n\nThe only difference from public registration: **no `otp_code`**. There is\nnobody at the reseller's end to type one.\n","required":["email","password","phone_number","national_code"],"properties":{"email":{"type":"string","format":"email","description":"Required. Normalized (gmail dots, case) before the uniqueness check."},"password":{"type":"string","format":"password","description":"**Required** — `auth.register` rejects an empty password. The reseller\nsets it, and can change it later via `POST /clients/{id}/password`.\n"},"phone_number":{"type":"string","description":"**Required.** Iranian mobile, validated with `ValidIranianPhone` and\nnormalized with `NormalizeIranianPhone`.\n","examples":["09123456789"]},"national_code":{"type":"string","description":"**Required.** Validated with `ValidIranianNationalCode` — a real\nchecksum, not a length check. A made-up 10-digit string is rejected.\n","examples":["0012345678"]},"name":{"type":"string","description":"Optional."},"birth_date":{"type":"string","format":"date","description":"Optional — an empty value is accepted (`parseBirthDate` returns\n`\"\", nil`). If present it must be `YYYY-MM-DD`.\n"},"timezone":{"type":"string","description":"Optional.","examples":["Asia/Tehran"]}}},"UpdateClientRequest":{"type":"object","description":"Every field optional; omitted fields are unchanged.","properties":{"name":{"type":"string"},"email":{"type":"string","format":"email"},"phone_number":{"type":"string"},"birth_date":{"type":"string","format":"date"}}},"TransferResult":{"type":"object","required":["reseller_balance_rials","client_balance_rials","amount_rials"],"properties":{"amount_rials":{"type":"integer","format":"int64"},"reseller_balance_rials":{"type":"integer","format":"int64","description":"My balance after the transfer."},"client_balance_rials":{"type":"integer","format":"int64","description":"The client's balance after the transfer."}}},"WalletTransaction":{"type":"object","description":"One row of the append-only ledger. Historical rows are never updated — a\ncorrection is a new, compensating entry.\n","required":["id","type","amount_rials","balance_after","created_at"],"properties":{"id":{"type":"integer"},"type":{"type":"string","description":"e.g. `recharge`, `traffic`, `subscription`, `transfer_in`, `transfer_out`."},"amount_rials":{"type":"integer","format":"int64","description":"Negative for a debit, positive for a credit."},"balance_after":{"type":"integer","format":"int64"},"description":{"type":"string"},"created_at":{"type":"string","format":"date-time"}}},"Domain":{"type":"object","required":["id","name","status","user_id","suspended"],"properties":{"id":{"type":"integer"},"name":{"type":"string","examples":["example.com"]},"user_id":{"type":"integer","description":"The owning client."},"status":{"type":"string","enum":["pending","active","moved","disabled"],"description":"NS-delegation state, driven by the checker — **not** a billing state.\n`moved` means the nameservers stopped pointing at NSIN.\n"},"suspended":{"type":"boolean","description":"Billing/administrative stop. **This, not `status`, is what decides\nwhether the site is served.** Set by the negative-balance ladder, or\nmanually via `POST /domains/{domainId}/disable`.\n"},"suspended_at":{"type":"string","format":"date-time","nullable":true},"suspended_reason":{"type":"string","enum":["","manual","quota_exceeded","negative_balance"],"description":"WHY the domain is suspended. Empty when `suspended` is false.\n\n**Read this before offering a resume button.** Only `manual` can be\nlifted with `POST /domains/{domainId}/enable`; the other two are\nthe system's own and that call returns **409**:\n\n- `manual` — you switched it off. Nothing auto-resumes it, which is\n  the point: a client suspended for not paying must not come back\n  online because a quota period rolled over.\n- `quota_exceeded` — the client used up his plan's included traffic.\n  A reseller's client has no pay-as-you-go, so this is his hard stop.\n  Resolve it by moving him to a bigger plan; the quota sweep then\n  resumes the domain by itself.\n- `negative_balance` — a wallet stayed negative past its grace\n  window. On a client's domain that is usually **yours**, not his:\n  the tenancy sweep suspends every domain under a reseller who is in\n  debt, and a client's own balance has nothing that can drive it\n  below zero. Top your balance back up and billing resumes the\n  domains it suspended.\n"},"dns_mode":{"type":"string","enum":["managed","external"]},"created_at":{"type":"string","format":"date-time"}}},"Cert":{"type":"object","description":"One TLS certificate row, as it applies to a single hostname. A domain\nnormally has two: the apex and its wildcard sibling.\n\nDeliberately a projection, not the stored row: private keys, ACME\naccount references and issuance internals are never exposed here, so\nadding a column to the certs table cannot silently widen this response.\n","required":["id","domain_id","domain","hostname","status","expires_at"],"properties":{"id":{"type":"integer"},"domain_id":{"type":"integer"},"domain":{"type":"string","description":"The registrable domain this certificate belongs to.","examples":["example.com"]},"hostname":{"type":"string","description":"The exact SAN this row covers.","examples":["example.com","*.example.com"]},"status":{"type":"string","enum":["pending","active","failed"],"description":"`pending` while issuing; `failed` after the CA refused the name."},"expires_at":{"type":"string","format":"date-time"}}},"DomainSubscription":{"type":"object","description":"A domain subscription seen from the operating side: what is live, when it\nexpires, and whose it is. The same underlying row as `Service`, without\nthe selling fields.\n","required":["id","domain_id","domain_name","client_id","plan_id","status","auto_renew"],"properties":{"id":{"type":"integer"},"domain_id":{"type":"integer"},"domain_name":{"type":"string","examples":["example.com"]},"client_id":{"type":"integer"},"plan_id":{"type":"integer"},"plan_name":{"type":"string","nullable":true},"status":{"$ref":"#/components/schemas/ServiceStatus"},"auto_renew":{"type":"boolean"},"started_at":{"type":"string","format":"date-time","nullable":true},"expires_at":{"type":"string","format":"date-time","nullable":true}}},"ServiceStatus":{"type":"string","enum":["active","grace","expired","cancelled"],"description":"Mirrors `subscriptions.status` exactly.\n\n**There is deliberately no `suspended` value.** Suspension is not a\nsubscription state in NSIN — it is `domains.suspended`, a boolean on the\ndomain, and that is the thing the edge actually checks before serving.\n\nSo: `expired` and `cancelled` mean *\"no plan, still serving\"*. They are\n**not** an off switch. The off switch is\n`POST /domains/{domainId}/disable`.\n\nA domain in that state has an allowance of zero and — because a\nreseller's client has no pay-as-you-go — is **suspended by the quota\nsweep** as soon as it serves a byte. It stops shortly; it does not run\nup a bill. But \"shortly\" is not \"now\", which is why the explicit domain\nsuspend exists.\n\n> **TODO (deferred):** a first-class `suspended` status on the\n> subscription itself, so a service can be frozen without touching the\n> domain row. Not needed for v1 — domain suspend already stops serving\n> and stops the meter, which is the entire business requirement. See the\n> Deferred table in `RESELLER_PLAN_V2.md`.\n"},"Service":{"type":"object","description":"A subscription. Not a new entity — this *is* the `subscriptions` row.\n\nThe plan is reported three ways on purpose, so a reseller can always see\n**which plan is active and what it cost** without a second lookup:\n`plan_id` + `plan_name` (which plan), `duration_days` (which term), and\n`price_rials` (what the client's wallet was actually debited).\n","required":["id","client_id","domain_id","plan_id","plan_name","plan_term_id","duration_days","price_rials","status","started_at","expires_at"],"properties":{"id":{"type":"integer"},"client_id":{"type":"integer"},"domain_id":{"type":"integer"},"domain_name":{"type":"string","examples":["example.com"]},"domain_disabled":{"type":"boolean","description":"Whether the domain behind this service is currently being served.\n\n**A service can be `active` while its domain is off.** The plan is\npaid for; the traffic is not flowing. Read this before reporting a\nservice as healthy.\n"},"domain_disabled_reason":{"type":"string","enum":["manual","quota_exceeded","negative_balance"],"description":"Present only when `domain_disabled` is true. `manual` is yours to\nlift with `POST /domains/{domainId}/enable`; the other two are the\nsystem's and that call returns 409.\n"},"plan_id":{"type":"integer","description":"Which plan is active. Free / Starter / Golden / Enterprise.","examples":[3]},"plan_name":{"type":"string","examples":["Golden"]},"plan_term_id":{"type":"integer","examples":[14]},"duration_days":{"type":"integer","description":"The term that was bought. The same plan at a different duration is a different price.","examples":[365]},"price_rials":{"type":"integer","format":"int64","description":"NSIN list price of this term — what the **client's** wallet was\ndebited at purchase. `0` for the Free plan, which is a real,\npurchasable plan (the debit is simply a no-op).\n","examples":[120000000]},"status":{"$ref":"#/components/schemas/ServiceStatus"},"started_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"},"grace_until":{"type":"string","format":"date-time","nullable":true},"auto_renew":{"type":"boolean"}}},"Plan":{"type":"object","required":["id","name","slug","terms"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"title_fa":{"type":"string"},"slug":{"type":"string"},"description":{"type":"string"},"max_domains":{"type":"integer","nullable":true,"description":"null = unlimited"},"max_records":{"type":"integer","nullable":true},"max_traffic_gb":{"type":"integer","nullable":true,"description":"Included traffic per quota period.\n\nFor a **direct** NSIN customer, usage beyond this is billed\npay-as-you-go and may drive the balance negative.\n\nFor a **reseller's client it is a hard cap, not a meter.** He has no\npay-as-you-go: traffic billing skips him entirely, and when he\nreaches this number the quota sweep **suspends his domain**. He is\nnever charged for overage, and his wallet is never driven negative\nby traffic. Buy him a bigger plan to lift the cap.\n\nThe cap is enforced at the next sweep, not at the byte, so a client\ncan overshoot by whatever he serves within one ticker interval.\n"},"max_bandwidth_gb":{"type":"integer","nullable":true},"max_requests_per_month":{"type":"integer","nullable":true},"logs_enabled":{"type":"boolean"},"monitoring_enabled":{"type":"boolean"},"rules_enabled":{"type":"boolean"},"cache_purge_enabled":{"type":"boolean"},"custom_ssl_enabled":{"type":"boolean"},"ws_enabled":{"type":"boolean"},"host_header_edit_enabled":{"type":"boolean"},"dedicated_support_enabled":{"type":"boolean"},"max_rules_per_set":{"type":"integer","nullable":true},"max_cache_cap_mb":{"type":"integer","description":"The largest per-domain cache cap this plan permits. Never null — the floor is 128."},"is_recommended":{"type":"boolean"},"auto_renew_allowed":{"type":"boolean","description":"When false, wallet auto-renew cannot be enabled and the renew job skips the subscription."},"sort_order":{"type":"integer","description":"Catalog display order."},"platform_features_total":{"type":"integer","description":"How many platform capability flags exist, so `platform_features_enabled` reads as \"N of M\"."},"platform_features_enabled":{"type":"integer","description":"How many of them this plan turns on."},"terms":{"type":"array","description":"Purchase targets. `POST /services` takes a **term** id, not a plan id.\n\n**Always present, and always an array.** A plan with no active term\ncomes back as `[]` rather than as a missing key or `null` — it is a\nplan you cannot currently sell, not a plan without the field. Ordered\nshortest duration first, which is also the baseline `discount_percent`\nis measured against.\n","items":{"$ref":"#/components/schemas/PlanTerm"}}}},"PlanTerm":{"type":"object","required":["id","duration_days","price_rials"],"properties":{"id":{"type":"integer"},"duration_days":{"type":"integer","examples":[30,90,365]},"price_rials":{"type":"integer","format":"int64","description":"NSIN **list** price — what the client's wallet is debited. The reseller\nsees it; the client never does. NSIN does not know, store, or ask for\nthe reseller's own retail price.\n"},"is_default":{"type":"boolean"},"grace_days":{"type":"integer","description":"Days after expiry before the subscription is treated as lapsed."},"sort_order":{"type":"integer"},"discount_percent":{"type":"integer","description":"Saving against the **shortest** active term's daily rate, as a whole\npercent. `0` on that shortest term itself, and on any term that is not\nactually cheaper per day. Computed, not stored — it is what a reseller\nprices a longer commitment against.\n"}}},"UsageRow":{"type":"object","required":["client_id","domain_id","domain_name"],"properties":{"client_id":{"type":"integer"},"domain_id":{"type":"integer"},"domain_name":{"type":"string"},"cached_bytes":{"type":"integer","format":"int64"},"proxied_bytes":{"type":"integer","format":"int64"},"direct_bytes":{"type":"integer","format":"int64"},"requests":{"type":"integer","format":"int64"},"charged_rials":{"type":"integer","format":"int64"}}},"ApiToken":{"type":"object","description":"The plaintext secret appears here **only** in the mint response.","required":["id","name","key_id","status","created_at"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"key_id":{"type":"string","description":"The public half. Safe to display and log.","examples":["7f3a9c"]},"status":{"type":"string","enum":["active","revoked"]},"expires_at":{"type":"string","format":"date-time","nullable":true},"last_used_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"ApiCall":{"type":"object","description":"One request you made to this API, and the answer it got.","required":["id","created_at","request_id","method","path","status","duration_ms","ip"],"properties":{"id":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"request_id":{"type":"string","description":"The `X-Request-Id` returned with this call. Quote it in a ticket.","examples":["9f2b41e8a7c04f16"]},"token_id":{"type":"integer","description":"The credential used. Absent when the call came from a dashboard session."},"token_name":{"type":"string","examples":["WHMCS production"]},"method":{"type":"string","examples":["POST"]},"path":{"type":"string","description":"What you asked for, query string included.","examples":["/reseller/v1/clients?page=2"]},"route":{"type":"string","description":"The route template it matched. **Empty means it matched nothing** —\nwhich is the answer when a 404 is really a wrong path.\n","examples":["/reseller/v1/clients/:clientId"]},"status":{"type":"integer","examples":[401]},"duration_ms":{"type":"integer","description":"Server-side handling time. Excludes network and TLS, so a client-side timeout far larger than this one means the time went to the wire, not to us."},"error":{"type":"string","description":"The `error` field of the response. Present only for failures.","examples":["Not Authorized"]},"ip":{"type":"string"},"user_agent":{"type":"string"}}},"ApiActivityPage":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiCall"}},"meta":{"$ref":"#/components/schemas/PageMeta"}}},"Diagnostics":{"type":"object","description":"What the server sees when your integration calls it.","required":["ok","base_url","reseller_id","auth","rate_limit","server_time","request_id"],"properties":{"ok":{"type":"boolean"},"base_url":{"type":"string","description":"The origin this API is served from. Compare it with the host you called.","examples":["https://api.nsin.cloud"]},"reseller_id":{"type":"integer"},"company":{"type":"string","nullable":true},"request_id":{"type":"string"},"server_time":{"type":"string","format":"date-time","description":"Useful when a signature or an expiry is being rejected and clock skew is suspected."},"auth":{"type":"object","description":"Which credential arrived, and what it resolved to.","required":["method"],"properties":{"method":{"type":"string","enum":["token","session"],"description":"`token` is an `nsin_live_` machine credential; `session` is a dashboard JWT."},"token_id":{"type":"integer"},"key_id":{"type":"string","description":"The public half of the credential."},"name":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true}}},"rate_limit":{"type":"object","required":["limit","window_seconds"],"properties":{"limit":{"type":"integer","description":"Requests allowed per window, per credential.","examples":[300]},"window_seconds":{"type":"integer","examples":[60]}}}}},"SupportContact":{"type":"object","description":"Rendered to the reseller's clients in place of the ticket UI. Free-form on\npurpose — NSIN does not model the reseller's support process.\n","properties":{"title":{"type":"string","examples":["پشتیبانی پارسا هاست"]},"phone":{"type":"string"},"email":{"type":"string","format":"email"},"telegram":{"type":"string"},"url":{"type":"string","format":"uri"},"body":{"type":"string","description":"Free text / markdown shown under the contact details."}}},"DomainTraffic":{"type":"object","description":"One domain's traffic in the requested period.","required":["domain_id","domain_name","total_requests","total_bandwidth","unique_visitors"],"properties":{"domain_id":{"type":"integer"},"domain_name":{"type":"string","examples":["shop.example.ir"]},"total_requests":{"type":"integer"},"total_bandwidth":{"type":"integer","description":"Bytes served to visitors."},"unique_visitors":{"type":"integer","description":"Distinct (client IP, JA4 TLS fingerprint) pairs. Pairing the two\nseparates people sharing one CGNAT address by device stack; on plain\nHTTP it degrades to counting IPs.\n"},"error_rate":{"type":"number","description":"Percentage of this domain's responses with status >= 400."},"latest_event":{"type":"string","format":"date-time","nullable":true,"description":"When this domain last served a request in the window, or `null` if\nit served none. `null` is a real answer — \"silent\" — and a caller\nmust not render it as a date it invented.\n"},"series":{"type":"array","description":"Requests per bucket, oldest first, for a sparkline. Present but\nempty for a domain with no traffic; a caller must draw nothing\nrather than a flat line along zero, which reads as measured silence\nwhen it may be an unanswered question.\n","items":{"$ref":"#/components/schemas/RequestPoint"}}}},"RequestPoint":{"type":"object","description":"One time bucket of request counts.","required":["timestamp","count"],"properties":{"timestamp":{"type":"string","description":"Bucket start, server-local, `YYYY-MM-DD HH:MM:SS` or ISO-8601."},"count":{"type":"integer"}}},"RequestSeries":{"type":"object","description":"A request-count series, oldest bucket first.","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RequestPoint"}}}},"BandwidthSeries":{"type":"object","description":"A bytes-in/bytes-out series, oldest bucket first.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","required":["timestamp","bytes_in","bytes_out"],"properties":{"timestamp":{"type":"string"},"bytes_in":{"type":"integer","description":"Bytes received from visitors."},"bytes_out":{"type":"integer","description":"Bytes served to visitors."}}}}}},"StatusCodeBreakdown":{"type":"object","description":"Response statuses and how often each occurred, busiest first.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","required":["status_code","count"],"properties":{"status_code":{"type":"integer","examples":[200,404,502]},"count":{"type":"integer"}}}}}},"CacheBreakdown":{"type":"object","description":"Cache outcomes for the period. `hit_rate` is hits / (hits + misses) —\nbypassed requests are excluded from the denominator because a bypass is\na decision not to cache, not a cache failure, and counting it as one\nmakes a correctly configured API look broken.\n","properties":{"hits":{"type":"integer"},"misses":{"type":"integer"},"bypass":{"type":"integer"},"hit_rate":{"type":"number","description":"Percentage","0 when nothing was looked up.":null},"bypass_reasons":{"type":"object","additionalProperties":{"type":"integer"},"description":"Why requests bypassed the cache, by reason. Absent when none did."}}},"TenancyTraffic":{"type":"object","description":"The roll-up across every domain of every client.","required":["domains","total_requests","total_bandwidth","unique_visitors","error_rate"],"properties":{"domains":{"type":"integer","description":"How many domains the figures cover."},"total_requests":{"type":"integer"},"total_bandwidth":{"type":"integer"},"unique_visitors":{"type":"integer","description":"Computed over the whole tenancy, NOT summed from the rows: one\nperson browsing three of your sites is one visitor.\n"},"error_rate":{"type":"number","description":"Percentage of responses with status >= 400."}}},"DomainAnalyticsSummary":{"type":"object","description":"One domain's volume and latency. Response-time figures exclude\nWebSocket requests — a WS duration spans the whole upgraded connection,\nso long-lived sockets would swamp the average.\n","properties":{"total_requests":{"type":"integer"},"total_bandwidth":{"type":"integer"},"unique_visitors":{"type":"integer"},"error_rate":{"type":"number"},"avg_response_time":{"type":"number","description":"Seconds."},"p90":{"type":"number"},"p95":{"type":"number"},"p99":{"type":"number"}}},"LogPage":{"type":"object","description":"A page of raw request rows, newest first. Each row is one request served\nat the edge and carries the end user's IP, user agent and headers.\n","required":["data","total","limit","offset"],"properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true}},"total":{"type":"integer","description":"Matching rows before pagination."},"limit":{"type":"integer"},"offset":{"type":"integer"}}},"WafLogPage":{"type":"object","description":"A page of WAF audit rows, newest first, each with the rule ids and\nmessages that matched and the data that triggered them.\n","required":["data","total","limit","offset"],"properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true}},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"}}},"OutageIncidentList":{"type":"object","required":["incidents"],"properties":{"incidents":{"type":"array","items":{"$ref":"#/components/schemas/OutageIncident"}}}},"OutageIncident":{"type":"object","required":["id","hostname","state","ongoing","started_at","duration_seconds","peak_err_pct","sample_reqs"],"properties":{"id":{"type":"integer"},"hostname":{"type":"string","description":"The subdomain that was down","not the zone.":null},"state":{"type":"string","description":"`open` while ongoing, `resolved` once recovered."},"ongoing":{"type":"boolean"},"started_at":{"type":"string","format":"date-time"},"resolved_at":{"type":"string","format":"date-time","description":"Absent while the incident is ongoing. Absent is not \"resolved at the\nzero time\" — render the two differently.\n"},"duration_seconds":{"type":"integer","description":"Measured to `resolved_at`, or to now while the incident is still\nopen, so an ongoing outage's duration grows as you watch it.\n"},"peak_err_pct":{"type":"number","description":"Highest origin-error percentage reached."},"sample_reqs":{"type":"integer","description":"Requests observed over the incident."}}},"UptimeLive":{"type":"object","required":["window_min","hosts"],"properties":{"window_min":{"type":"integer","description":"Length of the trailing window, in minutes — this domain's own\ndetection window, not a fixed value.\n"},"hosts":{"type":"array","description":"Never null. An empty array means no host served anything in the\nwindow, which is a real answer and must not render as an error.\n","items":{"$ref":"#/components/schemas/UptimeLiveStatus"}}}},"UptimeLiveStatus":{"type":"object","required":["hostname","requests","errors","error_pct","down","incident"],"properties":{"hostname":{"type":"string"},"requests":{"type":"integer"},"errors":{"type":"integer","description":"Origin-attributable 5xx responses."},"error_pct":{"type":"number","description":"Origin-error percentage across the window."},"down":{"type":"boolean","description":"Currently meets this domain's alert thresholds. Computed exactly as\nthe detector computes it, so this cannot disagree with `incident`\nexcept during the minutes between a breach and the incident opening.\n"},"incident":{"type":"boolean","description":"An incident is currently open for this host."}}},"UptimeSettings":{"type":"object","required":["enabled","threshold_pct","window_min","min_requests","min_active_min","recover_min","bounds"],"properties":{"enabled":{"type":"boolean","description":"Whether outage alerts are sent for this domain."},"threshold_pct":{"type":"integer","description":"Per-minute origin-error percentage that counts as down."},"window_min":{"type":"integer","description":"Minutes the host must stay down before an incident opens."},"min_requests":{"type":"integer","description":"Traffic floor — below this, no incident opens. It is what stops one\nfailed request on a near-idle staging host paging somebody at 3am.\n"},"min_active_min":{"type":"integer","description":"Minimum populated one-minute buckets required in the window."},"recover_min":{"type":"integer","description":"Consecutive clear minutes before an incident resolves."},"bounds":{"$ref":"#/components/schemas/UptimeSettingsBounds"}}},"UptimeSettingsBounds":{"type":"object","description":"Valid range for each configurable field, served with the values so a\nform can bound its own inputs instead of hard-coding limits that a\ndeployment can change under it.\n","required":["threshold_pct_min","threshold_pct_max","window_min_min","window_min_max","min_requests_min","recover_min_min","recover_min_max"],"properties":{"threshold_pct_min":{"type":"integer"},"threshold_pct_max":{"type":"integer"},"window_min_min":{"type":"integer"},"window_min_max":{"type":"integer","description":"Deployment-wide cap; not a constant."},"min_requests_min":{"type":"integer"},"recover_min_min":{"type":"integer"},"recover_min_max":{"type":"integer"}}},"UptimeSettingsUpdate":{"type":"object","description":"Every field is optional; omitted fields keep their current value. There\nis no way to express \"reset to default\" — send the default explicitly.\n","properties":{"enabled":{"type":"boolean"},"threshold_pct":{"type":"integer"},"window_min":{"type":"integer"},"min_requests":{"type":"integer"},"min_active_min":{"type":"integer","description":"May not exceed `window_min`. If the two are changed together in a way\nthat would break that, this one is lowered to match.\n"},"recover_min":{"type":"integer"}}},"MarkdownTesterResult":{"type":"object","required":["url","feature_enabled","html","markdown"],"properties":{"url":{"type":"string","description":"The URL that was fetched","after host and path validation.":null},"feature_enabled":{"type":"boolean","description":"The domain's `markdown_for_agents` setting at test time. With it off,\n`markdown.converted` will be false — that is the feature not being\non, not the page failing to convert, and the two must not be reported\nas the same thing.\n"},"html":{"$ref":"#/components/schemas/MarkdownTesterFetch"},"markdown":{"$ref":"#/components/schemas/MarkdownTesterFetch"}}},"MarkdownTesterFetch":{"type":"object","required":["status","content_type","content_length","body","truncated","converted"],"properties":{"status":{"type":"integer","description":"The origin/edge status. 0 when the fetch itself failed — see `error`."},"content_type":{"type":"string"},"content_length":{"type":"integer","description":"Body length as returned, after the 256 KiB cap is applied."},"body":{"type":"string"},"truncated":{"type":"boolean","description":"The body was longer than the cap and has been cut."},"binary":{"type":"boolean","description":"The body was not valid UTF-8, so `body` is omitted rather than mangled."},"cache_status":{"type":"string","description":"The edge's `Nsn-Cache-Status`","when it sent one.":null},"markdown_tokens":{"type":"integer","description":"From `X-Markdown-Tokens`. Absent when the edge did not count."},"original_tokens":{"type":"integer","description":"From `X-Original-Tokens`. Absent when the edge did not count."},"converted":{"type":"boolean","description":"The response came back as `text/markdown`."},"error":{"type":"string","description":"Why the fetch failed, when it did. Present with a zero `status` means\nnothing was reached at all — do not render that as a 0 response code.\n"}}}}}}