Programmatic access to virtual numbers, orders, and account balance.
Recommended
⟩Start with the official SDKs
Use the TypeScript/JavaScript or Python SDK for new integrations. Both SDKs default to the public /v2 API, preserve idempotency keys across safe retries, expose typed errors, and keep the OTP lifecycle consistent.
Create an order with product_id for an exact stable tier-slot, or with catalog_product_id, optional operator_id, min_price/max_price, and an idempotency key for retry-safe routed paid calls.
catalog_product_idmax_priceIdempotency-Key
02
Use the OTP
Wait for OTP, submit it in your target app, then call finish to close the order.
waitForOtpwait_for_otpfinish
03
Resend only when needed
After resend, wait for a new code with afterCode in TypeScript or after_code in Python.
can_resendresend_available_at
import { SmscodeClient, OtpTimeoutError } from "@smscode/sdk";const client = new SmscodeClient({ token: process.env.SMSCODE_TOKEN! });let orderId: number | undefined;try { const created = await client.orders.create({ catalog_product_id: Number(process.env.SMSCODE_CATALOG_PRODUCT_ID), max_price: "0.50", quantity: 1, }); const order = created.orders[0]!; orderId = order.id; const first = await client.orders.waitForOtp(orderId, { timeoutMs: 120_000 }); console.log(first.otpCode); // Submit this code in the target app. await client.orders.finish(orderId);} catch (err) { if (err instanceof OtpTimeoutError && orderId !== undefined) { const current = await client.orders.get(orderId); if (current.can_cancel) await client.orders.cancel(orderId); } throw err;}
import osfrom smscode import OtpTimeoutError, SmscodeClientwith SmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client: created = client.orders.create( catalog_product_id=int(os.environ["SMSCODE_CATALOG_PRODUCT_ID"]), max_price="0.50", quantity=1, ) order = created.orders[0] order_id = int(order["id"]) try: first = client.orders.wait_for_otp(order_id, timeout_ms=120_000) print(first.otp_code) # Submit this code in the target app. client.orders.finish(order_id) except OtpTimeoutError: current = client.orders.get(order_id) if current["can_cancel"]: client.orders.cancel(order_id) raise
Use can_resend and resend_available_at for resend timing. Lower-level resend timestamps are internal and are not public response fields.
⟩Overview
All money fields on the /v1 API are in IDR (Indonesian Rupiah), as whole integer units — for example "price": 15000 and "balance": 500000 mean Rp 15,000 and Rp 500,000. For a USD-native projection of the same ledger, switch to the v2 API using the version toggle above.
⟩Authentication
All API requests require a Bearer token. Generate one from Account Settings in the dashboard, then include it in every request:
Authorization:Bearer YOUR_API_TOKEN
Requests without a valid token receive a 401 UNAUTHORIZED response.
⟩Base URL
All endpoint paths below are relative to:
https://api.smscode.gg/v1
⟩Response Format
Every response returns JSON with a consistent envelope. All responses include an x-request-id header for debugging.
All money fields on the /v1 API are in IDR (Indonesian Rupiah), as whole integer units — for example "price": 15000 and "balance": 500000 mean Rp 15,000 and Rp 500,000. For a USD-native projection of the same ledger, switch to the v2 API using the version toggle above.
Returns the selectable operators for a country + service. If real operators and Any stock are both available, the response includes an Any row with operator_id null; if there are no operator-specific products, the list is empty.
Creates a new virtual number order. Deducts balance automatically. Supports an optional Idempotency-Key header to prevent duplicate orders on network retries.
Request Body
Name
Type
Required
Description
product_id
integer
No
Stable exact tier-slot product ID to order directly. Provide EITHER this OR catalog_product_id, not both.
catalog_product_id
integer
No
Routed country+platform umbrella ID. The server chooses a current matching tier. Provide either this or product_id.
operator_id
integer
No
Optional operator ID from /catalog/operators. Only valid with catalog_product_id; omit for Any.
min_price
integer
No
Optional price floor. IDR integer. Only valid with catalog_product_id.
max_price
integer
No
Optional price cap. IDR integer. Only valid with catalog_product_id.
prefer_provider
string
No
Optional provider code to prefer when offers tie.
policy
string
No
Optional routing policy, only valid with catalog_product_id. Values: cheapest (default) picks the lowest-priced healthy offer; best_success ranks offers by recent delivery success first. best_success scores each provider on its share of orders that received an OTP over the trailing 30 completed days, in 10% bands, and only counts a provider once it has at least 20 orders in that window — providers below that floor or with no history are treated as neutral, so new offers are never starved (opt-in; the signal cold-starts neutral). When prefer_provider is also set, the preferred provider still ranks first.
quantity
integer
No
Number of items (1-100, default 1)
Pass an Idempotency-Key header to safely retry without creating duplicate orders. The key may contain letters, digits, hyphen and underscore (A-Z a-z 0-9 _ -), up to 128 characters; an invalid key is rejected with 422 VALIDATION_ERROR. Retrying with the same key and the same body replays the original result (including the failed_count of a partial success). A retry that reaches the provider but fails is recorded and replays that same error — use a NEW key to try again. Failures with no side effects (insufficient balance, no available offer) release the key, so you can top up and retry with the same key. Reusing a key with a different body returns 422 IDEMPOTENCY_KEY_REUSED, and a still-running request with that key returns 409 REQUEST_IN_PROGRESS. The failed_reason field on create responses is always null — it is populated only on order poll/list.
Example Request
curl -s -X POST https://api.smscode.gg/v1/orders/create \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: unique-request-id-123" \ -d '{"product_id":142,"quantity":1}'# Or route by catalog_product_id — the server picks a current tier.# product_id is the stable exact tier-slot id. catalog_product_id is the# country+platform umbrella for routed ordering. Optional min_price/max_price# bound the tier; operator_id scopes to a carrier from /catalog/operators.# Pass EITHER product_id OR catalog_product_id.curl -s -X POST https://api.smscode.gg/v1/orders/create \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: unique-request-id-124" \ -d '{"catalog_product_id":87,"min_price":12000,"max_price":20000,"operator_id":433}'
Reactivate a completed number — re-order the same number for another verification code, without renting a fresh one. Only a completed order whose number supports reactivation qualifies (check can_reactivate on the order, or preview with reactivate-options). The reactivated child is a NEW order, returned in the same shape as create; the balance is charged automatically.
Request Body
Name
Type
Required
Description
id
integer
Yes
The completed order to reactivate.
max_price
integer
No
Optional cost ceiling. IDR integer. Reactivation is refused with 422 VALIDATION_ERROR if the live cost exceeds it.
Like create, this is a money mutation — pass an Idempotency-Key header for safe retries (a create and a reactivate can never collide on one key). Reusing a key with a different body returns 422 IDEMPOTENCY_KEY_REUSED, and a still-settling request with that key returns 409 REQUEST_IN_PROGRESS. A number that cannot be reactivated returns 409 CONFLICT; too-low balance returns 409 INSUFFICIENT_BALANCE.
Preview what a reactivation would charge right now. Read-only — consumes no Idempotency-Key and creates nothing. Returns cost as an IDR integer. Available only for a completed order whose number supports reactivation.
Path Parameters
Name
Type
Required
Description
id
integer
Yes
Order ID to preview reactivation cost for (path parameter).
Update your webhook URL and/or secret. A secret is auto-generated when you set a URL for the first time. Send an empty string to clear. URL must use HTTPS.
Request Body
Name
Type
Required
Description
webhook_url
string
No
HTTPS URL to receive webhook events (empty string to clear)
webhook_secret
string
No
Shared secret for HMAC-SHA256 signature (auto-generated if omitted on first set)
Send a test event to your configured webhook URL. Returns the HTTP status code from your server. Useful for verifying your endpoint is working before going live.
Parameters
None
Example Request
curl -s -X POST https://api.smscode.gg/v1/webhook/test \ -H "Authorization: Bearer YOUR_API_TOKEN"
const res = await fetch("https://api.smscode.gg/v1/webhook/test", { method: "POST", headers: { Authorization: "Bearer YOUR_API_TOKEN" },});const data = await res.json();
Verify this signature on your server to ensure the request is authentic. Delivery is fire-and-forget with a 3-second timeout and no retries.
⟩Rate Limits
API requests are rate-limited per endpoint group. Exceeding the limit returns 429 Too Many Requests with a Retry-After header indicating how many seconds to wait.
Error responses include one of these codes in error.code:
Code
HTTP
Description
UNAUTHORIZED
401
Missing or invalid API token
FORBIDDEN
403
Access denied
NOT_FOUND
404
Resource not found (order, exchange rate, etc.)
CONFLICT
409
Duplicate request or resource conflict
INSUFFICIENT_BALANCE
409
Not enough balance to create order
VALIDATION_ERROR
422
Request parameters failed validation
RATE_LIMIT_EXCEEDED
429
Too many requests (check Retry-After header)
INTERNAL_ERROR
500
Internal server error
PROVIDER_ERROR
422
Upstream SMS provider rejected the request. On order-create failures the error may carry details: cause_counts (legacy product_id orders — a tally keyed by cause) or attempts (catalog_product_id orders — per-attempt outcomes), using the values ok, no_numbers, insufficient_balance, price_rejected, provider_unavailable, provider_account_balance, provider_error.
NO_OFFER_AVAILABLE
422
No active offer matches the requested product and policy (price cap, availability).
CANCEL_TOO_EARLY
409
Order too recent to cancel — wait 2 minutes
REQUEST_IN_PROGRESS
409
A create request with this idempotency key is still in progress
IDEMPOTENCY_KEY_REUSED
422
This idempotency key was already used with a different request body
SERVICE_UNAVAILABLE
503
Service temporarily unavailable (maintenance)
⟩Overview
All money fields on the /v2 API are USD, returned as a money object — { "amount": "0.92", "currency": "USD", "canonical_amount": 15000, "canonical_currency": "IDR" }. amount is a decimal string; canonical_amount is the exact IDR ledger value (use it for reconciliation). The USD/IDR rate applied is disclosed once per response in meta.fx. v2 is a render-time USD projection over the same IDR ledger as v1 — it never stores or transacts USD.
All money fields on the /v2 API are USD, returned as a money object — { "amount": "0.92", "currency": "USD", "canonical_amount": 15000, "canonical_currency": "IDR" }. amount is a decimal string; canonical_amount is the exact IDR ledger value (use it for reconciliation). The USD/IDR rate applied is disclosed once per response in meta.fx. v2 is a render-time USD projection over the same IDR ledger as v1 — it never stores or transacts USD.
Identical to v1 — only the base path changes (/v1 → /v2).
GET/catalog/operators
Returns the selectable operators for a country + service. If real operators and Any stock are both available, the response includes an Any row with operator_id null; if there are no operator-specific products, the list is empty.
v2: money fields are USD money objects and the response carries a single meta.fx { pair, rate, rate_as_of }. rate is whole IDR per 1 USD, so USD = canonical_amount / rate. Totals use 2 decimals; per-item prices/refunds use 4. A strictly-positive amount never rounds to 0.00. rate_as_of is the rate's RFC3339 timestamp (+00:00 form) or null when no timestamp is recorded.
v2 only: if no usable USD/IDR rate exists, money endpoints return 503 FX_RATE_UNAVAILABLE with a Retry-After header instead of a money body. v1 never returns this.
GET/catalog/exchange-rate
Returns the current USD/IDR exchange rate used for currency conversion.
Parameters
None — v2 always returns USD/IDR; the v1 ?pair parameter is ignored.
v2: returns { pair, rate, rate_as_of } (no base_currency/quote_currency, no meta wrapper — the rate is the data). ?pair is ignored — v2 always returns USD/IDR (v1 honors ?pair). Returns 503 FX_RATE_UNAVAILABLE if no usable rate exists.
v2: money fields are USD money objects and the response carries a single meta.fx { pair, rate, rate_as_of }. rate is whole IDR per 1 USD, so USD = canonical_amount / rate. Totals use 2 decimals; per-item prices/refunds use 4. A strictly-positive amount never rounds to 0.00. rate_as_of is the rate's RFC3339 timestamp (+00:00 form) or null when no timestamp is recorded.
v2 only: if no usable USD/IDR rate exists, money endpoints return 503 FX_RATE_UNAVAILABLE with a Retry-After header instead of a money body. v1 never returns this.
GET/orders
Returns a list of the authenticated user's orders, sorted by most recent. Supports filtering by status and pagination via offset.
Query Parameters
Name
Type
Required
Description
limit
integer
No
Max results (1-100, default 20)
offset
integer
No
Number of results to skip (default 0)
status
string
No
Filter by status: ACTIVE, OTP_RECEIVED, COMPLETED, CANCELED, EXPIRED (case-insensitive)
v2: money fields are USD money objects and the response carries a single meta.fx { pair, rate, rate_as_of }. rate is whole IDR per 1 USD, so USD = canonical_amount / rate. Totals use 2 decimals; per-item prices/refunds use 4. A strictly-positive amount never rounds to 0.00. rate_as_of is the rate's RFC3339 timestamp (+00:00 form) or null when no timestamp is recorded.
v2 only: if no usable USD/IDR rate exists, money endpoints return 503 FX_RATE_UNAVAILABLE with a Retry-After header instead of a money body. v1 never returns this.
GET/orders/{id}
Returns a single order by ID. Only returns orders owned by the authenticated user.
v2: money fields are USD money objects and the response carries a single meta.fx { pair, rate, rate_as_of }. rate is whole IDR per 1 USD, so USD = canonical_amount / rate. Totals use 2 decimals; per-item prices/refunds use 4. A strictly-positive amount never rounds to 0.00. rate_as_of is the rate's RFC3339 timestamp (+00:00 form) or null when no timestamp is recorded.
v2 only: if no usable USD/IDR rate exists, money endpoints return 503 FX_RATE_UNAVAILABLE with a Retry-After header instead of a money body. v1 never returns this.
GET/orders/active
List all currently active orders (ACTIVE + OTP_RECEIVED). Use this to poll for OTP status updates.
v2: this endpoint is not money-bearing — it returns no amount and no meta.fx (same shape as v1, under /v2).
POST/orders/create
Creates a new virtual number order. Deducts balance automatically. Supports an optional Idempotency-Key header to prevent duplicate orders on network retries.
Request Body
Name
Type
Required
Description
product_id
integer
No
Stable exact tier-slot product ID to order directly. Provide EITHER this OR catalog_product_id, not both.
catalog_product_id
integer
No
Routed country+platform umbrella ID. The server chooses a current matching tier. Provide either this or product_id.
operator_id
integer
No
Optional operator ID from /catalog/operators. Only valid with catalog_product_id; omit for Any.
min_price
string
No
Optional price floor. USD decimal string (e.g. "0.30"). Only valid with catalog_product_id.
max_price
string
No
Optional price cap. USD decimal string (e.g. "0.50"). Only valid with catalog_product_id.
prefer_provider
string
No
Optional provider code to prefer when offers tie.
policy
string
No
Optional routing policy, only valid with catalog_product_id. Values: cheapest (default) picks the lowest-priced healthy offer; best_success ranks offers by recent delivery success first. best_success scores each provider on its share of orders that received an OTP over the trailing 30 completed days, in 10% bands, and only counts a provider once it has at least 20 orders in that window — providers below that floor or with no history are treated as neutral, so new offers are never starved (opt-in; the signal cold-starts neutral). When prefer_provider is also set, the preferred provider still ranks first.
quantity
integer
No
Number of items (1-100, default 1)
Pass an Idempotency-Key header to safely retry without creating duplicate orders. The key may contain letters, digits, hyphen and underscore (A-Z a-z 0-9 _ -), up to 128 characters; an invalid key is rejected with 422 VALIDATION_ERROR. Retrying with the same key and the same body replays the original result (including the failed_count of a partial success). A retry that reaches the provider but fails is recorded and replays that same error — use a NEW key to try again. Failures with no side effects (insufficient balance, no available offer) release the key, so you can top up and retry with the same key. Reusing a key with a different body returns 422 IDEMPOTENCY_KEY_REUSED, and a still-running request with that key returns 409 REQUEST_IN_PROGRESS. The failed_reason field on create responses is always null — it is populated only on order poll/list.
v2: money fields are USD money objects and the response carries a single meta.fx { pair, rate, rate_as_of }. rate is whole IDR per 1 USD, so USD = canonical_amount / rate. Totals use 2 decimals; per-item prices/refunds use 4. A strictly-positive amount never rounds to 0.00. rate_as_of is the rate's RFC3339 timestamp (+00:00 form) or null when no timestamp is recorded.
v2 only: if no usable USD/IDR rate exists, money endpoints return 503 FX_RATE_UNAVAILABLE with a Retry-After header instead of a money body. v1 never returns this.
POST/orders/cancel
Cancel an active order. The rental cost is refunded to your account balance.
v2: money fields are USD money objects and the response carries a single meta.fx { pair, rate, rate_as_of }. rate is whole IDR per 1 USD, so USD = canonical_amount / rate. Totals use 2 decimals; per-item prices/refunds use 4. A strictly-positive amount never rounds to 0.00. rate_as_of is the rate's RFC3339 timestamp (+00:00 form) or null when no timestamp is recorded.
v2 only: if no usable USD/IDR rate exists, money endpoints return 503 FX_RATE_UNAVAILABLE with a Retry-After header instead of a money body. v1 never returns this.
POST/orders/finish
Mark an order as completed after receiving the OTP. This releases the number immediately instead of waiting for expiry.
Identical to v1 — only the base path changes (/v1 → /v2).
POST/orders/reactivate
Reactivate a completed number — re-order the same number for another verification code, without renting a fresh one. Only a completed order whose number supports reactivation qualifies (check can_reactivate on the order, or preview with reactivate-options). The reactivated child is a NEW order, returned in the same shape as create; the balance is charged automatically.
Request Body
Name
Type
Required
Description
id
integer
Yes
The completed order to reactivate.
max_price
string
No
Optional cost ceiling. USD decimal string (e.g. "0.50"). Reactivation is refused with 422 VALIDATION_ERROR if the live cost exceeds it.
Like create, this is a money mutation — pass an Idempotency-Key header for safe retries (a create and a reactivate can never collide on one key). Reusing a key with a different body returns 422 IDEMPOTENCY_KEY_REUSED, and a still-settling request with that key returns 409 REQUEST_IN_PROGRESS. A number that cannot be reactivated returns 409 CONFLICT; too-low balance returns 409 INSUFFICIENT_BALANCE.
v2: money fields are USD money objects and the response carries a single meta.fx { pair, rate, rate_as_of }. rate is whole IDR per 1 USD, so USD = canonical_amount / rate. Totals use 2 decimals; per-item prices/refunds use 4. A strictly-positive amount never rounds to 0.00. rate_as_of is the rate's RFC3339 timestamp (+00:00 form) or null when no timestamp is recorded.
v2 only: if no usable USD/IDR rate exists, money endpoints return 503 FX_RATE_UNAVAILABLE with a Retry-After header instead of a money body. v1 never returns this.
GET/orders/{id}/reactivate-options
Preview what a reactivation would charge right now. Read-only — consumes no Idempotency-Key and creates nothing. Returns cost as a USD money object with an FX receipt. Available only for a completed order whose number supports reactivation.
Path Parameters
Name
Type
Required
Description
id
integer
Yes
Order ID to preview reactivation cost for (path parameter).
v2: money fields are USD money objects and the response carries a single meta.fx { pair, rate, rate_as_of }. rate is whole IDR per 1 USD, so USD = canonical_amount / rate. Totals use 2 decimals; per-item prices/refunds use 4. A strictly-positive amount never rounds to 0.00. rate_as_of is the rate's RFC3339 timestamp (+00:00 form) or null when no timestamp is recorded.
v2 only: if no usable USD/IDR rate exists, money endpoints return 503 FX_RATE_UNAVAILABLE with a Retry-After header instead of a money body. v1 never returns this.
GET/webhook
Returns your current webhook notification configuration.
Identical to v1 — only the base path changes (/v1 → /v2).
PATCH/webhook
Update your webhook URL and/or secret. A secret is auto-generated when you set a URL for the first time. Send an empty string to clear. URL must use HTTPS.
Request Body
Name
Type
Required
Description
webhook_url
string
No
HTTPS URL to receive webhook events (empty string to clear)
webhook_secret
string
No
Shared secret for HMAC-SHA256 signature (auto-generated if omitted on first set)
Identical to v1 — only the base path changes (/v1 → /v2).
POST/webhook/test
Send a test event to your configured webhook URL. Returns the HTTP status code from your server. Useful for verifying your endpoint is working before going live.
Parameters
None
Example Request
curl -s -X POST https://api.smscode.gg/v2/webhook/test \ -H "Authorization: Bearer YOUR_API_TOKEN"
const res = await fetch("https://api.smscode.gg/v2/webhook/test", { method: "POST", headers: { Authorization: "Bearer YOUR_API_TOKEN" },});const data = await res.json();
Verify this signature on your server to ensure the request is authentic. Delivery is fire-and-forget with a 3-second timeout and no retries.
⟩Rate Limits
API requests are rate-limited per endpoint group. Exceeding the limit returns 429 Too Many Requests with a Retry-After header indicating how many seconds to wait.
Error responses include one of these codes in error.code:
Code
HTTP
Description
UNAUTHORIZED
401
Missing or invalid API token
FORBIDDEN
403
Access denied
NOT_FOUND
404
Resource not found (order, exchange rate, etc.)
CONFLICT
409
Duplicate request or resource conflict
INSUFFICIENT_BALANCE
409
Not enough balance to create order
VALIDATION_ERROR
422
Request parameters failed validation
RATE_LIMIT_EXCEEDED
429
Too many requests (check Retry-After header)
INTERNAL_ERROR
500
Internal server error
PROVIDER_ERROR
422
Upstream SMS provider rejected the request. On order-create failures the error may carry details: cause_counts (legacy product_id orders — a tally keyed by cause) or attempts (catalog_product_id orders — per-attempt outcomes), using the values ok, no_numbers, insufficient_balance, price_rejected, provider_unavailable, provider_account_balance, provider_error.
NO_OFFER_AVAILABLE
422
No active offer matches the requested product and policy (price cap, availability).
CANCEL_TOO_EARLY
409
Order too recent to cancel — wait 2 minutes
REQUEST_IN_PROGRESS
409
A create request with this idempotency key is still in progress
IDEMPOTENCY_KEY_REUSED
422
This idempotency key was already used with a different request body
SERVICE_UNAVAILABLE
503
Service temporarily unavailable (maintenance)
FX_RATE_UNAVAILABLE
503
USD/IDR exchange rate unavailable (v2 money endpoints) — returns 503 with a Retry-After header.
v1 → v2
⟩Migrating v1 → v2
v1 serves IDR; v2 serves USD. Both versions coexist permanently — there is no sunset. Pick one version per integration; do not mix base paths. v2 is identical to v1 except how money is represented.
product_id is the stable SMSCode tier-slot ID. Store it when you want to order that exact tier; its price and availability can change in place. catalog_product_id is the stable country+platform umbrella for routed ordering; use it with optional operator_id, min_price, max_price, prefer_provider, and policy when you want the server to choose a current matching tier.