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.
An API key from the dashboard, and a funded wallet — purchases place a hold, so an empty wallet returns INSUFFICIENT_BALANCE.
# 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"
Every request carries a bearer token. Keys are created in the dashboard, shown once, and stored only as a hash.
| catalog:read | Countries, services, prices, availability, offers |
| activations:read | List activations, read one, read its messages |
| activations:write | Buy, cancel, complete |
| wallet:read | Balance |
| stats:read | Usage 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: Bearer sk_live_4f2a…7f2a Content-Type: application/json Idempotency-Key: 9f8c1e02-… # purchases only
{
"error": {
"code": "UNAUTHENTICATED",
"message": "Missing or invalid API key."
}
}
| Money | Always an object. micro is an integer count of millionths of a dollar, as a string to survive JSON number precision. |
| Identifiers | Opaque strings — ofr_…, act_…. They decode to nothing; never parse them. |
| Timestamps | ISO-8601 with timezone, second precision. Two events inside one second have arbitrary relative order. |
| Unknown fields | New fields may appear in any response. Ignore what you do not recognise rather than failing. |
| Rate limits | Per 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.
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.
"price": {
"micro": "280000", // authoritative
"display": "0.28" // render only
}
// 280000 µUSD = $0.28
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.
200 Success401 UNAUTHENTICATED404 NOT_FOUNDcurl -s https://smsmetro.com/api/v1/countries \ -H "Authorization: Bearer $SMSMETRO_KEY"
{
"data": [
{ "code": "GB", "name": "United Kingdom" },
{ "code": "US", "name": "United States" }
]
}
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.
|
country
optional string
|
Restrict to services with inventory in one country. |
200 Success401 UNAUTHENTICATED404 NOT_FOUNDcurl -s https://smsmetro.com/api/v1/services?country=… \ -H "Authorization: Bearer $SMSMETRO_KEY"
{
"data": [
{ "slug": "telegram", "name": "Telegram" },
{ "slug": "google", "name": "Google, Gmail, Youtube" }
]
}
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.
|
country
required string
|
ISO-2 country code. |
200 Success401 UNAUTHENTICATED404 NOT_FOUNDcurl -s https://smsmetro.com/api/v1/prices?country=… \ -H "Authorization: Bearer $SMSMETRO_KEY"
{
"data": [
{
"service": "telegram",
"price": { "micro": "280000", "display": "0.28" }
}
]
}
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.
|
country
required string
|
ISO-2 country code. |
|
service
optional string
|
Restrict to one service slug. |
200 Success401 UNAUTHENTICATED404 NOT_FOUNDcurl -s https://smsmetro.com/api/v1/availability?country=…&service=… \ -H "Authorization: Bearer $SMSMETRO_KEY"
{
"data": [
{
"service": "telegram", "country": "GB",
"availability": "HIGH"
}
]
}
// NONE | LOW | MEDIUM | HIGH | AVAILABLE
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 returned — label describes an offer's position in this list, so sorting by it contradicts the ranking it is describing.
Requires scope catalog:read.
|
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. |
200 Success401 UNAUTHENTICATED404 NOT_FOUNDcurl -s https://smsmetro.com/api/v1/offers?country=…&service=… \ -H "Authorization: Bearer $SMSMETRO_KEY"
{
"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
}
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.
|
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. |
200 Success401 UNAUTHENTICATED404 NOT_FOUNDcurl -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"}'
{
"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"
}
}
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.
200 Success401 UNAUTHENTICATED404 NOT_FOUNDcurl -s https://smsmetro.com/api/v1/activations/{id} \
-H "Authorization: Bearer $SMSMETRO_KEY"
{
"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 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.
200 Success401 UNAUTHENTICATED404 NOT_FOUNDcurl -s https://smsmetro.com/api/v1/activations/{id}/sms \
-H "Authorization: Bearer $SMSMETRO_KEY"
{
"data": [
{
"code": "384102",
"text": "384102 is your verification code.",
"sender": "Telegram",
"received_at": "2026-07-31T10:12:41+00:00"
}
]
}
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.
200 Success401 UNAUTHENTICATED409 INVALID_STATE404 NOT_FOUNDcurl -s -X POST https://smsmetro.com/api/v1/activations/{id}/cancel \
-H "Authorization: Bearer $SMSMETRO_KEY"
{
"data": {
"id": "act_019fa456-3256-73e7-8090…",
"state": "CANCELLED"
}
}
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.
200 Success401 UNAUTHENTICATED409 INVALID_STATE404 NOT_FOUNDcurl -s -X POST https://smsmetro.com/api/v1/activations/{id}/complete \
-H "Authorization: Bearer $SMSMETRO_KEY"
{
"data": {
"id": "act_019fa456-3256-73e7-8090…",
"state": "COMPLETED"
}
}
Available and held balances. A successful cancel changes both, so treat any cached figure as stale the moment one succeeds.
Requires scope wallet:read.
200 Success401 UNAUTHENTICATED404 NOT_FOUNDcurl -s https://smsmetro.com/api/v1/balance \ -H "Authorization: Bearer $SMSMETRO_KEY"
{
"data": {
"available": { "micro": "25000000", "display": "25.00" },
"held": { "micro": "280000", "display": "0.28" }
}
}
Take a signed callback instead of polling. Endpoints are configured in the dashboard, where a test-delivery button and the full delivery history live.
| activation.sms_received | A message arrived. The one most integrations need. |
| activation.completed | The activation finished and the hold was captured. |
| activation.expired | No code arrived; the hold was released. |
| activation.cancelled | The number was released early. |
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.
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.
X-SMSMetro-Signature: t=1722070000,v1=<hmac>
hmac = HMAC_SHA256(secret, "{t}.{raw_body}")
$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);
{
"event": "activation.sms_received",
"sent_at": "2026-07-31T10:12:41+00:00",
"data": {
"id": "act_019fa456-…",
"state": "SMS_RECEIVED",
"code": "384102"
}
}
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.
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
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()),
});