Skip to content

Create a client

POST
/reseller/v1/clients
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.

Media type application/json

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
email
required

Required. Normalized (gmail dots, case) before the uniqueness check.

string format: email
password
required

Requiredauth.register rejects an empty password. The reseller sets it, and can change it later via POST /clients/{id}/password.

string format: password
phone_number
required

Required. Iranian mobile, validated with ValidIranianPhone and normalized with NormalizeIranianPhone.

string
national_code
required

Required. Validated with ValidIranianNationalCode — a real checksum, not a length check. A made-up 10-digit string is rejected.

string
name

Optional.

string
birth_date

Optional — an empty value is accepted (parseBirthDate returns "", nil). If present it must be YYYY-MM-DD.

string format: date
timezone

Optional.

string

Created

Media type application/json

An end user owned by the calling reseller.

object
id
required
integer
name
string
nullable
email
required
string format: email
phone_number
required
string
phone_verified
required

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.

boolean
phone_verified_by

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.

string
Allowed values: "" otp provider admin
national_code
string
birth_date
string format: date
nullable
active
required

false = account disabled; cannot log in.

boolean
wallet_balance_rials

Visible to the reseller. The client is never shown this, and gets 403 from every wallet endpoint.

integer format: int64
created_at
required
string format: date-time
Example
{
"phone_number": "09123456789",
"phone_verified_by": "",
"national_code": "0012345678"
}

Malformed or invalid input.

Media type application/json

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
error
required
string
code

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 from POST /services and POST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default 0, 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 until POST /domains/{domainId}/enable.
  • unknown_rule_type — 404 from the rules paths when ruleType is 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.
string
Example
{
"error": "forbidden",
"code": "credit_limit_reached"
}

Missing, malformed, expired, or revoked credential.

Media type application/json

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
error
required
string
code

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 from POST /services and POST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default 0, 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 until POST /domains/{domainId}/enable.
  • unknown_rule_type — 404 from the rules paths when ruleType is 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.
string
Example
{
"error": "forbidden",
"code": "credit_limit_reached"
}

Email, phone, or national code already registered.

Media type application/json

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
error
required
string
code

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 from POST /services and POST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default 0, 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 until POST /domains/{domainId}/enable.
  • unknown_rule_type — 404 from the rules paths when ruleType is 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.
string
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.

Media type application/json

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
error
required
string
code

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 from POST /services and POST /services/{serviceId}/change-plan. Your wallet, not the client’s: funding the purchase would take you past your overdraft (default 0, 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 until POST /domains/{domainId}/enable.
  • unknown_rule_type — 404 from the rules paths when ruleType is 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.
string
Example
{
"error": "forbidden",
"code": "credit_limit_reached"
}