Create a client
const url = 'https://api.nsin.cloud/reseller/v1/clients';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"email":"hello@example.com","password":"example","phone_number":"09123456789","national_code":"0012345678","name":"example","birth_date":"2026-04-15","timezone":"Asia/Tehran"}'};
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/clients \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "email": "hello@example.com", "password": "example", "phone_number": "09123456789", "national_code": "0012345678", "name": "example", "birth_date": "2026-04-15", "timezone": "Asia/Tehran" }'Creates an end user owned by the calling reseller (users.reseller_id = <caller>).
No OTP is required or consumed. A reseller provisions a client from
his own system, where there is nobody to type a code. The client is
created with phone_verified = true and
phone_verified_by = "provider": you entered the number and you vouch
for it, so your customer is never challenged to re-prove it.
He is upgraded to phone_verified_by = "otp" automatically the first
time he happens to sign in with a one-time code — that is stronger
evidence, and it is recorded as such.
Note that a client detached from your account by an NSIN administrator
loses a provider verification and must prove the number himself, since
the vouching that backed it no longer applies.
This does not weaken the public POST /auth/register, which still
requires an OTP. The shared creation logic is factored out; the OTP gate
stays on the public route only.
Phone and national code are validated with the same validators the panel
uses (ValidIranianPhone, ValidIranianNationalCode) — a reseller must
supply real identity data, not placeholders.
Authorizations
Section titled “Authorizations ”Request Body required
Section titled “Request Body required ”These are exactly the fields the public POST /auth/register requires
and validates ([auth.go:251-267]), minus otp_code. The reseller must
supply real identity data — the validators are checksum-level, not
length checks, so placeholders are rejected.
The only difference from public registration: no otp_code. There is
nobody at the reseller’s end to type one.
object
Required. Normalized (gmail dots, case) before the uniqueness check.
Required — auth.register rejects an empty password. The reseller
sets it, and can change it later via POST /clients/{id}/password.
Required. Iranian mobile, validated with ValidIranianPhone and
normalized with NormalizeIranianPhone.
Required. Validated with ValidIranianNationalCode — a real
checksum, not a length check. A made-up 10-digit string is rejected.
Optional.
Optional — an empty value is accepted (parseBirthDate returns
"", nil). If present it must be YYYY-MM-DD.
Optional.
Responses
Section titled “ Responses ”Created
An end user owned by the calling reseller.
object
Whether the panel will accept this number without challenging it.
true for a freshly provisioned client — you vouched for it. See
phone_verified_by for the basis.
How the number came to be verified.
otp— the client consumed a one-time code sent to this exact number. Proof that somebody holds the handset.provider— you entered it and vouched for it. Trust in you, not proof of the handset.admin— set by NSIN staff.""— not verified.
Changing a client’s phone via PATCH /clients/{clientId} re-bases
this on provider: an OTP consumed against the old number proves
nothing about the new one, but your vouching still applies.
false = account disabled; cannot log in.
Visible to the reseller. The client is never shown this, and gets 403 from every wallet endpoint.
Example
{ "phone_number": "09123456789", "phone_verified_by": "", "national_code": "0012345678"}Malformed or invalid input.
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"}Email, phone, or national code already registered.
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"}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"}