# SMSCode — Complete API Documentation > SMSCode is a virtual number service for SMS verification. This document is the plain-text API reference for LLMs and automation tools. The canonical human-readable API documentation is https://smscode.gg/docs. --- ## Version Model SMSCode exposes two REST API versions. Pick one version per integration and do not mix base paths in the same client. | Version | Base URL | Money model | Intended use | |---------|----------|-------------|--------------| | v1 | `https://api.smscode.gg/v1` | IDR integer amounts | Existing IDR-native integrations | | v2 | `https://api.smscode.gg/v2` | USD money objects with exact IDR canonical amounts | USD-native integrations and resellers | The ledger remains IDR. v2 is a render-time USD projection over the same IDR ledger used by v1. v2 does not store or transact a separate USD wallet. --- ## Authentication All API requests require a Bearer token in the `Authorization` header. ```http Authorization: Bearer YOUR_API_TOKEN ``` Generate an API token from Account Settings in the SMSCode dashboard. --- ## Official SDKs Prefer an official SDK for new integrations. Both SDKs use the public API, default to `/v2`, expose typed errors, and preserve idempotency keys for safe paid-call retries. ```bash bun add @smscode/sdk # TypeScript/JavaScript pip install smscode # Python ``` Minimal TypeScript: ```ts import { SmscodeClient } from "@smscode/sdk"; const client = new SmscodeClient({ token: process.env.SMSCODE_TOKEN! }); const { orders } = await client.orders.create({ catalog_product_id: Number(process.env.SMSCODE_CATALOG_PRODUCT_ID), max_price: "0.50", quantity: 1, }); const orderId = orders[0]!.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); ``` Minimal Python: ```python import os from smscode import SmscodeClient with 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_id = int(created.orders[0]["id"]) 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) ``` For resend flows, use `afterCode` in TypeScript or `after_code` in Python so polling ignores the preserved old OTP. Use `can_resend` and `resend_available_at` to decide when the action is available. The wait helpers resolve only on a classified code. A delivered SMS can have `otp_code=null` with a non-null `otp_message` containing the exact SMS text after U+0000 removal; read the active-order status or consume `order.otp_received` webhooks for that state. A helper timeout is not proof that cancellation is allowed: re-read the order and gate cancellation on `can_cancel`. REST examples below are the public reference for custom runtimes. --- ## Response Envelope All endpoints return JSON with the same envelope. Success: ```json { "success": true, "data": { "...": "..." } } ``` Failure: ```json { "success": false, "error": { "code": "ERROR_CODE", "message": "Human-readable message" } } ``` Responses include an `x-request-id` header for support/debugging. --- ## Money Contract ### v1: IDR Integers All v1 money fields are integer Indonesian Rupiah (IDR). Examples: ```json { "price": 15000 } ``` ```json { "currency": "IDR", "balance": 500000 } ``` ### v2: USD Money Objects All v2 money fields are USD and are returned as money objects: ```json { "amount": "0.9231", "currency": "USD", "canonical_amount": 15000, "canonical_currency": "IDR" } ``` Rules: - `amount` is a decimal string. - `currency` is always `"USD"`. - `canonical_amount` is the exact IDR ledger amount. - `canonical_currency` is always `"IDR"`. - Use `canonical_amount` for ledger reconciliation. - USD values are projections from IDR using the response-level `meta.fx.rate`. - Totals use 2 decimal places. Per-item prices and refunds use 4 decimal places. - A strictly positive canonical amount never serializes to `"0.00"` or `"0.0000"`. Every v2 response containing a money object includes exactly one response-level FX object: ```json { "meta": { "fx": { "pair": "USD/IDR", "rate": 16250, "rate_as_of": "2026-05-27T08:00:00+00:00" } } } ``` `rate` is whole IDR per 1 USD, so `USD = canonical_amount / rate`. `rate_as_of` is an RFC3339 timestamp or `null` if no timestamp was recorded. If v2 cannot build a USD projection because no usable USD/IDR rate exists, money-bearing endpoints return HTTP 503 with `FX_RATE_UNAVAILABLE` and a `Retry-After` header. v1 never returns `FX_RATE_UNAVAILABLE`. ```json { "success": false, "error": { "code": "FX_RATE_UNAVAILABLE", "message": "USD/IDR exchange rate is unavailable" } } ``` --- ## Endpoint Inventory All paths below exist under both `/v1` and `/v2` unless noted. | Method | Path | Description | v2 behavior | |--------|------|-------------|-------------| | GET | `/catalog/countries` | List available countries | Same shape as v1 | | GET | `/catalog/services` | List available services/platforms | Same shape as v1 | | GET | `/catalog/operators` | List operators (carriers) with stock + synthesized `any` | Same shape as v1 | | GET | `/catalog/products` | List available products with pricing (carry `operator_id`/`operator_name`; `?operator_id=` filter) | `price` is a USD money object + `meta.fx` | | GET | `/catalog/exchange-rate` | Current USD/IDR exchange rate | Returns `{ pair, rate, rate_as_of }`; ignores `?pair` | | GET | `/balance` | Current account balance | `balance` is a USD money object + `meta.fx` | | GET | `/orders` | List order history | `amount` is a USD money object + `meta.fx` | | GET | `/orders/active` | List active orders for OTP polling | Same shape as v1; no money and no `meta.fx` | | GET | `/orders/{id}` | Get one order | `amount` is a USD money object + `meta.fx` | | POST | `/orders/create` | Create a virtual-number order | Same response shape as v1 | | POST | `/orders/cancel` | Cancel an active order and refund | `refund_amount` and `new_balance` are USD money objects + `meta.fx` | | POST | `/orders/finish` | Mark an order complete | Same shape as v1 | | POST | `/orders/resend` | Request SMS resend | Same shape as v1 | | POST | `/orders/reactivate` | Reactivate a completed number (re-order the same number) | `amount` is a USD money object; `max_price` is a USD decimal string + `meta.fx` | | GET | `/orders/{id}/reactivate-options` | Preview the reactivation cost | `cost` is a USD money object + `meta.fx` | | GET | `/webhook` | Get webhook configuration | Same shape as v1 | | PATCH | `/webhook` | Update webhook URL/secret | Same shape as v1 | | POST | `/webhook/test` | Send a test webhook event | Same shape as v1 | --- ## Catalog ### GET /catalog/countries Returns all available countries. Request: ```bash curl -s https://api.smscode.gg/v1/catalog/countries \ -H "Authorization: Bearer YOUR_API_TOKEN" ``` Response: ```json { "success": true, "data": [ { "id": 7, "code": "ID", "name": "Indonesia", "dial_code": "+62", "emoji": "🇮🇩", "active": true } ] } ``` ### GET /catalog/services Returns available services/platforms. Optionally filter by `country_id`. Query parameters: | Name | Type | Required | Description | |------|------|----------|-------------| | `country_id` | integer | No | Filter services available for this country | Request: ```bash curl -s "https://api.smscode.gg/v1/catalog/services?country_id=7" \ -H "Authorization: Bearer YOUR_API_TOKEN" ``` Response: ```json { "success": true, "data": [ { "id": 3, "code": "wa", "name": "WhatsApp", "active": true } ] } ``` ### GET /catalog/operators Returns the operators (carriers) with in-stock tiers for a `(country, service)` — e.g. Telkomsel, XL. Prepends a synthesized `any` entry (null `operator_id`) only when the carrier-agnostic tiers also have stock. An empty array means there is no operator choice (order the `any` tiers directly). Pass `operator_id` to `/catalog/products` to filter, and to `/orders/create` (with `catalog_product_id`) to route the order to that operator. Query parameters: | Name | Type | Required | Description | |------|------|----------|-------------| | `country_id` | integer | Yes | Country to list operators for | | `platform_id` | integer | Yes | Service/platform to list operators for | Request: ```bash curl -s "https://api.smscode.gg/v1/catalog/operators?country_id=7&platform_id=3" \ -H "Authorization: Bearer YOUR_API_TOKEN" ``` Response: ```json { "success": true, "data": [ { "operator_id": null, "code": "any", "name": null, "local_name": null }, { "operator_id": 42, "code": "telkomsel", "name": "Telkomsel", "local_name": "Telkomsel" } ] } ``` ### GET /catalog/products Returns a paginated list of available products. Filter by country and/or platform. Query parameters: | Name | Type | Required | Description | |------|------|----------|-------------| | `country_id` | integer | No | Filter by country ID | | `platform_id` | integer | No | Filter by platform/service ID | | `sort` | string | No | `price_asc` default, `price_desc`, `available_asc`, `available_desc`, `name_asc`, `name_desc` | | `limit` | integer | No | Results per page, 1-10000, default 1000 | | `page` | integer | No | Page number, minimum 1 | v1 response: ```json { "success": true, "data": [ { "id": 142, "name": "WhatsApp Indonesia", "country_id": 7, "platform_id": 3, "available": 42, "price": 15000, "active": true } ], "meta": { "page": 1, "limit": 10, "count": 1 } } ``` v2 response: ```json { "success": true, "data": [ { "id": 142, "name": "WhatsApp Indonesia", "country_id": 7, "platform_id": 3, "available": 42, "price": { "amount": "0.9231", "currency": "USD", "canonical_amount": 15000, "canonical_currency": "IDR" }, "active": true } ], "meta": { "page": 1, "limit": 10, "count": 1, "fx": { "pair": "USD/IDR", "rate": 16250, "rate_as_of": "2026-05-27T08:00:00+00:00" } } } ``` ### GET /catalog/exchange-rate Returns the current USD/IDR exchange rate. v1: - Optional query parameter: `pair`, default `USD/IDR`. - Response data includes `pair`, `base_currency`, `quote_currency`, and `rate`. ```json { "success": true, "data": { "pair": "USD/IDR", "base_currency": "USD", "quote_currency": "IDR", "rate": 16250 } } ``` v2: - Ignores `?pair`; always returns USD/IDR. - Response data includes `pair`, `rate`, and `rate_as_of`. - No money object and no `meta.fx`; the FX object is the endpoint's data. - Returns `503 FX_RATE_UNAVAILABLE` if no usable rate exists. ```json { "success": true, "data": { "pair": "USD/IDR", "rate": 16250, "rate_as_of": "2026-05-27T08:00:00+00:00" } } ``` --- ## Balance ### GET /balance Returns the authenticated user's account balance. v1 response: ```json { "success": true, "data": { "currency": "IDR", "balance": 500000 } } ``` v2 response: ```json { "success": true, "data": { "balance": { "amount": "30.77", "currency": "USD", "canonical_amount": 500000, "canonical_currency": "IDR" } }, "meta": { "fx": { "pair": "USD/IDR", "rate": 16250, "rate_as_of": "2026-05-27T08:00:00+00:00" } } } ``` --- ## Orders ### GET /orders Returns the authenticated user's orders sorted by most recent. Query parameters: | Name | Type | Required | Description | |------|------|----------|-------------| | `limit` | integer | No | Max rows to return | | `offset` | integer | No | Rows to skip | | `status` | string | No | Filter by order status | v1 order money fields are IDR integers. v2 order money fields are USD money objects plus `meta.fx`. ### GET /orders/{id} Returns one order by ID. Path parameters: | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | integer | Yes | Order ID | v2 example: ```json { "success": true, "data": { "id": 1001, "status": "OTP_RECEIVED", "created_at": "2026-02-25T10:00:00+00:00", "product_id": 142, "phone_number": "+6281234567890", "amount": { "amount": "0.9231", "currency": "USD", "canonical_amount": 15000, "canonical_currency": "IDR" }, "otp_code": "123456", "otp_received_at": "2026-02-25T10:05:00+00:00", "expires_at": "2026-02-25T10:20:00+00:00", "canceled_at": null, "failed_reason": null }, "meta": { "fx": { "pair": "USD/IDR", "rate": 16250, "rate_as_of": "2026-05-27T08:00:00+00:00" } } } ``` ### GET /orders/active Lists active orders for OTP polling. Active orders contain no money field and v2 does not include `meta.fx`. Polling guidance: - Poll every 3-5 seconds after creating an order. - When `status` becomes `OTP_RECEIVED`, inspect both `otp_code` and `otp_message`. - `otp_code` is nullable when no code can be classified; `otp_message` still contains the exact SMS body after U+0000 removal. - Any SMS delivery closes refund eligibility, so use the server `can_cancel` capability instead of inferring from `otp_code`. - Orders expire automatically at `expires_at`. ### POST /orders/create Creates a new virtual-number order. Request body: | Name | Type | Required | Description | |------|------|----------|-------------| | `catalog_product_id` | integer | Yes | Stable catalog product ID to order | | `operator_id` | integer | No | Route to one operator (carrier), from `/catalog/operators`; omit for `any`. Valid only with `catalog_product_id`. | | `max_price` | string | No | v2 USD decimal string price cap (ceiling), e.g. `"0.50"`. Valid only with `catalog_product_id`. | | `min_price` | string | No | v2 USD decimal string price floor, e.g. `"0.30"`. Valid only with `catalog_product_id`. | | `quantity` | integer | No | Number of orders to create | Use an `Idempotency-Key` header for safe retries. ```bash curl -s -X POST https://api.smscode.gg/v2/orders/create \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: unique-request-id-123" \ -d '{"catalog_product_id":87,"max_price":"0.50","quantity":1}' ``` Legacy v1 integrations may order a specific tier directly with `product_id` and IDR integer money fields. The `product_id` is the stable SMSCode tier-slot identifier — stable across provider price and stock changes (`price` and stock are mutable and update in place), and never silently repointed to a different tier. The v2 `catalog_product_id` flow instead orders the best current offer for the stable country-and-platform umbrella; prefer it for new integrations. Response shape is the same in v1 and v2: ```json { "success": true, "data": { "orders": [ { "id": 1002, "status": "ACTIVE", "phone_number": "+6281234567891", "otp_code": null, "otp_received_at": null, "expires_at": "2026-02-25T10:50:00+00:00", "failed_reason": null } ], "failed_count": 0 } } ``` ### POST /orders/cancel Cancels an active order and refunds the balance. Request body: | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | integer | Yes | Order ID to cancel | v1 response uses IDR integers: ```json { "success": true, "data": { "order_id": 1001, "status": "CANCELED", "refund_amount": 15000, "new_balance": 515000 } } ``` v2 response uses USD money objects: ```json { "success": true, "data": { "order_id": 1001, "status": "CANCELED", "refund_amount": { "amount": "0.9231", "currency": "USD", "canonical_amount": 15000, "canonical_currency": "IDR" }, "new_balance": { "amount": "31.69", "currency": "USD", "canonical_amount": 515000, "canonical_currency": "IDR" } }, "meta": { "fx": { "pair": "USD/IDR", "rate": 16250, "rate_as_of": "2026-05-27T08:00:00+00:00" } } } ``` Only active orders without any SMS delivery can be canceled. Expired orders without an SMS are refunded automatically. A delivered SMS is billable even when no OTP code is detected. ### POST /orders/finish Marks an active order complete. Request body: `{ "id": 1001 }`. v1 and v2 response shapes are identical. ### POST /orders/resend Requests SMS resend for an active order. Request body: `{ "id": 1001 }`. v1 and v2 response shapes are identical. ### POST /orders/reactivate Reactivates a completed number — re-orders 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 (server-authoritative) or preview with `GET /orders/{id}/reactivate-options` first. The reactivated child is a NEW order returned in the SAME shape as `/orders/create`. Request body: | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | integer | Yes | The completed parent order to reactivate | | `max_price` | integer (v1) / string (v2) | No | Cost ceiling: v1 IDR integer, v2 USD decimal string (e.g. `"0.50"`). Reactivation is refused with `422 VALIDATION_ERROR` if the live cost exceeds it. | Like `/orders/create`, this is a money mutation — send 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`; 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`. ```bash curl -s -X POST https://api.smscode.gg/v2/orders/reactivate \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: unique-request-id-901" \ -d '{"id":90210,"max_price":"0.50"}' ``` v1 response uses an IDR integer `amount`; v2 projects it to a USD money object with an `meta.fx` receipt: ```json { "success": true, "data": { "orders": [ { "id": 90244, "status": "ACTIVE", "phone_number": "+6281234567890", "otp_code": null, "otp_received_at": null, "expires_at": "2026-05-11T10:20:00+00:00", "failed_reason": null, "amount": { "amount": "46.8750", "currency": "USD", "canonical_amount": 750000, "canonical_currency": "IDR" } } ], "failed_count": 0 }, "meta": { "fx": { "pair": "USD/IDR", "rate": 16000, "rate_as_of": "2026-05-11T09:00:00+00:00" } } } ``` ### GET /orders/{id}/reactivate-options Read-only preview of what a reactivation would charge right now. Consumes NO idempotency key and writes nothing. v1 returns `{ "cost": }`; v2 returns `cost` as a USD money object with an `meta.fx` receipt: ```json { "success": true, "data": { "cost": { "amount": "46.8750", "currency": "USD", "canonical_amount": 750000, "canonical_currency": "IDR" } }, "meta": { "fx": { "pair": "USD/IDR", "rate": 16000, "rate_as_of": "2026-05-11T09:00:00+00:00" } } } ``` --- ## Webhooks ### GET /webhook Gets the current webhook configuration. ```json { "success": true, "data": { "webhook_url": "https://example.com/webhook", "webhook_secret": "a1b2c3d4e5f6..." } } ``` ### PATCH /webhook Updates webhook URL and/or secret. Request body: | Name | Type | Required | Description | |------|------|----------|-------------| | `webhook_url` | string | No | HTTPS URL to receive webhook events | | `webhook_secret` | string | No | Secret for verifying webhook signatures | ### POST /webhook/test Sends a test webhook event to the configured URL. Webhook deliveries include: ```http X-Webhook-Signature: sha256={HMAC-SHA256(body, webhook_secret)} ``` Verify the signature before trusting webhook payloads. `order.otp_received` is emitted for every novel SMS. Its `otp_code` is nullable and `otp_message` carries the exact message text after U+0000 removal. Rapid deliveries are unordered, but each event contains a self-consistent aggregate snapshot. A retained code may come from an earlier SMS than the latest message; reject older aggregate pairs by `sms_revision` and do not assume both fields coexisted in one provider message. --- ## Error Codes | Code | HTTP | Description | |------|------|-------------| | `UNAUTHORIZED` | 401 | Missing or invalid API token | | `FORBIDDEN` | 403 | Access denied | | `NOT_FOUND` | 404 | Resource not found | | `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` | | `INTERNAL_ERROR` | 500 | Internal server error | | `PROVIDER_ERROR` | 422 | Upstream SMS provider rejected the request | | `CANCEL_TOO_EARLY` | 409 | Order too recent to cancel | | `SERVICE_UNAVAILABLE` | 503 | Service temporarily unavailable | | `FX_RATE_UNAVAILABLE` | 503 | v2 money projection cannot be built because USD/IDR rate is unavailable | --- ## Rate Limits | Endpoint group | Limit | Window | |----------------|-------|--------| | Catalog: countries, services, products, exchange-rate | 5,000 requests | 60 seconds | | Balance | 600 requests | 60 seconds | | Order reads: list, get, active | 5,000 requests | 60 seconds | | Order create | 3,000 requests | 60 seconds | | Order cancel | 1,000 requests | 60 seconds | | Order actions: finish, resend | 1,000 requests | 60 seconds | | Webhook config: get, update | 600 requests | 60 seconds | | Webhook test | 10 requests | 60 seconds | If rate-limited, back off and retry after the `Retry-After` header. --- ## Migrating from v1 to v2 1. Change the base path from `/v1` to `/v2`. 2. Parse money fields as objects, not numbers. 3. Read `amount` as a decimal string and `currency` as `"USD"`. 4. Use `canonical_amount` for exact IDR ledger reconciliation. 5. Read a single response-level `meta.fx` for the conversion rate on money-bearing responses. 6. Handle `503 FX_RATE_UNAVAILABLE` and retry after `Retry-After`. v1 and v2 coexist permanently. There is no forced sunset. --- ## Integration Examples Prefer an official SDK for new integrations: ```bash bun add @smscode/sdk # TypeScript/JavaScript pip install smscode # Python ``` ### Python SDK ```python import os from smscode import OtpTimeoutError, OrderTerminalError, SmscodeClient client = SmscodeClient(token=os.environ["SMSCODE_TOKEN"]) with client: created = client.orders.create( catalog_product_id=int(os.environ["SMSCODE_CATALOG_PRODUCT_ID"]), max_price="0.50", # /v2 USD decimal string quantity=1, ) order_id = int(created.orders[0]["id"]) phone = created.orders[0]["phone_number"] print(f"Use {phone} for verification") try: otp = client.orders.wait_for_otp(order_id, timeout_ms=120_000) print(f"OTP: {otp.otp_code}") # Submit the OTP in your target app, then FINISH the order. Once any SMS # arrives, cancel/refund is closed. client.orders.finish(order_id) except (OtpTimeoutError, OrderTerminalError): current = client.orders.get(order_id) if current["can_cancel"]: client.orders.cancel(order_id) ``` For resend flows, preserve the previous code so polling waits for a new one: ```python first = client.orders.wait_for_otp(order_id) client.orders.resend(order_id) second = client.orders.wait_for_otp(order_id, after_code=first.otp_code) ``` ### TypeScript/JavaScript SDK ```ts import { SmscodeClient, OtpTimeoutError, OrderTerminalError } from "@smscode/sdk"; const client = new SmscodeClient({ token: process.env.SMSCODE_TOKEN! }); const { orders, idempotencyKey } = await client.orders.create({ catalog_product_id: Number(process.env.SMSCODE_CATALOG_PRODUCT_ID), max_price: "0.50", quantity: 1, }); const orderId = orders[0]!.id; try { const { otpCode } = await client.orders.waitForOtp(orderId, { timeoutMs: 120_000 }); console.log("OTP:", otpCode); // Submit the OTP in your target app, then FINISH the order. await client.orders.finish(orderId); } catch (err) { if (err instanceof OtpTimeoutError || err instanceof OrderTerminalError) { const current = await client.orders.get(orderId); if (current.can_cancel) await client.orders.cancel(orderId); } else { throw err; } } // Reuse `idempotencyKey` if you retry this exact create after a failure. ``` --- ## Support - Email: support@smscode.gg - Telegram: https://t.me/smscode_gg - Contact: https://smscode.gg/contact ## Legal - Terms of Service: https://smscode.gg/terms - Privacy Policy: https://smscode.gg/privacy