Overview
The developer API sells the same numbers as the app, from the same balance, through a REST API and a compatible endpoint.
- Base URL
https://smsgrab.com/api/dev/v1- sms-activate compatible
https://smsgrab.com/api/stubs/handler_api.php- Format
- JSON over HTTPS,
snake_casefields, UTF-8. Money is an integer in minor units ofcurrency(USD cents). Timestamps are ISO 8601 in UTC with milliseconds. - Authentication
- An API key (
sgk_…) inAuthorization: BearerorX-Api-Key. - Money
- Every charge goes through the same double-entry ledger as the app and the website. Numbers without a code are refunded automatically.
- Request ids
- Every answer carries
X-Request-Id. Include it when you contact support.
Quickstart
Three calls take you from a new key to a received code.
- Create an account with a verified e-mail address and open the developer area.
- Create a key with “Read and buy” access and copy its secret.
- Make sure your balance covers the number you want, then run the calls below.
export SMSGRAB_API_KEY="sgk_your_key"
curl -s https://smsgrab.com/api/dev/v1/balance \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
curl -s -X POST https://smsgrab.com/api/dev/v1/activations \
-H "Authorization: Bearer $SMSGRAB_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"service":"telegram","country":"indonesia","max_price_minor":30}'
curl -s "https://smsgrab.com/api/dev/v1/activations/100000123?wait=25" \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
curl -s -X POST https://smsgrab.com/api/dev/v1/activations/100000123/finish \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
The number is yours for 20 minutes. Poll GET /activations/{id}?wait=25 (the request is held until something changes) or subscribe to webhooks. When a code has arrived, finish the activation; if none arrives, it expires and the charge is refunded automatically. Complete programs in five languages are in Complete examples.
Authentication
Every request carries one of your API keys.
GET /api/dev/v1/balance HTTP/1.1
Host: smsgrab.com
Authorization: Bearer sgk_your_key
X-Api-Key: sgk_… works as well. A key in a query string is accepted only by the compatible endpoint. There is no CORS: keys belong on servers, never in browsers or apps you ship to users.
| Situation | Answer |
|---|---|
| No key, malformed or unknown key | 401 UNAUTHORIZED · API_KEY_INVALID |
| Revoked key | 401 UNAUTHORIZED · API_KEY_REVOKED |
| Expired key, or an old secret after its rotation grace | 401 UNAUTHORIZED · API_KEY_EXPIRED |
| Address outside the key's allowlist | 403 FORBIDDEN · API_KEY_IP_NOT_ALLOWED |
| The key lacks the needed scope | 403 FORBIDDEN · API_KEY_SCOPE |
| E-mail no longer verified, account suspended, or developer access blocked | 403 FORBIDDEN · EMAIL_NOT_VERIFIED, ACCOUNT_BLOCKED, DEVELOPER_ACCESS_BLOCKED |
| Too many rejected keys from one network | 429 RATE_LIMITED |
API keys
Keys are created, limited and rotated in your account.
| Format | sgk_ followed by 40 letters and digits. Shown once, when it is created or rotated. We store only a keyed hash. |
|---|---|
| Scopes | read (balance, catalogue, prices, activations, usage) or read + purchase (buy, cancel, finish). |
| IP allowlist | Up to 50 IPv4/IPv6 addresses or CIDR ranges. Empty means any address. |
| Daily spend cap | Optional, per UTC day, counted over the key's net charges. Purchases above it answer 403 DAILY_SPEND_CAP_REACHED and nothing is charged. |
| Expiry | Optional, at most two years ahead. |
| Rotation | A new secret for the same key, settings, usage and webhooks. The old secret keeps working for the grace period you choose (up to 72 hours). |
| Revocation | Immediate and final. The key's webhooks are switched off. |
| Per account | Up to 10 active keys. |
Creating or rotating a key, adding a webhook, rolling its secret and widening a key's access (more scopes, a wider allowlist, a higher cap or a later expiry) ask for your password again, and your two-step code when it is on. Revoking and tightening never do.
Pricing
Your price is built from the wholesale cost of each offer and a markup that falls with volume.
developer_price = max(ceil(wholesale × (100 + markup) / 100), minimum)
developer_price = min(developer_price, app_price)
| 30-day developer spend | Markup on wholesale | Example price |
|---|---|---|
| Below $100.00 | +80% | $0.18 28% below the app |
| $100.00 and more | +70% | $0.17 32% below the app |
| $500.00 and more | +60% | $0.16 36% below the app |
| $2,000.00 and more | +50% | $0.15 40% below the app |
The tier follows your trailing 30-day developer spend (charges minus refunds of numbers bought through the API or the compatible endpoint) and applies within five minutes of crossing a threshold. The minimum price is $0.10. A developer price is never above the price of the same number in the app. The price is fixed at purchase time; send max_price_minor to protect yourself from a change between reading prices and buying. Your current markup and tier are in GET /me.
Rate limits
Limits keep the platform fast for everyone. Most integrations never reach them.
| Limit | Default | When exceeded |
|---|---|---|
| Requests per key | 600 / minute | 429 RATE_LIMITED with Retry-After |
| Purchases per key | 60 / minute | 429 RATE_LIMITED |
| Requests per account (all keys) | 3,000 / minute | 429 RATE_LIMITED |
| Numbers waiting at once | 100 | 422 VALIDATION · ACTIVATION_LIMIT_REACHED |
| Held long-poll requests per key | 20 | 429 RATE_LIMITED |
| Webhooks per key | 3 | 422 VALIDATION · WEBHOOK_LIMIT_REACHED |
Every answer carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy for the tightest limit that applies.
Errors
Failed calls answer with a stable code and, where useful, a more precise reason.
{
"code": "NO_NUMBERS_AVAILABLE",
"message": "No numbers available for this service and country",
"reason": "OUT_OF_STOCK"
}
| HTTP | code | reason | Meaning |
|---|---|---|---|
400 / 422 | VALIDATION | ACTIVATION_LIMIT_REACHED, ACTIVATION_CODE_RECEIVED, ACTIVATION_FINISHED, ACTIVATION_NO_CODE_YET, ACTIVATION_CANCEL_NOT_YET | A field is malformed or the activation is in the wrong state. The message names the field. |
401 | UNAUTHORIZED | API_KEY_INVALID, API_KEY_REVOKED, API_KEY_EXPIRED | No key, an unknown key, a revoked key, or an old secret after its rotation grace. |
402 | INSUFFICIENT_BALANCE | – | The balance does not cover the price. Nothing is charged. |
403 | FORBIDDEN | API_KEY_IP_NOT_ALLOWED, API_KEY_SCOPE, EMAIL_NOT_VERIFIED, ACCOUNT_BLOCKED, DEVELOPER_ACCESS_BLOCKED, DAILY_SPEND_CAP_REACHED | The key or the account may not do this right now. |
404 | NOT_FOUND, SERVICE_NOT_FOUND, COUNTRY_NOT_FOUND | ACTIVATION_NOT_FOUND | Unknown id, service or country. |
409 | NO_NUMBERS_AVAILABLE | OUT_OF_STOCK, PROVIDER_OUT_OF_NUMBERS | No number could be delivered. Any charge was refunded in the same request. |
409 | PRICE_CHANGED | – | The price is above max_price_minor. Nothing is charged. |
429 | RATE_LIMITED | – | Slow down and retry after the Retry-After header. |
503 | PROVIDER_UNAVAILABLE | PURCHASES_PAUSED, PROVIDER_PAUSED, PROVIDER_CIRCUIT_OPEN, SERVER_DRAINING | Purchases are paused for a moment. Nothing is charged. |
503 | FEATURE_UNAVAILABLE | – | The developer API is switched off for maintenance. |
5xx | INTERNAL | – | Retry idempotent requests with exponential backoff. |
New reason values may be added at any time; treat unknown ones like their code.
Idempotency
Retrying a purchase safely never buys twice.
Send an Idempotency-Key header (any unique string up to 64 characters, a UUID is ideal) with POST /activations. If the network drops and you retry with the same key, you get the first answer again with Idempotent-Replayed: true instead of a second number. Cancel and finish are idempotent by nature: repeating them when the activation already has that status succeeds.
Endpoints
All endpoints live under the base address and answer JSON.
- GET
/meThe calling key, your price and your limits. - GET
/balanceYour account balance. - GET
/countriesCountries with numbers in stock. - GET
/servicesServices, optionally only those in stock in one country. - GET
/pricesYour developer prices and stock per offer. - POST
/activationsBuy a number. - GET
/activationsList your activations. - GET
/activations/{id}One activation, with optional long polling. - POST
/activations/{id}/cancelCancel before a code arrived and get a refund. - POST
/activations/{id}/finishFinish an activation after the code. - GET
/usageRequests, purchases and spend over time.
GET
/me
The calling key, your price and your limits. scope: read
curl -s https://smsgrab.com/api/dev/v1/me \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
{
"key": { "id": "0b8e2a41-7c1d-4e5f-9a3b-6c7d8e9f0a1b", "name": "production bot", "display_prefix": "sgk_4hT9wQ", "last4": "Xa7Q",
"scopes": ["read", "purchase"], "ip_allowlist": ["203.0.113.0/24"], "daily_spend_cap_minor": 5000,
"rate_limit_per_minute": 600, "purchase_limit_per_minute": 60, "expires_at": null },
"account": { "user_id": "2b4b9c1d-3e5f-4a6b-8c7d-9e0f1a2b3c4d", "email": "dev@example.org" },
"currency": "USD",
"pricing": { "markup_percent": 70, "source": "tier", "min_price_minor": 10, "cap_at_retail": true,
"trailing_30d_spend_minor": 18250,
"tier": { "min_spend_minor": 10000, "markup_percent": 70 },
"next_tier": { "min_spend_minor": 50000, "markup_percent": 60, "remaining_minor": 31750 } },
"limits": { "max_active_activations": 100, "active_activations": 3, "spend_today_minor": 420 }
}
GET
/balance
Your account balance. scope: read
curl -s https://smsgrab.com/api/dev/v1/balance \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
{ "balance_minor": 12345, "currency": "USD", "updated_at": "2026-09-27T10:02:11.482Z" }
GET
/countries
Countries with numbers in stock. scope: read
curl -s https://smsgrab.com/api/dev/v1/countries \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
{ "items": [ { "id": "indonesia", "name": "Indonesia", "iso_code": "ID", "dial_code": "62", "flag_emoji": "🇮🇩",
"available_numbers": 812334, "service_count": 402, "compat_id": 6 } ] }
GET
/services
Services, optionally only those in stock in one country. scope: read
| Parameter | In | Type | Description |
|---|---|---|---|
country | query | string | Only services in stock in this country. With it, min_price_minor is your price there and country_count is omitted. |
curl -s "https://smsgrab.com/api/dev/v1/services?country=indonesia" \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
{ "items": [ { "id": "whatsapp", "name": "WhatsApp", "category": "MESSAGING",
"icon_url": "https://smsgrab.com/api/v1/icons/whatsapp?v=1fceb4feb3", "compat_code": "wa",
"min_price_minor": 17 } ] }
GET
/prices
Your developer prices and stock per offer. scope: read
| Parameter | In | Type | Description |
|---|---|---|---|
service | query | string | A service id. Leave it out for every service. |
country | query | string | A country id. Leave it out for every country. |
curl -s --compressed "https://smsgrab.com/api/dev/v1/prices?service=whatsapp&country=indonesia" \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
{ "currency": "USD", "markup_percent": 70,
"items": [ { "service_id": "whatsapp", "country_id": "indonesia", "price_minor": 17, "retail_price_minor": 25,
"available_numbers": 30412 } ] }
POST
/activations
Buy a number. scope: purchase
| Parameter | In | Type | Description |
|---|---|---|---|
service required | body | string | A service id (whatsapp) or its compatible code (wa). |
country required | body | string | A country id (indonesia), an ISO code (ID) or a compatible id (6). |
max_price_minor | body | integer | The most you want to pay, in cents. A higher price answers 409 PRICE_CHANGED and nothing is charged. |
Idempotency-Key | header | string | Any unique string up to 64 characters. Repeating it replays the first success instead of buying twice. |
curl -s -X POST https://smsgrab.com/api/dev/v1/activations \
-H "Authorization: Bearer $SMSGRAB_API_KEY" \
-H "Idempotency-Key: order-7f3c2a" \
-H "Content-Type: application/json" \
-d '{"service":"whatsapp","country":"indonesia","max_price_minor":20}'
{ "id": "5c1d0c2e-9f3a-4b7e-8a61-2f0c9d4e7b10", "compat_id": 100000123, "status": "WAITING_SMS",
"service_id": "whatsapp", "service_name": "WhatsApp", "country_id": "indonesia", "country_name": "Indonesia",
"country_iso_code": "ID", "phone_number": "+6281234567890", "price_minor": 17, "currency": "USD",
"created_at": "2026-09-27T10:05:00.000Z", "expires_at": "2026-09-27T10:25:00.000Z", "finished_at": null,
"refunded": false, "refund_minor": 0, "sms": [], "api_key_id": "0b8e2a41-7c1d-4e5f-9a3b-6c7d8e9f0a1b" }
GET
/activations
List your activations. scope: read
| Parameter | In | Type | Description |
|---|---|---|---|
status | query | string | active (waiting or code received) or finished. |
service | query | string | Only this service id. |
country | query | string | Only this country id. |
from, to | query | date-time | Only activations bought in this time window (ISO 8601). |
source | query | string | api (default, bought through the developer API or the compatible endpoint) or all (the whole account). |
limit | query | integer | 1 to 100, default 50. |
cursor | query | string | next_cursor of the previous page. |
curl -s "https://smsgrab.com/api/dev/v1/activations?status=active&limit=20" \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
{ "items": [ { "id": "5c1d0c2e-9f3a-4b7e-8a61-2f0c9d4e7b10", "status": "CODE_RECEIVED", "phone_number": "+6281234567890" } ], "next_cursor": "MTc5NDI1" }
GET
/activations/{id}
One activation, with optional long polling. scope: read
| Parameter | In | Type | Description |
|---|---|---|---|
id required | path | string | The activation UUID or its numeric compat_id. |
wait | query | integer | 0 to 30 seconds. The request is held until the activation changes (a new SMS or status) or the time runs out. At most 20 held requests per key. |
curl -s "https://smsgrab.com/api/dev/v1/activations/100000123?wait=25" \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
{ "id": "5c1d0c2e-9f3a-4b7e-8a61-2f0c9d4e7b10", "compat_id": 100000123, "status": "CODE_RECEIVED",
"phone_number": "+6281234567890", "price_minor": 17, "currency": "USD",
"sms": [ { "code": "482913", "text": "Your WhatsApp code is 482-913", "sender": "WhatsApp",
"received_at": "2026-09-27T10:06:11.870Z" } ] }
POST
/activations/{id}/cancel
Cancel before a code arrived and get a refund. scope: purchase
| Parameter | In | Type | Description |
|---|---|---|---|
id required | path | string | The activation UUID or compat_id. Accepted only while waiting for the first SMS; the charge is refunded. |
curl -s -X POST https://smsgrab.com/api/dev/v1/activations/100000123/cancel \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
{ "id": "5c1d0c2e-9f3a-4b7e-8a61-2f0c9d4e7b10", "status": "CANCELLED", "refunded": true, "refund_minor": 17 }
POST
/activations/{id}/finish
Finish an activation after the code. scope: purchase
| Parameter | In | Type | Description |
|---|---|---|---|
id required | path | string | The activation UUID or compat_id. Accepted once a code has arrived. |
curl -s -X POST https://smsgrab.com/api/dev/v1/activations/100000123/finish \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
{ "id": "5c1d0c2e-9f3a-4b7e-8a61-2f0c9d4e7b10", "status": "COMPLETED", "finished_at": "2026-09-27T10:07:02.113Z" }
GET
/usage
Requests, purchases and spend over time. scope: read
| Parameter | In | Type | Description |
|---|---|---|---|
from, to | query | date | YYYY-MM-DD, at most 366 days apart. Default: the last 30 days. |
granularity | query | string | day (default) or month. |
key | query | string | current (default, the calling key) or all (every key of the account). |
curl -s "https://smsgrab.com/api/dev/v1/usage?granularity=day&key=all" \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
{ "currency": "USD", "from": "2026-09-01", "to": "2026-09-27", "granularity": "day",
"totals": { "requests": 48211, "errors": 120, "rate_limited": 3, "purchases": 1804, "spend_minor": 31211,
"refunds_minor": 2410, "net_minor": 28801 },
"series": [ { "date": "2026-09-27", "requests": 2103, "errors": 4, "rate_limited": 0, "purchases": 88,
"spend_minor": 1496, "refunds_minor": 102, "net_minor": 1394 } ],
"by_key": [ { "key_id": "0b8e2a41-7c1d-4e5f-9a3b-6c7d8e9f0a1b", "name": "production bot", "requests": 48211, "net_minor": 28801 } ] }
Activation lifecycle
An activation moves through a few states from purchase to code.
WAITING_SMSCODE_RECEIVEDCOMPLETED
WAITING_SMS: the number is ready and waiting for the first message. It can be cancelled (refunded).CODE_RECEIVED: at least one SMS arrived;smsholds every message withcode,text,senderandreceived_at. More messages may follow.COMPLETED: you finished it, or the code window ended after a code.CANCELLEDandEXPIRED: no code arrived. The charge is refunded andrefunded/refund_minorare set.
A number lives 20 minutes (expires_at). Cancel is accepted only while waiting (422 ACTIVATION_CODE_RECEIVED after a code; 422 ACTIVATION_CANCEL_NOT_YET when an early cancel is refused, retry after a minute). Finish is accepted once a code has arrived.
Long polling. GET /activations/{id}?wait=25 holds the request until the activation changes or the time runs out, then answers the current object. It replaces tight polling loops and costs one request per change.
Webhooks
Webhooks push activation events to your server as they happen.
| Event | data |
|---|---|
activation.created | activation |
activation.code_received | activation (with every SMS so far) |
activation.completed | activation |
activation.cancelled | activation |
activation.expired | activation |
activation.refunded | activation |
balance.low | { balance_minor, currency, threshold_minor } |
webhook.test | { message } |
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: SmsGrab-Webhooks/1.0
SmsGrab-Event: activation.code_received
SmsGrab-Delivery: 9f7d6c1e-2b4a-4f0e-9c3d-1a2b3c4d5e6f
SmsGrab-Signature: t=1790503200,v1=3b9a0c5e7d2f41a8b6c9e0d1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5
{
"id": "evt_4e1f9a0b2c3d",
"type": "activation.code_received",
"created_at": "2026-09-27T10:06:12.004Z",
"api_version": "2026-09-27",
"data": {
"activation": {
"id": "5c1d0c2e-9f3a-4b7e-8a61-2f0c9d4e7b10",
"status": "CODE_RECEIVED",
"sms": [{ "code": "482913", "text": "Your WhatsApp code is 482-913", "sender": "WhatsApp", "received_at": "2026-09-27T10:06:11.870Z" }]
}
}
}
- Success is any 2xx answer within 10 seconds. Redirects are never followed.
- Retries after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours (±10 % jitter), then the delivery is marked
dead. A retry repeats the same eventid, so de-duplicate by it. - Order: events of one activation are delivered in order.
- 410 Gone switches the webhook off at once. After 50 failed attempts spanning at least 24 hours it is switched off and you get a notification.
- URLs must be
https://on port 443 or 1024–65535, use a host name, and resolve only to public addresses. Private, loopback, link-local and reserved ranges are refused. - Deliveries are kept 30 days. Send a test event, see every attempt and redeliver from your account.
Checking signatures
Check every delivery before you trust it.
SmsGrab-Signature: t=<unix seconds>,v1=<hex>. v1 is the lowercase hex HMAC-SHA256 of "<t>.<raw body>" with your webhook secret (whsec_…, shown once). Verify against the raw bytes before parsing JSON, reject a t more than 300 seconds from your clock, and compare in constant time.
PHP
<?php
function verifySmsGrabSignature(string $header, string $body, string $secret): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$name, $value] = array_pad(explode('=', $part, 2), 2, '');
$parts[trim($name)] = trim($value);
}
$timestamp = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
return abs(time() - $timestamp) <= 300 && hash_equals($expected, $parts['v1'] ?? '');
}
$body = file_get_contents('php://input');
if (! verifySmsGrabSignature($_SERVER['HTTP_SMSGRAB_SIGNATURE'] ?? '', $body, getenv('SMSGRAB_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
http_response_code(200);
Python
import hashlib
import hmac
import time
def verify(header: str, body: bytes, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
timestamp = int(parts.get("t", "0"))
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
return abs(time.time() - timestamp) <= 300 and hmac.compare_digest(expected, parts.get("v1", ""))
Node.js
import crypto from 'node:crypto';
export function verify(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=', 2)));
const timestamp = Number(parts.t ?? 0);
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'utf8');
return Math.abs(Date.now() / 1000 - timestamp) <= 300
&& given.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), given);
}
Go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func Verify(header string, body []byte, secret string) bool {
var timestamp int64
var signature string
for _, part := range strings.Split(header, ",") {
name, value, found := strings.Cut(strings.TrimSpace(part), "=")
if !found {
continue
}
switch name {
case "t":
timestamp, _ = strconv.ParseInt(value, 10, 64)
case "v1":
signature = value
}
}
age := time.Now().Unix() - timestamp
if age > 300 || age < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
sms-activate compatibility
Tools written for the sms-activate protocol work by changing one address.
https://smsgrab.com/api/stubs/handler_api.php
GET or POST (form encoded) with api_key, action and the action's parameters. The key may also be sent as Authorization: Bearer. Answers are plain text (or JSON where shown) with HTTP 200 for every protocol answer, exactly like the original protocol; rate limiting answers HTTP 429 with TOO_MANY_REQUESTS. Amounts are decimal USD with two decimals (your developer price). A wrong, revoked or expired key answers BAD_KEY; an unknown action answers BAD_ACTION.
| action | Parameters | Success | Errors |
|---|---|---|---|
getBalance | – | ACCESS_BALANCE:12.34 | BAD_KEY |
getNumber | service, country, maxPrice? | ACCESS_NUMBER:100000123:6281234567890 | BAD_SERVICE, BAD_COUNTRY, NO_NUMBERS, NO_BALANCE, WRONG_MAX_PRICE:0.17, CHANNELS_LIMIT, BANNED:…, NO_ACCESS |
getNumberV2 | service, country, maxPrice? | {"activationId":"100000123","phoneNumber":"6281234567890","activationCost":"0.17",…} | same as getNumber |
setStatus | id, status (1, 3, 6, 8) | ACCESS_READY, ACCESS_RETRY_GET, ACCESS_ACTIVATION, ACCESS_CANCEL | NO_ACTIVATION, BAD_STATUS, EARLY_CANCEL_DENIED |
getStatus | id | STATUS_WAIT_CODE, STATUS_WAIT_RETRY:…, STATUS_OK:482913, STATUS_CANCEL | NO_ACTIVATION |
getStatusV2 | id | {"verificationType":0,"sms":{"code":"482913",…},"call":null} | NO_ACTIVATION |
getActiveActivations | – | {"status":"success","activeActivations":[…]} | {"status":"error","error":"NO_ACTIVATIONS"} |
getPrices | service?, country? | {"6":{"wa":{"cost":0.17,"count":30412}}} | BAD_SERVICE, BAD_COUNTRY |
getCountries | – | {"6":{"id":6,"eng":"Indonesia",…}} | – |
getServicesList | country? | {"status":"success","services":[{"code":"wa","name":"WhatsApp"}]} | – |
getNumbersStatus | country? | {"wa_0":"30412","tg_0":"1822"} | BAD_COUNTRY |
getTopCountriesByService | service | {"0":{"country":6,"count":30412,"price":0.17}} | BAD_SERVICE |
setStatus: 1 ready, 3 wait for another SMS, 6 finish, 8 cancel and refund. getActiveActivations reports 4 while waiting and 1 after a code. Times are UTC, yyyy-MM-dd HH:mm:ss. The activation id is the numeric compat_id, which the REST API accepts too.
Service codes
waWhatsApptgTelegramviViberigInstagramfbFacebookgoGoogletwX / TwitterdsDiscordlfTikTokamAmazonmmMicrosoftmbYahoooiTinderubUbertsPayPalnfNetflixwbWeChatvkVKontakteokOdnoklassnikidheBaykaShopeefuSnapchatmtSteambwSignaldrOpenAImeLINEktKakaoTalkotAny other
Country ids
0Russia6Indonesia4Philippines10Vietnam16United Kingdom22India36Canada43Germany73Brazil187USA
Our catalogue ids and ISO country codes are accepted as well, so every product is reachable. getCountries and getServicesList are the authoritative lists. Keys, scopes, allowlists, spend caps, limits, prices, refunds, webhooks and metering are the same as for the REST API.
curl
BASE="https://smsgrab.com/api/stubs/handler_api.php?api_key=$SMSGRAB_API_KEY"
curl -s "$BASE&action=getBalance"
curl -s "$BASE&action=getNumber&service=tg&country=6"
curl -s "$BASE&action=getStatus&id=100000123"
curl -s "$BASE&action=setStatus&id=100000123&status=6"
Python
import os
import requests
BASE = "https://smsgrab.com/api/stubs/handler_api.php"
KEY = os.environ["SMSGRAB_API_KEY"]
def action(name: str, **params) -> str:
response = requests.get(BASE, params={"api_key": KEY, "action": name, **params}, timeout=30)
response.raise_for_status()
return response.text
print(action("getBalance"))
answer = action("getNumber", service="tg", country=6)
if answer.startswith("ACCESS_NUMBER:"):
_, activation_id, number = answer.split(":")
print(activation_id, number)
Complete examples
Buy a number, wait for the code and finish the activation, in five languages.
curl
export SMSGRAB_API_KEY="sgk_your_key"
curl -s https://smsgrab.com/api/dev/v1/balance \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
curl -s -X POST https://smsgrab.com/api/dev/v1/activations \
-H "Authorization: Bearer $SMSGRAB_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"service":"telegram","country":"indonesia","max_price_minor":30}'
curl -s "https://smsgrab.com/api/dev/v1/activations/100000123?wait=25" \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
curl -s -X POST https://smsgrab.com/api/dev/v1/activations/100000123/finish \
-H "Authorization: Bearer $SMSGRAB_API_KEY"
Python
import os
import uuid
import requests
API = "https://smsgrab.com/api/dev/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SMSGRAB_API_KEY']}"
def buy(service: str, country: str, max_price_minor: int) -> dict:
response = session.post(
f"{API}/activations",
json={"service": service, "country": country, "max_price_minor": max_price_minor},
headers={"Idempotency-Key": str(uuid.uuid4())},
timeout=30,
)
response.raise_for_status()
return response.json()
def wait_for_code(activation_id: str) -> str | None:
while True:
response = session.get(f"{API}/activations/{activation_id}", params={"wait": 25}, timeout=40)
response.raise_for_status()
activation = response.json()
if activation["sms"]:
return activation["sms"][-1]["code"]
if activation["status"] != "WAITING_SMS":
return None
activation = buy("telegram", "indonesia", 30)
print("Number:", activation["phone_number"])
code = wait_for_code(activation["id"])
if code:
print("Code:", code)
session.post(f"{API}/activations/{activation['id']}/finish", timeout=30).raise_for_status()
else:
print("No code arrived, the charge was refunded")
Node.js
import { randomUUID } from 'node:crypto';
const API = 'https://smsgrab.com/api/dev/v1';
async function call(path, { method = 'GET', body, idempotencyKey } = {}) {
const response = await fetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.SMSGRAB_API_KEY}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const data = response.status === 204 ? null : await response.json();
if (!response.ok) {
throw new Error(`${data?.code ?? response.status}: ${data?.message ?? ''}`);
}
return data;
}
let activation = await call('/activations', {
method: 'POST',
body: { service: 'telegram', country: 'indonesia', max_price_minor: 30 },
idempotencyKey: randomUUID(),
});
console.log('Number:', activation.phone_number);
while (activation.sms.length === 0 && activation.status === 'WAITING_SMS') {
activation = await call(`/activations/${activation.id}?wait=25`);
}
if (activation.sms.length > 0) {
console.log('Code:', activation.sms.at(-1).code);
await call(`/activations/${activation.id}/finish`, { method: 'POST' });
} else {
console.log('No code arrived, the charge was refunded');
}
PHP
<?php
require __DIR__.'/vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://smsgrab.com/api/dev/v1/',
'headers' => ['Authorization' => 'Bearer '.getenv('SMSGRAB_API_KEY')],
'timeout' => 40,
]);
$read = static fn ($response): array => json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
$activation = $read($client->post('activations', [
'json' => ['service' => 'telegram', 'country' => 'indonesia', 'max_price_minor' => 30],
'headers' => ['Idempotency-Key' => bin2hex(random_bytes(16))],
]));
echo 'Number: '.$activation['phone_number'].PHP_EOL;
while ($activation['sms'] === [] && $activation['status'] === 'WAITING_SMS') {
$activation = $read($client->get('activations/'.$activation['id'], ['query' => ['wait' => 25]]));
}
if ($activation['sms'] !== []) {
echo 'Code: '.$activation['sms'][array_key_last($activation['sms'])]['code'].PHP_EOL;
$client->post('activations/'.$activation['id'].'/finish');
} else {
echo 'No code arrived, the charge was refunded'.PHP_EOL;
}
Go
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
const api = "https://smsgrab.com/api/dev/v1"
type Activation struct {
ID string `json:"id"`
Status string `json:"status"`
PhoneNumber string `json:"phone_number"`
SMS []struct {
Code string `json:"code"`
} `json:"sms"`
}
var client = &http.Client{Timeout: 40 * time.Second}
func call(method, path string, body any, idempotencyKey string, out any) error {
var payload bytes.Buffer
if body != nil {
if err := json.NewEncoder(&payload).Encode(body); err != nil {
return err
}
}
request, err := http.NewRequest(method, api+path, &payload)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+os.Getenv("SMSGRAB_API_KEY"))
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
request.Header.Set("Idempotency-Key", idempotencyKey)
}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 300 {
var failure struct {
Code string `json:"code"`
Message string `json:"message"`
}
_ = json.NewDecoder(response.Body).Decode(&failure)
return fmt.Errorf("%s: %s", failure.Code, failure.Message)
}
if out == nil {
return nil
}
return json.NewDecoder(response.Body).Decode(out)
}
func idempotencyKey() string {
buffer := make([]byte, 16)
_, _ = rand.Read(buffer)
return hex.EncodeToString(buffer)
}
func main() {
var activation Activation
order := map[string]any{"service": "telegram", "country": "indonesia", "max_price_minor": 30}
if err := call("POST", "/activations", order, idempotencyKey(), &activation); err != nil {
panic(err)
}
fmt.Println("Number:", activation.PhoneNumber)
for len(activation.SMS) == 0 && activation.Status == "WAITING_SMS" {
if err := call("GET", "/activations/"+activation.ID+"?wait=25", nil, "", &activation); err != nil {
panic(err)
}
}
if len(activation.SMS) == 0 {
fmt.Println("No code arrived, the charge was refunded")
return
}
fmt.Println("Code:", activation.SMS[len(activation.SMS)-1].Code)
if err := call("POST", "/activations/"+activation.ID+"/finish", nil, "", nil); err != nil {
panic(err)
}
}
Versions and changes
We keep the API stable and tell you what changes.
- The current version is
2026-09-27; webhook payloads carry it inapi_version. - We add fields, events and error reasons without notice. Ignore fields and reasons you do not know.
- Breaking changes get a new path (
/dev/v2) and a long overlap. - The machine-readable description is part of the OpenAPI document at
https://smsgrab.com/api/v1/openapi.json.