Buy a plan for a client's domain
const url = 'https://api.nsin.cloud/reseller/v1/services';const options = { method: 'POST', headers: { 'Idempotency-Key': 'example', Authorization: 'Bearer <token>', 'Content-Type': 'application/json' }, body: '{"domain_id":412,"plan_term_id":9,"auto_renew":false}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url https://api.nsin.cloud/reseller/v1/services \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: example' \ --data '{ "domain_id": 412, "plan_term_id": 9, "auto_renew": false }'A service is a subscription — the existing subscriptions row. There
is no separate entity, which is why WHMCS maps 1:1.
You pay; the client’s wallet is only where the money lands. The
purchase debits the CLIENT’s wallet at NSIN list price, and whatever that
wallet is short by is moved out of YOUR wallet into his first, inside
this same transaction (ref_type: reseller_autofund on both ledger
rows). Top-up and purchase commit or roll back together, so a failed
purchase can never leave you charged for a plan the client did not get.
You do not have to pre-fund the client: POST /clients/{clientId}/credit
is optional and only makes that automatic top-up smaller, or a no-op.
There is no reseller branch anywhere in the pricing path — a reseller’s client pays exactly what a direct customer pays. NSIN never learns, stores, or asks for the reseller’s own retail price.
Purchase is keyed on plan_term_id, not plan_id: a plan has several
terms (30/90/365 days) at different prices. Get them from GET /plans.
WHMCS CreateAccount maps here.
Authorizations
Section titled “Authorizations ”Parameters
Section titled “ Parameters ”Header Parameters
Section titled “Header Parameters ”Required on every endpoint that moves money.
A client-generated unique string (a UUID is fine). Replaying the same key
returns the original response byte for byte and does not repeat
the operation. A replayed response is marked with the response header
Idempotent-Replay: true.
This is not optional politeness. Without it, a network timeout on a
credit or createService call leaves the caller unable to tell whether
the money moved, and a naive retry double-charges.
A key never expires. There is no TTL and nothing prunes the table: the record is written inside the same transaction as the money movement and then kept forever, so a key is burned permanently. The same key sent a year later still replays the original answer instead of transferring again. Generate one key per operation — never one per process, per day, or per client.
Keys are scoped to the calling reseller, so two partners may pick the same UUID without colliding, and neither can probe the other’s key space.
Reuse with a different request is refused rather than replayed: same
key, different method, path or body is 422. See the
IdempotencyConflict response.
A failed operation records nothing, because the record and the money share one transaction and roll back together. A transfer that bounced for insufficient funds is therefore freely retryable with the same key once the wallet is funded.
The limit is 191 characters, not the 128 this document used to claim —
it is the width of the database column, and it is the only validation
performed on the value. The check runs on the trimmed string and counts
bytes, so a non-ASCII key runs out before 191 characters; over-length is
400.
Request Body required
Section titled “Request Body required ”object
From GET /plans → terms[].id. Not the plan id.
Responses
Section titled “ Responses ”Purchased. The client’s wallet was debited, having first been topped
up from yours for whatever it was short — or, if this
Idempotency-Key was already used for this exact request, the first
call’s response replayed unchanged, with no second purchase.
A subscription. Not a new entity — this is the subscriptions row.
The plan is reported three ways on purpose, so a reseller can always see
which plan is active and what it cost without a second lookup:
plan_id + plan_name (which plan), duration_days (which term), and
price_rials (what the client’s wallet was actually debited).
object
Whether the domain behind this service is currently being served.
A service can be active while its domain is off. The plan is
paid for; the traffic is not flowing. Read this before reporting a
service as healthy.
Present only when domain_disabled is true. manual is yours to
lift with POST /domains/{domainId}/enable; the other two are the
system’s and that call returns 409.
Which plan is active. Free / Starter / Golden / Enterprise.
The term that was bought. The same plan at a different duration is a different price.
NSIN list price of this term — what the client’s wallet was
debited at purchase. 0 for the Free plan, which is a real,
purchasable plan (the debit is simply a no-op).
Mirrors subscriptions.status exactly.
There is deliberately no suspended value. Suspension is not a
subscription state in NSIN — it is domains.suspended, a boolean on the
domain, and that is the thing the edge actually checks before serving.
So: expired and cancelled mean “no plan, still serving”. They are
not an off switch. The off switch is
POST /domains/{domainId}/disable.
A domain in that state has an allowance of zero and — because a reseller’s client has no pay-as-you-go — is suspended by the quota sweep as soon as it serves a byte. It stops shortly; it does not run up a bill. But “shortly” is not “now”, which is why the explicit domain suspend exists.
TODO (deferred): a first-class
suspendedstatus on the subscription itself, so a service can be frozen without touching the domain row. Not needed for v1 — domain suspend already stops serving and stops the meter, which is the entire business requirement. See the Deferred table inRESELLER_PLAN_V2.md.
Example
{ "domain_name": "example.com", "domain_disabled_reason": "manual", "plan_id": 3, "plan_name": "Golden", "plan_term_id": 14, "duration_days": 365, "price_rials": 120000000, "status": "active"}Headers
Section titled “Headers ”Sent as true only on a replay: the body is the stored answer
from the first call with this Idempotency-Key, and the
operation did NOT run a second time. Absent on the call that
actually did the work — which is how a retrying client tells
which of its attempts moved the money.
A malformed body, or a value the handler rejects outright.
Also the answer to a missing Idempotency-Key: the generated
wrapper rejects that before the handler is entered, so the body
carries the wrapper’s message rather than the handler’s. A key longer
than 191 bytes is rejected by the handler, with a message saying so.
Every error body carries error, a human-readable sentence. Some also
carry code, a stable machine-readable reason — branch on that, never on
the sentence, which is prose and gets reworded.
object
Present only on the failures worth branching on, and deliberately not an exhaustive enum: treat a code you do not recognise as if it were absent and fall back to the status code.
The ones that exist today:
credit_limit_reached— 402 fromPOST /servicesandPOST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default0, i.e. past zero). Top up; do not credit the client.domain_disabled— 409 from a configuration write on a disabled domain (records, rules, cache, SSL, members, settings). The domain is read-only untilPOST /domains/{domainId}/enable.unknown_rule_type— 404 from the rules paths whenruleTypeis not one of the documented values. You get this rather than an empty array, so a typo cannot read as a rule set that happens to be empty.rate_limited— 429 from any route. Keyed per credential, so one runaway integration cannot lock its owner out of the dashboard.session_check_failed— 503, and only on calls made with a dashboard session JWT. The session could not be verified, which is not the same as knowing it is revoked, so it is retryable and the session survives. Machine tokens never see this.
Example
{ "error": "forbidden", "code": "credit_limit_reached"}Missing, malformed, expired, or revoked credential.
Every error body carries error, a human-readable sentence. Some also
carry code, a stable machine-readable reason — branch on that, never on
the sentence, which is prose and gets reworded.
object
Present only on the failures worth branching on, and deliberately not an exhaustive enum: treat a code you do not recognise as if it were absent and fall back to the status code.
The ones that exist today:
credit_limit_reached— 402 fromPOST /servicesandPOST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default0, i.e. past zero). Top up; do not credit the client.domain_disabled— 409 from a configuration write on a disabled domain (records, rules, cache, SSL, members, settings). The domain is read-only untilPOST /domains/{domainId}/enable.unknown_rule_type— 404 from the rules paths whenruleTypeis not one of the documented values. You get this rather than an empty array, so a typo cannot read as a rule set that happens to be empty.rate_limited— 429 from any route. Keyed per credential, so one runaway integration cannot lock its owner out of the dashboard.session_check_failed— 503, and only on calls made with a dashboard session JWT. The session could not be verified, which is not the same as knowing it is revoked, so it is retryable and the session survives. Machine tokens never see this.
Example
{ "error": "forbidden", "code": "credit_limit_reached"}Your wallet came up short, not the client’s. Funding him for this
purchase would take you past your credit limit — which, with the
default reseller_max_negative_rials of 0, means past zero. The
body carries "code": "credit_limit_reached".
Crediting the client does not help: POST /clients/{clientId}/credit
draws on the same wallet and fails for the same reason. Top your own
wallet up through the panel’s Zarinpal flow, or ask NSIN to raise the
limit, then retry with the same Idempotency-Key — a failed purchase
records no key.
Nothing was written: no transfer, no ledger row, no subscription. (A
402 with no code would be the client’s own balance, which the
automatic top-up makes unreachable on this path.)
Every error body carries error, a human-readable sentence. Some also
carry code, a stable machine-readable reason — branch on that, never on
the sentence, which is prose and gets reworded.
object
Present only on the failures worth branching on, and deliberately not an exhaustive enum: treat a code you do not recognise as if it were absent and fall back to the status code.
The ones that exist today:
credit_limit_reached— 402 fromPOST /servicesandPOST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default0, i.e. past zero). Top up; do not credit the client.domain_disabled— 409 from a configuration write on a disabled domain (records, rules, cache, SSL, members, settings). The domain is read-only untilPOST /domains/{domainId}/enable.unknown_rule_type— 404 from the rules paths whenruleTypeis not one of the documented values. You get this rather than an empty array, so a typo cannot read as a rule set that happens to be empty.rate_limited— 429 from any route. Keyed per credential, so one runaway integration cannot lock its owner out of the dashboard.session_check_failed— 503, and only on calls made with a dashboard session JWT. The session could not be verified, which is not the same as knowing it is revoked, so it is retryable and the session survives. Machine tokens never see this.
Example
{ "error": "forbidden", "code": "credit_limit_reached"}Authenticated, but the target does not belong to you — or you are not a reseller at all.
This is the response the tenant-isolation matrix asserts on. Every route in this document is called with reseller B’s credential against reseller A’s ids, and must answer 403 or 404 with none of A’s data in the body.
Every error body carries error, a human-readable sentence. Some also
carry code, a stable machine-readable reason — branch on that, never on
the sentence, which is prose and gets reworded.
object
Present only on the failures worth branching on, and deliberately not an exhaustive enum: treat a code you do not recognise as if it were absent and fall back to the status code.
The ones that exist today:
credit_limit_reached— 402 fromPOST /servicesandPOST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default0, i.e. past zero). Top up; do not credit the client.domain_disabled— 409 from a configuration write on a disabled domain (records, rules, cache, SSL, members, settings). The domain is read-only untilPOST /domains/{domainId}/enable.unknown_rule_type— 404 from the rules paths whenruleTypeis not one of the documented values. You get this rather than an empty array, so a typo cannot read as a rule set that happens to be empty.rate_limited— 429 from any route. Keyed per credential, so one runaway integration cannot lock its owner out of the dashboard.session_check_failed— 503, and only on calls made with a dashboard session JWT. The session could not be verified, which is not the same as knowing it is revoked, so it is retryable and the session survives. Machine tokens never see this.
Example
{ "error": "forbidden", "code": "credit_limit_reached"}No such resource.
Every error body carries error, a human-readable sentence. Some also
carry code, a stable machine-readable reason — branch on that, never on
the sentence, which is prose and gets reworded.
object
Present only on the failures worth branching on, and deliberately not an exhaustive enum: treat a code you do not recognise as if it were absent and fall back to the status code.
The ones that exist today:
credit_limit_reached— 402 fromPOST /servicesandPOST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default0, i.e. past zero). Top up; do not credit the client.domain_disabled— 409 from a configuration write on a disabled domain (records, rules, cache, SSL, members, settings). The domain is read-only untilPOST /domains/{domainId}/enable.unknown_rule_type— 404 from the rules paths whenruleTypeis not one of the documented values. You get this rather than an empty array, so a typo cannot read as a rule set that happens to be empty.rate_limited— 429 from any route. Keyed per credential, so one runaway integration cannot lock its owner out of the dashboard.session_check_failed— 503, and only on calls made with a dashboard session JWT. The session could not be verified, which is not the same as knowing it is revoked, so it is retryable and the session survives. Machine tokens never see this.
Example
{ "error": "forbidden", "code": "credit_limit_reached"}The domain already has an active service.
Every error body carries error, a human-readable sentence. Some also
carry code, a stable machine-readable reason — branch on that, never on
the sentence, which is prose and gets reworded.
object
Present only on the failures worth branching on, and deliberately not an exhaustive enum: treat a code you do not recognise as if it were absent and fall back to the status code.
The ones that exist today:
credit_limit_reached— 402 fromPOST /servicesandPOST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default0, i.e. past zero). Top up; do not credit the client.domain_disabled— 409 from a configuration write on a disabled domain (records, rules, cache, SSL, members, settings). The domain is read-only untilPOST /domains/{domainId}/enable.unknown_rule_type— 404 from the rules paths whenruleTypeis not one of the documented values. You get this rather than an empty array, so a typo cannot read as a rule set that happens to be empty.rate_limited— 429 from any route. Keyed per credential, so one runaway integration cannot lock its owner out of the dashboard.session_check_failed— 503, and only on calls made with a dashboard session JWT. The session could not be verified, which is not the same as knowing it is revoked, so it is retryable and the session survives. Machine tokens never see this.
Example
{ "error": "forbidden", "code": "credit_limit_reached"}This Idempotency-Key has already been used, for a different request.
A replay is only a replay if the method, path and body all match: they are hashed together into a fingerprint stored beside the recorded response, and a key that comes back with a different fingerprint is a caller bug — most often a key generated once per process instead of once per operation.
422 rather than 409 because the request is well-formed and authorized and no resource is in a conflicting state; it is the key that cannot be processed. Replaying the stored answer instead would tell the caller his SECOND transfer succeeded when it never ran, and he would report a client as funded who is not.
Nothing here is retryable until the key changes. The original operation stands, the new one never ran, and the fix is a fresh key.
Every error body carries error, a human-readable sentence. Some also
carry code, a stable machine-readable reason — branch on that, never on
the sentence, which is prose and gets reworded.
object
Present only on the failures worth branching on, and deliberately not an exhaustive enum: treat a code you do not recognise as if it were absent and fall back to the status code.
The ones that exist today:
credit_limit_reached— 402 fromPOST /servicesandPOST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default0, i.e. past zero). Top up; do not credit the client.domain_disabled— 409 from a configuration write on a disabled domain (records, rules, cache, SSL, members, settings). The domain is read-only untilPOST /domains/{domainId}/enable.unknown_rule_type— 404 from the rules paths whenruleTypeis not one of the documented values. You get this rather than an empty array, so a typo cannot read as a rule set that happens to be empty.rate_limited— 429 from any route. Keyed per credential, so one runaway integration cannot lock its owner out of the dashboard.session_check_failed— 503, and only on calls made with a dashboard session JWT. The session could not be verified, which is not the same as knowing it is revoked, so it is retryable and the session survives. Machine tokens never see this.
Example
{ "error": "forbidden", "code": "credit_limit_reached"}The caller’s request budget is spent. 300 requests per minute by
default (RESELLER_RATE_LIMIT), and the same limiter covers every one of
the 83 operations in this document.
Counted per credential, not per reseller: each nsin_live_ token has
its own budget and a dashboard session has another, so one runaway
integration cannot lock its owner out of his own panel, and revoking that
token is enough to stop it.
Branch on "code": "rate_limited" in the body. The message beside it is
written for a human and may be reworded.
Retry-After is set, in seconds until the window resets — wait that long
rather than retrying at once. The X-RateLimit-* headers are not on
this response; they appear only on the responses the limiter let through,
so a client that reads its remaining budget from the 429 alone will never
see one.
Every error body carries error, a human-readable sentence. Some also
carry code, a stable machine-readable reason — branch on that, never on
the sentence, which is prose and gets reworded.
object
Present only on the failures worth branching on, and deliberately not an exhaustive enum: treat a code you do not recognise as if it were absent and fall back to the status code.
The ones that exist today:
credit_limit_reached— 402 fromPOST /servicesandPOST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default0, i.e. past zero). Top up; do not credit the client.domain_disabled— 409 from a configuration write on a disabled domain (records, rules, cache, SSL, members, settings). The domain is read-only untilPOST /domains/{domainId}/enable.unknown_rule_type— 404 from the rules paths whenruleTypeis not one of the documented values. You get this rather than an empty array, so a typo cannot read as a rule set that happens to be empty.rate_limited— 429 from any route. Keyed per credential, so one runaway integration cannot lock its owner out of the dashboard.session_check_failed— 503, and only on calls made with a dashboard session JWT. The session could not be verified, which is not the same as knowing it is revoked, so it is retryable and the session survives. Machine tokens never see this.
Example
{ "error": "forbidden", "code": "credit_limit_reached"}Headers
Section titled “Headers ”Seconds until the current window resets.
Something failed on our side. The body carries a human sentence and never
an internal detail — a live sweep of this API once returned
dial tcp 127.0.0.1:9000: connect: connection refused, which is our
topology rather than an error message. That is now impossible.
Distinguish it from 503. A 503 means a dependency is down and the
identical request will succeed later, so retry it. A 500 means the request
hit a genuine fault: retrying it unchanged will fail the same way, and it
should be reported with the X-Request-Id from the response header.
Declared on every operation because every operation can reach it. It was previously declared on exactly one, which left a generated client with no branch for the answer it is most likely to be surprised by.
Every error body carries error, a human-readable sentence. Some also
carry code, a stable machine-readable reason — branch on that, never on
the sentence, which is prose and gets reworded.
object
Present only on the failures worth branching on, and deliberately not an exhaustive enum: treat a code you do not recognise as if it were absent and fall back to the status code.
The ones that exist today:
credit_limit_reached— 402 fromPOST /servicesandPOST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default0, i.e. past zero). Top up; do not credit the client.domain_disabled— 409 from a configuration write on a disabled domain (records, rules, cache, SSL, members, settings). The domain is read-only untilPOST /domains/{domainId}/enable.unknown_rule_type— 404 from the rules paths whenruleTypeis not one of the documented values. You get this rather than an empty array, so a typo cannot read as a rule set that happens to be empty.rate_limited— 429 from any route. Keyed per credential, so one runaway integration cannot lock its owner out of the dashboard.session_check_failed— 503, and only on calls made with a dashboard session JWT. The session could not be verified, which is not the same as knowing it is revoked, so it is retryable and the session survives. Machine tokens never see this.
Example
{ "error": "forbidden", "code": "credit_limit_reached"}