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();
Configure a webhook URL to receive real-time push notifications for order events instead of polling. This is the recommended approach for bot scripts.
Events
Event
Trigger
order.otp_received
A new SMS was delivered; the parsed code may be null
order.completed
Order marked as completed (manually or by expiry)
order.expired
Order expired before any SMS delivery (balance refunded)
order.canceled
Order canceled by user (balance refunded)
Each novel SMS emits this event. otp_code may be null while otp_message is present. Multiple SMS events can arrive out of order; use sms_revision to ignore an older aggregate pair.
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();
Identical to v1 — only the base path changes (/v1 → /v2).
⟩Webhook Notifications
Configure a webhook URL to receive real-time push notifications for order events instead of polling. This is the recommended approach for bot scripts.
Events
Event
Trigger
order.otp_received
A new SMS was delivered; the parsed code may be null
order.completed
Order marked as completed (manually or by expiry)
order.expired
Order expired before any SMS delivery (balance refunded)
order.canceled
Order canceled by user (balance refunded)
Each novel SMS emits this event. otp_code may be null while otp_message is present. Multiple SMS events can arrive out of order; use sms_revision to ignore an older aggregate pair.
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.