API reference v1-beta

Quickstart

Order a number, poll for the code, release it. Plain HTTPS and JSON, bearer auth, no client library required. Base URL https://smsmetro.com/api/v1.

The API is v1-beta. Breaking changes are announced in advance but remain permitted until three external integrations have sustained thirty days of real traffic. We say so plainly rather than let you discover it later.

What you need

An API key from the dashboard, and a funded wallet — purchases place a hold, so an empty wallet returns INSUFFICIENT_BALANCE.

first_activation.sh
# 1 — buy a number
curl -s https://smsmetro.com/api/v1/activations \
  -H "Authorization: Bearer $SMSMETRO_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"service":"telegram","country":"GB"}'

# 2 — poll until the code lands
curl -s https://smsmetro.com/api/v1/activations/$ID \
  -H "Authorization: Bearer $SMSMETRO_KEY"

# 3 — confirm you used it
curl -s -X POST https://smsmetro.com/api/v1/activations/$ID/complete \
  -H "Authorization: Bearer $SMSMETRO_KEY"

Authentication

Every request carries a bearer token. Keys are created in the dashboard, shown once, and stored only as a hash.

Scopes

catalog:readCountries, services, prices, availability, offers
activations:readList activations, read one, read its messages
activations:writeBuy, cancel, complete
wallet:readBalance
stats:readUsage figures

Give each application only the scopes it uses — a leaked read key cannot spend money. Rotate by creating the new key, deploying, then revoking the old one; both work during the overlap.

Authorization header
Authorization: Bearer sk_live_4f2a…7f2a
Content-Type: application/json
Idempotency-Key: 9f8c1e02-…   # purchases only
401 · UNAUTHENTICATED
{
  "error": {
    "code": "UNAUTHENTICATED",
    "message": "Missing or invalid API key."
  }
}

Conventions

MoneyAlways an object. micro is an integer count of millionths of a dollar, as a string to survive JSON number precision.
IdentifiersOpaque strings — ofr_…, act_…. They decode to nothing; never parse them.
TimestampsISO-8601 with timezone, second precision. Two events inside one second have arbitrary relative order.
Unknown fieldsNew fields may appear in any response. Ignore what you do not recognise rather than failing.
Rate limitsPer key. A limited request returns 429 with Retry-After — honour it and back off. Polling every 2–3s is comfortable; sub-second is not.
price.micro is authoritative. price.display exists for rendering only — never parse it back into a number.
One Idempotency-Key per purchase intent, reused across retries of that same intent. A fresh key per click double-charges on a timeout retry; a shared key across different purchases returns IDEMPOTENCY_CONFLICT.
money object
"price": {
  "micro":   "280000",    // authoritative
  "display": "0.28"       // render only
}

// 280000 µUSD = $0.28

List countries

GET /v1/countries

Every country with routable inventory, as ISO-2 codes. Countries we cannot currently serve are omitted, so this list is shorter than a world atlas by design.

Requires scope catalog:read.

Responses

200 Success
Request
curl -s https://smsmetro.com/api/v1/countries \
  -H "Authorization: Bearer $SMSMETRO_KEY"
const res = await fetch("https://smsmetro.com/api/v1/countries", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${key}`,  },});
const { data } = await res.json();
r = requests.get(
    "https://smsmetro.com/api/v1/countries",
    headers={
        "Authorization": f"Bearer {key}",    },)
data = r.json()["data"]
200 · Response
{
  "data": [
    { "code": "GB", "name": "United Kingdom" },
    { "code": "US", "name": "United States" }
  ]
}

List services

GET /v1/services

The service catalogue. Names come from supplier labels and are sometimes bundled ("Google, Gmail, Youtube") — match on the whole string, not the first word. The list grows as the catalogue is mapped, so cache it briefly if at all.

Requires scope catalog:read.

Parameters

country optional
string
Restrict to services with inventory in one country.

Responses

200 Success
Request
curl -s https://smsmetro.com/api/v1/services?country=… \
  -H "Authorization: Bearer $SMSMETRO_KEY"
const res = await fetch("https://smsmetro.com/api/v1/services?country=…", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${key}`,  },});
const { data } = await res.json();
r = requests.get(
    "https://smsmetro.com/api/v1/services?country=…",
    headers={
        "Authorization": f"Bearer {key}",    },)
data = r.json()["data"]
200 · Response
{
  "data": [
    { "slug": "telegram", "name": "Telegram" },
    { "slug": "google", "name": "Google, Gmail, Youtube" }
  ]
}

Cheapest price per service

GET /v1/prices

One collapsed cheapest price per service. Stable and permanent — if all you need is "what does this cost", this is the right endpoint and it will not change.

Requires scope catalog:read.

Parameters

country required
string
ISO-2 country code.

Responses

200 Success
Request
curl -s https://smsmetro.com/api/v1/prices?country=… \
  -H "Authorization: Bearer $SMSMETRO_KEY"
const res = await fetch("https://smsmetro.com/api/v1/prices?country=…", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${key}`,  },});
const { data } = await res.json();
r = requests.get(
    "https://smsmetro.com/api/v1/prices?country=…",
    headers={
        "Authorization": f"Bearer {key}",    },)
data = r.json()["data"]
200 · Response
{
  "data": [
    {
      "service": "telegram",
      "price": { "micro": "280000", "display": "0.28" }
    }
  ]
}

Stock availability

GET /v1/availability

Availability bands per country and service. Bands rather than counts: an exact number would be stale before you read it, and would imply inventory is reserved when it is not. AVAILABLE means the supplier does not report stock counts — it is buyable.

Requires scope catalog:read.

Parameters

country required
string
ISO-2 country code.
service optional
string
Restrict to one service slug.

Responses

200 Success
Request
curl -s https://smsmetro.com/api/v1/availability?country=…&service=… \
  -H "Authorization: Bearer $SMSMETRO_KEY"
const res = await fetch("https://smsmetro.com/api/v1/availability?country=…&service=…", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${key}`,  },});
const { data } = await res.json();
r = requests.get(
    "https://smsmetro.com/api/v1/availability?country=…&service=…",
    headers={
        "Authorization": f"Bearer {key}",    },)
data = r.json()["data"]
200 · Response
{
  "data": [
    {
      "service": "telegram", "country": "GB",
      "availability": "HIGH"
    }
  ]
}

// NONE | LOW | MEDIUM | HIGH | AVAILABLE

List offers

GET /v1/offers

Every purchasable source for a country and service, already ranked. This is the endpoint behind manual buying: list the offers, then buy a specific one by offer_id. Render in the order returnedlabel describes an offer's position in this list, so sorting by it contradicts the ranking it is describing.

Requires scope catalog:read.

Parameters

country required
string
ISO-2 country code.
service required
string
Service slug.
product optional
string
ACTIVATION (default), RENTAL or VOICE.
max_price_micro optional
integer
Exclude offers above this customer price.
limit optional
integer
Default 50, maximum 100.

Responses

200 Success
Request
curl -s https://smsmetro.com/api/v1/offers?country=…&service=… \
  -H "Authorization: Bearer $SMSMETRO_KEY"
const res = await fetch("https://smsmetro.com/api/v1/offers?country=…&service=…", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${key}`,  },});
const { data } = await res.json();
r = requests.get(
    "https://smsmetro.com/api/v1/offers?country=…&service=…",
    headers={
        "Authorization": f"Bearer {key}",    },)
data = r.json()["data"]
200 · Response
{
  "data": [
    {
      "offer_id": "ofr_019f95bc-cb8b-702e-81c7…",
      "service": "telegram", "country": "GB",
      "product": "ACTIVATION",
      "label": "Best price",
      "kind": "OPERATOR",
      "price": { "micro": "280000", "display": "0.28" },
      "availability": "HIGH",
      "published_success_rate": 98.5,
      "historical_success_rate": 96.2,
      "historical_sample_size": 412
    }
  ],
  "price_valid_hint_seconds": 45
}

Buy a number

POST /v1/activations

Reserves a number and places a hold for its price. Nothing is captured until the activation completes. Omit offer_id and routing picks the source; include it to buy a specific offer.

Requires scope activations:write.

Parameters

service required
string
Service slug.
country required
string
ISO-2 country code.
offer_id optional
string
Present = manual buy. Absent = smart routing.
max_price optional
object
{ "micro": "…" } — the most you will pay, as the customer price.
routing_mode optional
string
Smart buy only: BALANCED, CHEAPEST, FASTEST, QUALITY.

Responses

200 Success
Request
curl -s -X POST https://smsmetro.com/api/v1/activations \
  -H "Authorization: Bearer $SMSMETRO_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"service":"telegram","country":"GB"}'
const res = await fetch("https://smsmetro.com/api/v1/activations", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${key}`,    "Idempotency-Key": intentKey,
    "Content-Type": "application/json",  },  body: JSON.stringify({ service: "telegram", country: "GB" }),});
const { data } = await res.json();
r = requests.post(
    "https://smsmetro.com/api/v1/activations",
    headers={
        "Authorization": f"Bearer {key}",        "Idempotency-Key": intent_key,    },    json={"service": "telegram", "country": "GB"},)
data = r.json()["data"]
200 · Response
{
  "data": {
    "id": "act_019fa456-3256-73e7-8090…",
    "state": "WAITING_SMS",
    "number": "+447700900123",
    "service": "telegram", "country": "GB",
    "price": { "micro": "280000", "display": "0.28" },
    "expires_at": "2026-07-31T10:32:00+00:00",
    "cancelable_at": "2026-07-31T10:22:00+00:00"
  }
}

Activation status

GET /v1/activations/{id}

State, number, price and expiry. Poll every two to three seconds while waiting, and stop at any terminal state. Look activations up by the id returned at purchase — never by "latest", since timestamps are second-precision and ordering within a second is arbitrary.

Requires scope activations:read.

Responses

200 Success
Request
curl -s https://smsmetro.com/api/v1/activations/{id} \
  -H "Authorization: Bearer $SMSMETRO_KEY"
const res = await fetch("https://smsmetro.com/api/v1/activations/{id}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${key}`,  },});
const { data } = await res.json();
r = requests.get(
    "https://smsmetro.com/api/v1/activations/{id}",
    headers={
        "Authorization": f"Bearer {key}",    },)
data = r.json()["data"]
200 · Response
{
  "data": {
    "id": "act_019fa456-3256-73e7-8090…",
    "state": "SMS_RECEIVED",
    "number": "+447700900123",
    "cancelable": true,
    "cancelable_at": "2026-07-31T10:22:00+00:00",
    "expires_at": "2026-07-31T10:32:00+00:00"
  }
}

// CREATED → WAITING_SMS → SMS_RECEIVED → COMPLETED
// terminal: CANCELLED · EXPIRED · FAILED

Messages received

GET /v1/activations/{id}/sms

Messages received on the number, oldest first, each with the extracted code. If a service uses a format we have not seen, code may be null while text is intact — always keep a fallback that reads the body.

Requires scope activations:read.

Responses

200 Success
Request
curl -s https://smsmetro.com/api/v1/activations/{id}/sms \
  -H "Authorization: Bearer $SMSMETRO_KEY"
const res = await fetch("https://smsmetro.com/api/v1/activations/{id}/sms", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${key}`,  },});
const { data } = await res.json();
r = requests.get(
    "https://smsmetro.com/api/v1/activations/{id}/sms",
    headers={
        "Authorization": f"Bearer {key}",    },)
data = r.json()["data"]
200 · Response
{
  "data": [
    {
      "code": "384102",
      "text": "384102 is your verification code.",
      "sender": "Telegram",
      "received_at": "2026-07-31T10:12:41+00:00"
    }
  ]
}

Cancel an activation

POST /v1/activations/{id}/cancel

Releases the number and returns the hold in full. Providers penalise instant cancellation, so a short window is enforced — read cancelable_at and wait for it. An early attempt returns INVALID_STATE.

Requires scope activations:write.

Responses

200 Success
Request
curl -s -X POST https://smsmetro.com/api/v1/activations/{id}/cancel \
  -H "Authorization: Bearer $SMSMETRO_KEY"
const res = await fetch("https://smsmetro.com/api/v1/activations/{id}/cancel", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${key}`,  },});
const { data } = await res.json();
r = requests.post(
    "https://smsmetro.com/api/v1/activations/{id}/cancel",
    headers={
        "Authorization": f"Bearer {key}",    },)
data = r.json()["data"]
200 · Response
{
  "data": {
    "id": "act_019fa456-3256-73e7-8090…",
    "state": "CANCELLED"
  }
}

Complete an activation

POST /v1/activations/{id}/complete

Confirms you have used the code. The hold is captured and the number released. This is the only moment money actually leaves your balance.

Requires scope activations:write.

Responses

200 Success
Request
curl -s -X POST https://smsmetro.com/api/v1/activations/{id}/complete \
  -H "Authorization: Bearer $SMSMETRO_KEY"
const res = await fetch("https://smsmetro.com/api/v1/activations/{id}/complete", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${key}`,  },});
const { data } = await res.json();
r = requests.post(
    "https://smsmetro.com/api/v1/activations/{id}/complete",
    headers={
        "Authorization": f"Bearer {key}",    },)
data = r.json()["data"]
200 · Response
{
  "data": {
    "id": "act_019fa456-3256-73e7-8090…",
    "state": "COMPLETED"
  }
}

Wallet balance

GET /v1/balance

Available and held balances. A successful cancel changes both, so treat any cached figure as stale the moment one succeeds.

Requires scope wallet:read.

Responses

200 Success
Request
curl -s https://smsmetro.com/api/v1/balance \
  -H "Authorization: Bearer $SMSMETRO_KEY"
const res = await fetch("https://smsmetro.com/api/v1/balance", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${key}`,  },});
const { data } = await res.json();
r = requests.get(
    "https://smsmetro.com/api/v1/balance",
    headers={
        "Authorization": f"Bearer {key}",    },)
data = r.json()["data"]
200 · Response
{
  "data": {
    "available": { "micro": "25000000", "display": "25.00" },
    "held":      { "micro": "280000",  "display": "0.28" }
  }
}

Webhooks

Take a signed callback instead of polling. Endpoints are configured in the dashboard, where a test-delivery button and the full delivery history live.

Events

activation.sms_receivedA message arrived. The one most integrations need.
activation.completedThe activation finished and the hold was captured.
activation.expiredNo code arrived; the hold was released.
activation.cancelledThe number was released early.

Verifying a delivery

Recompute the HMAC over the timestamp and the raw body — re-serialised JSON will not match. Compare in constant time and reject stale timestamps to block replays.

Delivery semantics

At least once. Retries continue with backoff until an endpoint answers 2xx, so the same event can arrive twice — make handlers idempotent on data.id plus event. Respond fast and do the work asynchronously; a slow endpoint is treated as a failed one.

Signature header
X-SMSMetro-Signature: t=1722070000,v1=<hmac>

hmac = HMAC_SHA256(secret, "{t}.{raw_body}")
verify.php
$parts = [];
foreach (explode(',', $header) as $s) {
    [$k, $v] = explode('=', trim($s), 2);
    $parts[$k] = $v;
}

if (abs(time() - (int) $parts['t']) > 300) abort(400);

$expected = hash_hmac(
    'sha256', $parts['t'].'.'.$rawBody, $secret
);
if (! hash_equals($expected, $parts['v1'])) abort(400);
Payload
{
  "event": "activation.sms_received",
  "sent_at": "2026-07-31T10:12:41+00:00",
  "data": {
    "id": "act_019fa456-…",
    "state": "SMS_RECEIVED",
    "code": "384102"
  }
}

Errors

Errors are JSON: { "error": { "code": "…", "message": "…" } }. Branch on code and show message to humans — messages may be reworded, codes are the contract. Every code below is link-stable at /docs/errors#the-code.

Three shapes worth handling separately: 4xx that will never succeed as sent (fix the request), 409 conflicts reflecting a world that moved (refresh and decide again), and 5xx or timeouts — retry the same Idempotency-Key, never a fresh one.

UNAUTHENTICATED 401 Missing, malformed or revoked API key.
ACCOUNT_SUSPENDED 403 The account cannot transact. Contact support.
SERVICE_RESTRICTED 403 The service exists but is not purchasable on your account.
NOT_FOUND 404 No such resource, or it belongs to another account.
UNKNOWN_SERVICE 422 The service slug does not exist in the catalogue.
IDEMPOTENCY_KEY_REQUIRED 422 Purchases require an Idempotency-Key header.
NO_STOCK 409 No routable inventory for that country and service right now.
PRICE_CEILING_UNMEETABLE 409 No source exists at or below your max_price.
OFFER_UNAVAILABLE 409 The chosen offer sold out, expired, or its source was pulled from routing. Nothing charged — refresh and choose again. We never substitute.
PRICE_MOVED 409 The price rose above your max_price between listing and buying. Nothing charged.
INSUFFICIENT_BALANCE 409 The wallet cannot cover the hold. Fund it and retry with the same Idempotency-Key.
INVALID_STATE 409 Not valid for the activation's current state — e.g. cancelling inside the provider's window.
IDEMPOTENCY_IN_PROGRESS 409 The same key is still being processed. Wait and poll; do not resend.
IDEMPOTENCY_UNRESOLVED 409 An earlier attempt with this key died mid-flight. Check your activations list.
IDEMPOTENCY_CONFLICT 409 Same key, different body: a client bug. One key per purchase intent.

SDKs

Official SDKs are planned and not yet published. The surface is deliberately small enough that plain HTTP is a first-class integration path, and we would rather ship no SDK than an unmaintained one. When they publish they will be listed here, and nothing above will change.

© 2026 SMSMetro, Inc. · smsmetro.com

Minimal client
const smsmetro = (key) => ({
  buy: (service, country, intentKey) =>
    fetch("https://smsmetro.com/api/v1/activations", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Idempotency-Key": intentKey,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ service, country }),
    }).then((r) => r.json()),
});