API Reference
A REST API over HTTPS with JSON bodies and Bearer authentication. Buy a virtual number for an exact price tier, poll it for the verification code, then finish. Everything the dashboard does is available here.
Overview
The API is versioned under /api/v2. Every response is JSON with a success boolean at the top — see Response format. Money is a decimal USD string with variable precision ("0.5", "0.425") — parse it, don't assume two places. Timestamps are ISO 8601 UTC.
Typical integration: list products for a service, create an order against one, then poll the order until otp_code is set.
Base URL
| Field | Type | Notes |
|---|---|---|
| Production | string | https://api.smsgecko.com/api/v2 |
Authentication
Every request needs a Bearer token. Mint one from the dashboard (Settings → API keys) using a session cookie. Keys look like smsg_live_… and are shown only once, so copy it somewhere safe and include it in every request.
curl https://api.smsgecko.com/api/v2/orders/active \
-H "Authorization: Bearer smsg_live_xxxxxxxxxxxx"A missing or invalid key returns 401 UNAUTHORIZED. A valid key for a suspended account returns 403 FORBIDDEN.
Response format
Every response is JSON with a top-level success boolean — check it before touching data.
{
"success": true,
"data": { ... }
}{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable message"
}
}error.code is a stable machine-readable string (e.g. INSUFFICIENT_BALANCE) — switch on it, not on error.message, which can change. Full list in Errors.
Idempotency
POST /orders is retry-safe. Send an Idempotency-Key header (8–128 chars) — replaying it returns the original order instead of buying twice. Reuse the same key across every retry of one logical purchase (changing max_price on a retry is fine; it isn't part of the key's identity).
A second request with the same key while the first is still being processed gets 409 REQUEST_IN_PROGRESS — retry shortly rather than assuming it failed. Reusing a key with a different catalog_product_id or operator_id gets 422 IDEMPOTENCY_KEY_REUSED; pick a new key for a genuinely different order.
Errors
Failures use HTTP status codes plus the error envelope above. error.code is the stable, machine-readable field — switch on it, not on error.message. error.details additionally carries field-level messages on BAD_REQUEST from a failed body/query check.
| Code | HTTP | Description |
|---|---|---|
| BAD_REQUEST | 400 | Malformed request body/query, or an invalid `Idempotency-Key` header. |
| INVALID_JSON | 400 | Request body is not valid JSON. |
| UNAUTHORIZED | 401 | Missing or invalid Bearer token. |
| INSUFFICIENT_BALANCE | 402 | Wallet balance is below the order or reactivation price. |
| FORBIDDEN | 403 | API key belongs to a suspended account. |
| NOT_FOUND | 404 | No order with that id under your account, or the route doesn’t exist. |
| CONFLICT | 409 | No provider is enabled, the order is already resolved (finished/canceled/expired), already has a code, or the provider can’t resend/reactivate. |
| CANCEL_TOO_EARLY | 409 | The number was bought too recently — the post-purchase hold hasn’t elapsed. `error.details.retryAfterSeconds` says how much longer. |
| REQUEST_IN_PROGRESS | 409 | A create request with this exact `Idempotency-Key` is still being processed — retry shortly. |
| UNPROCESSABLE | 422 | The live price moved above your `max_price`. |
| NO_OFFER_AVAILABLE | 422 | No active offer matches that service/country/tier right now — out of stock, or the tier no longer exists. |
| IDEMPOTENCY_KEY_REUSED | 422 | This `Idempotency-Key` was replayed with a different `catalog_product_id` or `operator_id`. Use a new key for a different logical order. |
| PROVIDER_ERROR | 422 | No number could be rented. `error.details.attempts` has one `{ outcome }` per upstream tried, where `outcome` is `no_numbers`, `provider_unavailable`, or `provider_error`. |
| RATE_LIMITED | 429 | Too many requests — check the `RateLimit-Reset` header (seconds) and back off. |
| INTERNAL_ERROR | 500 | Unexpected server error — safe to retry once. |
| SERVICE_UNAVAILABLE | 503 | Ordering is paused for maintenance. |
Rate limits
300 requests per minute per IP. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the window resets); once you are over, you get 429 RATE_LIMITED.
Polling an order
- Poll every 5 seconds. A code rarely arrives sooner, so polling faster only uses up your request budget.
- Count your parallel orders. 20 orders polled every 5 seconds is 240 requests a minute, close to the limit. If you need more than that, poll less often or contact support.
- Stop when the order ends. Only
waitingneeds polling.completed,canceledandexpiredare final. - Set a time limit. Stop at
expires_at(about 7 minutes after the purchase). Expired orders are refunded automatically. - Back off on a 429. Wait
RateLimit-Resetseconds before the next request, then carry on.
The OTP lifecycle
- Find a product.
GET /catalog/products?service=returns one row per price tier for each country. Each row'sidis a stable tier slot. Look it up every time you order. Service codes, country codes and product ids can change as the catalog is updated, so never hardcode them or keep them long-term. - Create the order.
POST /orderswithcatalog_product_idand amax_pricecap. The wallet is charged now; the order iswaitingwith aphone_number. - Poll for the code.
GET /orders/:idevery 5 seconds untilotp_codeis set (statusbecomescompleted). Stop early ifstatusturnscanceledorexpired, or whenexpires_atpasses. If nothing arrives, the order auto-refunds. See Rate limits for polling advice. - Finish (
POST /orders/:id/finish) to signal you're done. Or cancel a still-waiting order for a full refund, resend to ask for another SMS, or reactivate a completed order to buy another code on the same number.
TOKEN="smsg_live_xxxxxxxxxxxx"
# 1. look the product up by name. Never hardcode service codes, country codes or product ids:
# they can change as the catalog is updated.
SERVICE=$(curl -s "https://api.smsgecko.com/api/v2/catalog/products?search=whatsapp" \
-H "Authorization: Bearer $TOKEN" \
| jq -r '[.data[] | select(.service | ascii_downcase == "whatsapp")][0].service_slug')
PID=$(curl -s "https://api.smsgecko.com/api/v2/catalog/products?service=$SERVICE" \
-H "Authorization: Bearer $TOKEN" \
| jq -r '[.data[] | select(.country == "United States")][0].id')
[ "$PID" != "null" ] || { echo "no product for that service/country"; exit 1; }
# 2. buy it, never paying more than $1.00 (the order lives at .data on a single-object response)
ORDER=$(curl -s -X POST https://api.smsgecko.com/api/v2/orders/create \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d "{\"catalog_product_id\":\"$PID\",\"max_price\":\"1.00\"}")
ID=$(echo "$ORDER" | jq -r '.data.id')
[ "$ID" != "null" ] || { echo "$ORDER"; exit 1; }
# 3. poll every 5 seconds for up to ~7 minutes; stop as soon as the code arrives or the order ends
CODE=""
for i in $(seq 1 84); do
RES=$(curl -s https://api.smsgecko.com/api/v2/orders/$ID -H "Authorization: Bearer $TOKEN")
CODE=$(echo "$RES" | jq -r '.data.otp_code // empty')
STATUS=$(echo "$RES" | jq -r '.data.status')
[ -n "$CODE" ] && break
[ "$STATUS" = "waiting" ] || break
sleep 5
done
# 4. got a code: finish. No code: cancel (full refund; an expired order is refunded for you)
if [ -n "$CODE" ]; then
echo "code: $CODE"
curl -s -X POST https://api.smsgecko.com/api/v2/orders/$ID/finish -H "Authorization: Bearer $TOKEN"
else
curl -s -X POST https://api.smsgecko.com/api/v2/orders/$ID/cancel -H "Authorization: Bearer $TOKEN"
fiGet wallet balance
Your current wallet balance, as the same decimal USD string used everywhere else in the API.
curl https://api.smsgecko.com/api/v2/balance \
-H "Authorization: Bearer $TOKEN"{ "success": true, "data": { "balance": "12.47" } }List products
One product per price tier of a service×country, cheapest first — pass a row's id to POST /orders/create. Without service you instead get a bare service list for discovery (price "0", stock 0, id is just the service code — not orderable). Query with ?service= to get real, buyable rows.
Don't hardcode service codes, country codes or product ids. They can change periodically as providers and the catalog change, so look up the service and country you want by name from this endpoint each time you order (or at least refresh them often). Codes shown in these docs, such as wa or 187, are only examples.
| Field | Type | Notes |
|---|---|---|
| service | string? | Provider service code (e.g. "wa"). Required for prices. |
| country | string? | Provider country code (e.g. "187") — narrows the results. A name or ISO code like "us" does not match, and codes can change: read them from the country_code field rather than hardcoding. |
| search | string? | Without service: filter the service list by name or code (e.g. "whatsapp"). |
| limit | int? | 1–500, default 200. |
curl "https://api.smsgecko.com/api/v2/catalog/products?service=wa&country=187" \
-H "Authorization: Bearer $TOKEN"{
"success": true,
"data": [
{
"id": "wa::187",
"service": "wa",
"service_slug": "wa",
"country": "United States",
"country_code": "187",
"operator": null,
"price": "0.47",
"stock": 212412
}
]
}Create an order
Buys the chosen tier, charges the wallet, rents a number. Returns 201 with a waiting order. POST /api/v2/orders does exactly the same and keeps working, so existing integrations don’t need to change.
| Field | Type | Notes |
|---|---|---|
| catalog_product_id | string | A product `id` from /catalog/products. (`product_id` is accepted too.) |
| max_price | string? | Decimal USD cap — 422 if the live price exceeds it. |
| operator_id | string? | Pin a carrier: buys the cheapest in-stock tier for it, overriding any tier index in `catalog_product_id`. |
| Idempotency-Key | header? | 8–128 chars — replay-safe create. |
curl -X POST https://api.smsgecko.com/api/v2/orders/create \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 7b1e0c9c-3d21-4b2f-9a10-8f4e2c1d0abc" \
-H "Content-Type: application/json" \
-d '{"catalog_product_id":"wa::187","max_price":"0.50"}'{
"success": true,
"data": {
"id": "SG-8FQ3-K2",
"status": "waiting",
"product": { "service": "Whatsapp", "country": "United States" },
"phone_number": "+15551234567",
"price": "0.47",
"otp_code": null,
"sms": [],
"created_at": "2026-09-10T14:00:00.000Z",
"expires_at": "2026-09-10T14:20:00.000Z",
"finished_at": null
}
}Active orders
Every still-waiting order on your account, newest first: { "success": true, "data": Order[] }.
curl https://api.smsgecko.com/api/v2/orders/active \
-H "Authorization: Bearer $TOKEN"{
"success": true,
"data": [
{
"id": "SG-8FQ3-K2",
"status": "waiting",
"product": { "service": "Whatsapp", "country": "United States" },
"phone_number": "+15551234567",
"price": "0.47",
"otp_code": null,
"sms": [],
"created_at": "2026-09-10T14:00:00.000Z",
"expires_at": "2026-09-10T14:20:00.000Z",
"finished_at": null
}
]
}Get an order
Poll this for the code. sms[] holds each received message; otp_code is the parsed code once it lands. :id is the order’s id (short, like SG-8FQ3-K2); a legacy 24-hex id still works too.
curl https://api.smsgecko.com/api/v2/orders/SG-8FQ3-K2 \
-H "Authorization: Bearer $TOKEN"{
"success": true,
"data": {
"id": "SG-8FQ3-K2",
"status": "completed",
"product": { "service": "Whatsapp", "country": "United States" },
"phone_number": "+15551234567",
"price": "0.47",
"otp_code": "482913",
"sms": [
{
"sender": "WhatsApp",
"text": "Your WhatsApp code: 482-913",
"received_at": "2026-09-10T14:03:12.000Z"
}
],
"created_at": "2026-09-10T14:00:00.000Z",
"expires_at": "2026-09-10T14:20:00.000Z",
"finished_at": null
}
}Finish an order
Marks a completed order finished (finished_at is set) and tells the upstream you're done with the number. No-op if already finished. Send the order id in the JSON body. POST /api/v2/orders/:id/finish does exactly the same and keeps working.
| Field | Type | Notes |
|---|---|---|
| id | string | The order's `id` (like SG-8FQ3-K2). |
curl -X POST https://api.smsgecko.com/api/v2/orders/finish \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"SG-8FQ3-K2"}'{
"success": true,
"data": {
"id": "SG-8FQ3-K2",
"status": "completed",
"product": { "service": "Whatsapp", "country": "United States" },
"phone_number": "+15551234567",
"price": "0.47",
"otp_code": "482913",
"sms": [
{
"sender": "WhatsApp",
"text": "Your WhatsApp code: 482-913",
"received_at": "2026-09-10T14:03:12.000Z"
}
],
"created_at": "2026-09-10T14:00:00.000Z",
"expires_at": "2026-09-10T14:20:00.000Z",
"finished_at": "2026-09-10T14:05:00.000Z"
}
}Cancel an order
Cancels a waiting order and refunds it in full. A short post-purchase lock applies — 409 until it elapses, and once a code has arrived.
Send the order id in the JSON body. POST /api/v2/orders/:id/cancel does exactly the same and keeps working.
| Field | Type | Notes |
|---|---|---|
| id | string | The order's `id` (like SG-8FQ3-K2). |
curl -X POST https://api.smsgecko.com/api/v2/orders/cancel \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"SG-8FQ3-K2"}'{
"success": true,
"data": {
"id": "SG-8FQ3-K2",
"status": "canceled",
"product": { "service": "Whatsapp", "country": "United States" },
"phone_number": "+15551234567",
"price": "0.47",
"otp_code": null,
"sms": [],
"created_at": "2026-09-10T14:00:00.000Z",
"expires_at": "2026-09-10T14:20:00.000Z",
"finished_at": null
}
}sms is always empty here — cancel doesn't look up message history, since a canceled order has none to poll.
Resend a code
Asks the upstream for another SMS on a still-waiting order. Free — no new rental, no charge. 409 if the order isn't waiting or the provider can't resend; 422 (PROVIDER_ERROR) if the provider refused or couldn't be reached, so a 200 means another code really was requested. One call every 5 seconds; faster is 429.
Send the order id in the JSON body. POST /api/v2/orders/:id/resend does exactly the same and keeps working.
| Field | Type | Notes |
|---|---|---|
| id | string | The order's `id` (like SG-8FQ3-K2). |
curl -X POST https://api.smsgecko.com/api/v2/orders/resend \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"SG-8FQ3-K2"}'{
"success": true,
"data": {
"id": "SG-8FQ3-K2",
"status": "waiting",
"product": { "service": "Whatsapp", "country": "United States" },
"phone_number": "+15551234567",
"price": "0.47",
"otp_code": null,
"sms": [],
"created_at": "2026-09-10T14:00:00.000Z",
"expires_at": "2026-09-10T14:20:00.000Z",
"finished_at": null
}
}The response is just the order as it stands right after the request — the resend itself is fire-and-forget upstream. Keep polling GET /orders/:id for the new message.
Reactivate an order
Buys another code on a completed order's number. Charges the current tier price and reopens the order as waiting, keeping its message history. 402 on low balance; 409 if the order isn't completed or the provider can't reactivate; 422 if that service/country has no offer available right now.
Send the order id in the JSON body. POST /api/v2/orders/:id/reactivate does exactly the same and keeps working.
| Field | Type | Notes |
|---|---|---|
| id | string | The order's `id` (like SG-8FQ3-K2). |
curl -X POST https://api.smsgecko.com/api/v2/orders/reactivate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"SG-8FQ3-K2"}'{
"success": true,
"data": {
"id": "SG-8FQ3-K2",
"status": "waiting",
"product": { "service": "Whatsapp", "country": "United States" },
"phone_number": "+15551234567",
"price": "0.47",
"otp_code": null,
"sms": [
{
"sender": "WhatsApp",
"text": "Your WhatsApp code: 482-913",
"received_at": "2026-09-10T14:03:12.000Z"
}
],
"created_at": "2026-09-10T14:00:00.000Z",
"expires_at": "2026-09-10T15:10:00.000Z",
"finished_at": null
}
}id, phone_number and past sms entries carry over from before reactivation; otp_code, status and expires_at reset like a fresh order.
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.
| Event | Trigger |
|---|---|
| order.otp_received | An OTP arrived — same moment `otp_code` is set. |
Every delivery has the same envelope — event, timestamp, and a flat data object (not the same shape as the order object returned by the REST endpoints):
{
"event": "order.otp_received",
"timestamp": "2026-09-10T14:03:12.000Z",
"data": {
"order_id": "SG-8FQ3-K2",
"status": "completed",
"phone_number": "+15551234567",
"otp_code": "482913",
"otp_message": "Your WhatsApp code: 482-913",
"service": "WhatsApp",
"country": "United States",
"price": "0.47",
"created_at": "2026-09-10T14:01:02.000Z",
"expires_at": "2026-09-10T14:11:02.000Z",
"finished_at": null
}
}| Field | Type | Notes |
|---|---|---|
| order_id | string | Short order id, e.g. "SG-8FQ3-K2". |
| status | enum | "waiting" | "completed" | "canceled" | "expired" |
| phone_number | string | E.164, e.g. "+15551234567". |
| otp_code | string | null | Parsed code, if one was found in the message. |
| otp_message | string | null | Raw text of the SMS that completed the order. |
| service | string | Display name, e.g. "WhatsApp". |
| country | string | Display name, e.g. "United States". |
| price | string | Decimal USD charged. |
| created_at | string | ISO 8601. |
| expires_at | string | ISO 8601. |
| finished_at | string | null | ISO 8601, once you call /finish. |
Each webhook request includes an X-SMSGecko-Signature: sha256=<hex> header — HMAC-SHA256 of the raw request body using your webhook_secret as the key. Verify this signature on your server before trusting the payload:
import { createHmac, timingSafeEqual } from 'node:crypto';
function isValidSignature(rawBody, header, secret) {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(header ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}
// rawBody must be the exact bytes received — parse JSON only after verifying.
app.post('/webhooks/smsgecko', express.raw({ type: 'application/json' }), (req, res) => {
if (!isValidSignature(req.body, req.get('X-SMSGecko-Signature'), process.env.SMSGECKO_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
// ...
res.sendStatus(200);
});Delivery is best-effort — one attempt, a 5s timeout, no retry queue. GET /orders/:id always reflects the real state, so treat webhooks as a fast path, not the source of truth.
Get webhook config
Your account's current webhook, or nulls if none is configured yet.
None
curl https://api.smsgecko.com/api/v2/webhook \
-H "Authorization: Bearer $TOKEN"{
"success": true,
"data": {
"webhook_url": "https://example.com/webhooks/smsgecko",
"webhook_secret": "3f1c9e7b2a6d4508f0c1e9b7a2d6f435"
}
}Set or update the webhook
Partial update — send only the fields you're changing.
| Name | Type | Required | Description |
|---|---|---|---|
| webhook_url | string | null | No | Must be https:// and not a local/private address. Pass null to remove the webhook (and its secret). |
| webhook_secret | string | No | 16–128 chars. Omit when first setting webhook_url and one is generated for you; omit on later calls to leave it unchanged. |
curl -X PATCH https://api.smsgecko.com/api/v2/webhook \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"webhook_url":"https://example.com/webhooks/smsgecko"}'{
"success": true,
"data": {
"webhook_url": "https://example.com/webhooks/smsgecko",
"webhook_secret": "3f1c9e7b2a6d4508f0c1e9b7a2d6f435"
}
}400 BAD_REQUEST if webhook_url isn't https:// or resolves to something like localhost or a private IP range.
Send a test event
Fires a synthetic webhook.test event at your configured URL right now, so you can check your receiver end-to-end without waiting for a real order. 409 if no webhook_url is set.
curl -X POST https://api.smsgecko.com/api/v2/webhook/test \
-H "Authorization: Bearer $TOKEN"{
"success": true,
"data": { "delivered": true, "statusCode": 200 }
}delivered reflects whether your endpoint answered with a 2xx — statusCode is null if the request errored or timed out before getting a response at all.
The order object
| Field | Type | Notes |
|---|---|---|
| id | string | Short order id, e.g. "SG-8FQ3-K2" (orders created before this may still be 24-hex). |
| status | enum | "waiting" | "completed" | "canceled" | "expired" |
| product | object | { service, country } — display names. |
| phone_number | string | E.164, e.g. "+15551234567". |
| price | string | Decimal USD charged. |
| otp_code | string | null | Parsed code once delivered. |
| sms | array | [{ sender, text, received_at }] |
| created_at | string | ISO 8601. |
| expires_at | string | Auto-refund deadline while waiting. |
| finished_at | string | null | Set by /finish. |
The product object
| Field | Type | Notes |
|---|---|---|
| id | string | "<service>::<country>[::<tier>]" — pass to /orders. |
| service | string | Provider service code. |
| service_slug | string | Same code (kept for compatibility). |
| country | string | Display name. |
| country_code | string | Provider country code. |
| operator | string | null | Upstream operator, when distinguished. |
| price | string | Decimal USD, markup applied. Variable precision — "0.5", "0.425". |
| stock | number | Reported availability (0 = unknown / none). |