# SMSZ API Documentation

> Programmatic access to SMSZ virtual numbers: buy single-use numbers for SMS
> verification, rent numbers long term, and receive incoming messages by webhook.

Version: 2026-07-01
Base URL: https://www.smsz.net/api/v1
OpenAPI: https://www.smsz.net/api/v1/openapi.json

## Introduction
The SMSZ API gives you programmatic access to everything the website does: buy a number for a single SMS verification, rent a number for days or months, read incoming messages, and get pushed a webhook the moment a code arrives.

**Base URL**

```
https://www.smsz.net/api/v1
```

Everything is JSON over HTTPS. All amounts are in **USD**, all timestamps are **ISO 8601 in UTC**, and every purchase is charged against your account balance — top it up from the website before your first call.

The current API version is `2026-07-01`, returned on every response as the `SMSZ-Version` header. Additive changes (new fields, new endpoints, new event types) ship without a version bump, so **write clients that ignore unknown fields**.

**Two products**

| | Activations | Rentals |
|---|---|---|
| Purpose | One verification code | Ongoing use of a number |
| Lifetime | ~15-20 minutes | 1 day to 12 months |
| Endpoint | `POST /activations` | `POST /rentals` |
| Messages | Usually one | Unlimited for the period |

## Authentication
Every request carries an API key as a bearer token:

```
Authorization: Bearer smsz_live_9f2a1c4d_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

**Creating a key.** Sign in at [smsz.net](https://www.smsz.net), open the account menu and choose **API keys**. The full key is shown **once, at creation** — we store only a hash of it, so if you lose it you must create a new one.

**Scopes.** Each key carries an explicit list of permissions. Grant only what an integration needs: a server that just buys numbers has no reason to hold `webhooks:write`.

- `account:read`
- `activations:read`
- `activations:write`
- `rentals:read`
- `rentals:write`
- `webhooks:read`
- `webhooks:write`

A call missing the scope it needs fails with `insufficient_scope` and names the missing scope.

**IP allowlist.** A key can be pinned to one or more IPv4 addresses or CIDR ranges. Requests from anywhere else are refused with `ip_not_allowed`. Worth doing for keys that live on a fixed server.

**Keeping keys safe.** Keys are bearer credentials — anyone holding one can spend your balance. Keep them server-side. Never ship one in a browser bundle, a mobile app, or a public repository. If a key leaks, revoke it in the dashboard; revocation takes effect immediately.

Verify a new key with:

```bash
curl https://www.smsz.net/api/v1/ping \
  -H "Authorization: Bearer $SMSZ_API_KEY"
```

## Quickstart
Buying a number and reading its code takes two calls.

**1. Buy the number**

```bash
curl -X POST https://www.smsz.net/api/v1/activations \
  -H "Authorization: Bearer $SMSZ_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"country": "US", "service": "telegram"}'
```

```json
{
  "id": "cmg7x2k9a0001l208hq3v7bqz",
  "object": "activation",
  "status": "pending",
  "phone_number": "+12025550147",
  "price": 0.62,
  "expires_at": "2026-07-24T10:30:03.000Z",
  "messages": []
}
```

Use `phone_number` wherever you are signing up. Your balance is debited immediately; if the provider cannot fill the order you are not charged at all.

**2. Get the code**

```bash
curl https://www.smsz.net/api/v1/activations/cmg7x2k9a0001l208hq3v7bqz/messages \
  -H "Authorization: Bearer $SMSZ_API_KEY"
```

```json
{
  "object": "list",
  "data": [
    {
      "id": "cmg7xb1s70006l208r9y2mnop",
      "object": "message",
      "sender": "Telegram",
      "text": "Telegram code 51284",
      "code": "51284",
      "received_at": "2026-07-24T10:16:44.000Z"
    }
  ],
  "has_more": false,
  "total": 1
}
```

Poll every 3-5 seconds until `data` is non-empty, or [use a webhook](#webhooks) and skip polling entirely. `code` is the digits we extracted; `text` is the full message if the extraction misses.

**3. Finish**

```bash
curl -X POST https://www.smsz.net/api/v1/activations/cmg7x2k9a0001l208hq3v7bqz/finish \
  -H "Authorization: Bearer $SMSZ_API_KEY"
```

Not required, but it releases the number early. If no code ever arrived, finishing refunds you — as does letting the activation expire on its own.

**A complete example**

```javascript
const API = "https://www.smsz.net/api/v1";
const headers = {
  Authorization: `Bearer ${process.env.SMSZ_API_KEY}`,
  "Content-Type": "application/json",
};

async function getVerificationCode(country, service) {
  const created = await fetch(`${API}/activations`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({ country, service }),
  });

  if (!created.ok) {
    const { error } = await created.json();
    throw new Error(`${error.code}: ${error.message}`);
  }

  const activation = await created.json();
  console.log("Use this number:", activation.phone_number);

  const deadline = Date.parse(activation.expires_at);
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, 4000));

    const res = await fetch(`${API}/activations/${activation.id}/messages`, { headers });
    const { data } = await res.json();
    if (data.length > 0) return data[0].code;
  }

  throw new Error("No SMS arrived before the activation expired");
}
```

```python
import os, time, uuid, requests

API = "https://www.smsz.net/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SMSZ_API_KEY']}"

def get_verification_code(country: str, service: str) -> str:
    response = session.post(
        f"{API}/activations",
        json={"country": country, "service": service},
        headers={"Idempotency-Key": str(uuid.uuid4())},
    )
    if not response.ok:
        error = response.json()["error"]
        raise RuntimeError(f"{error['code']}: {error['message']}")

    activation = response.json()
    print("Use this number:", activation["phone_number"])

    for _ in range(60):
        time.sleep(4)
        messages = session.get(f"{API}/activations/{activation['id']}/messages").json()
        if messages["data"]:
            return messages["data"][0]["code"]

    raise TimeoutError("No SMS arrived before the activation expired")
```

## Activations
An activation is a number rented for a single verification. It lives for roughly 15-20 minutes depending on the provider, and `expires_at` tells you exactly when.

**Choosing what to buy.** `country` accepts an ISO code, our slug, or the country name — `US`, `united-states` and `United States` all work. `service` is a slug from `GET /services`. Check price and stock first with `GET /pricing/activations?country=US&service=telegram`.

Omit `operator` and we pick the cheapest one with stock. Omit `provider` too — pinning a provider only narrows what we can fill the order from.

**Status.**

| Status | Meaning |
|---|---|
| `pending` | Live and waiting for an SMS |
| `completed` | A message arrived, or you finished it |
| `expired` | The window closed with no message — refunded |
| `cancelled` | You cancelled it |
| `refunded` | The charge was returned |

**Refunds are automatic.** You are never charged for an activation that received nothing. If the window closes empty, the balance goes back on its own. Cancelling early does the same thing sooner. Once a message arrives the activation has done its job and is not refundable.

**Errors worth handling.** `insufficient_balance` (402) means top up. `number_unavailable` (409) means that country and service pair has no stock right now — try another country, or check `GET /pricing/activations` for what is actually available.

## Rentals
A rental holds a number for days or months and receives unlimited messages for the period.

**Ordering.** Rentals are bought against an *offer* from `GET /pricing/rentals`, because availability and price vary by country and length:

```bash
curl "https://www.smsz.net/api/v1/pricing/rentals?country=GB&days=30" \
  -H "Authorization: Bearer $SMSZ_API_KEY"
```

```json
{
  "object": "list",
  "data": [
    {
      "offer_id": "o1.Xn9pQ2s.7Kd1fA.k3mZq0vR8tYw",
      "country": "GB",
      "country_name": "United Kingdom",
      "duration_days": 30,
      "price": 14.5,
      "currency": "USD",
      "available": 62
    }
  ]
}
```

Pass that row's `offer_id` straight back — it already fixes the country, the length and the price:

```bash
curl -X POST https://www.smsz.net/api/v1/rentals \
  -H "Authorization: Bearer $SMSZ_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"offer_id": "o1.Xn9pQ2s.7Kd1fA.k3mZq0vR8tYw"}'
```

`offer_id` is opaque and short-lived: treat it as a token to round-trip, not a value to parse or store. It expires after **30 minutes**, so fetch offers immediately before ordering rather than caching them. An expired or altered token is rejected with `invalid_parameter` — fetch a fresh one and retry.

**Per-service rentals.** Adding `service` rents one service on the number rather than the whole number. Cheaper when you only need one platform.

**Extending.** `POST /rentals/{id}/extend` with `{"days": 30}` adds time and charges your balance. Extend before `expires_at` — an expired rental cannot be revived, only replaced.

**Cancelling.** Rentals are refundable **within 120 minutes of purchase and only if no message has been received** — that is the window our providers give us, so it is the window we can offer. Outside it the call returns `not_cancellable`.

**Messages.** Use `GET /rentals/{id}/messages` for the full history, or subscribe to `rental.message.received` and be pushed each one. Rentals often run for weeks, so webhooks are strongly preferred over polling here.

## Webhooks
Webhooks push events to your server as they happen, so you do not have to poll. This is the recommended way to consume the API.

**Register an endpoint**

```bash
curl -X POST https://www.smsz.net/api/v1/webhooks/endpoints \
  -H "Authorization: Bearer $SMSZ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/smsz",
    "events": ["activation.message.received", "rental.message.received"]
  }'
```

The response contains a `secret` starting `whsec_`. **This is the only time it is returned.** Store it — it is what proves a delivery came from us.

Subscribe to `["*"]` for everything, or use prefixes like `["rental.*"]` for a family of events.

**Payload shape**

```json
{
  "id": "cmg7xh2k4000cl208a1b2c3d4",
  "object": "event",
  "type": "activation.message.received",
  "api_version": "2026-07-01",
  "created": 1784889404,
  "data": {
    "id": "cmg7x2k9a0001l208hq3v7bqz",
    "object": "activation",
    "status": "completed",
    "phone_number": "+12025550147",
    "messages": [
      { "id": "cmg7xb1s70006l208r9y2mnop", "object": "message", "sender": "Telegram", "text": "Telegram code 51284", "code": "51284", "received_at": "2026-07-24T10:16:44.000Z" }
    ]
  }
}
```

`data` is the same object the REST endpoints return, so one deserializer handles both.

**Verifying signatures**

Every delivery carries a `SMSZ-Signature` header:

```
SMSZ-Signature: t=1784889404,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`v1` is HMAC-SHA256 of `{timestamp}.{raw request body}`, keyed with your endpoint secret. Verify it on **the raw body, before any JSON parsing** — re-serializing changes the bytes and the signature will not match.

```javascript
import crypto from "node:crypto";

function verifySmszWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=").map((s) => s.trim()))
  );

  // Reject old deliveries so a captured payload cannot be replayed later.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

// Express — note express.raw(), not express.json()
app.post("/hooks/smsz", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySmszWebhook(req.body.toString(), req.get("SMSZ-Signature"), process.env.SMSZ_WEBHOOK_SECRET)) {
    return res.status(400).send("bad signature");
  }

  const event = JSON.parse(req.body.toString());

  // Acknowledge first, work afterwards: we retry anything that is not a 2xx.
  res.status(200).send("ok");
  handleEvent(event).catch(console.error);
});
```

```python
import hmac, hashlib, time
from flask import Flask, request, abort

def verify_smsz_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in signature_header.split(","))

    # Reject old deliveries so a captured payload cannot be replayed later.
    if abs(time.time() - int(parts["t"])) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(), f"{parts['t']}.{raw_body.decode()}".encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, parts["v1"])

@app.post("/hooks/smsz")
def smsz_webhook():
    if not verify_smsz_webhook(request.get_data(), request.headers["SMSZ-Signature"], SECRET):
        abort(400)

    event = request.get_json()
    enqueue(event)   # acknowledge fast, process out of band
    return "", 200
```

**Delivery rules**

- Any **2xx** is an acknowledgement. Anything else is retried.
- Retries: **8 attempts** at 10s → 30s → 2m → 10m → 30m → 2h → 6h → 12h — just over 24 hours in total.
- Deliveries time out after **10 seconds**, so acknowledge immediately and do the work asynchronously.
- Redirects are **not** followed. If your URL moves, update the endpoint.
- After **20 consecutive failures** an endpoint is disabled. Re-enable it with `PATCH /webhooks/endpoints/{id}` and `{"status": "active"}`.
- Delivery is **at-least-once** and ordering is **not guaranteed**. Deduplicate on the event `id`, and prefer the state in `data` over inferring it from event sequence.

**Debugging.** `POST /webhooks/endpoints/{id}/test` sends a real signed event with obviously fake data and reports what your server answered. `GET /webhooks/deliveries` is the delivery log: status, response code, attempt count and next retry.

**No public endpoint?** Every event is also readable from `GET /events`. Poll it with `after` set to the last event id you handled. Events are retained for 30 days.

**Event types**

- `activation.created` — An activation was purchased and its number is ready to receive SMS.
- `activation.message.received` — An SMS arrived on an activation. The extracted verification code is in `data.messages[0].code` when one could be parsed.
- `activation.completed` — An activation was marked finished and will receive no further messages.
- `activation.cancelled` — An activation was cancelled before use and the balance was refunded.
- `activation.expired` — An activation reached its expiry window without receiving an SMS.
- `activation.refunded` — The balance for an activation was returned to the account.
- `rental.created` — A long-term rental was ordered. It may still be provisioning.
- `rental.activated` — A rental finished provisioning and is now live.
- `rental.message.received` — An SMS arrived on a rental number.
- `rental.extended` — A rental was extended and its expiry moved forward.
- `rental.expiring` — A rental expires within 24 hours.
- `rental.expired` — A rental reached the end of its period and stopped receiving messages.
- `rental.cancelled` — A rental was cancelled.
- `balance.updated` — The account balance changed.

## Errors
Every failure returns the same envelope with a conventional HTTP status:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "insufficient_balance",
    "message": "Insufficient balance. Required: 0.62, Available: 0.10",
    "doc_url": "https://www.smsz.net/api#errors",
    "request_id": "req_4f1c8a90b2d34e5f6a7b8c9d"
  }
}
```

**Branch on `code`, not on `message`.** Codes are stable; wording is not. `param` names the offending field when the error is about one.

Every response — success or failure — carries a `SMSZ-Request-Id` header. Log it. Quoting it lets support find the exact request in seconds.

**Which errors to retry**

| Status | Retry? |
|---|---|
| 400, 401, 402, 403, 404, 422 | No. Fix the request, the key, or your balance. |
| 409 | Only `idempotency_request_in_progress`. The rest are state conflicts. |
| 429 | Yes, after `Retry-After`. |
| 5xx | Yes, with exponential backoff — and reuse the same `Idempotency-Key`. |

## Rate limits
Limits are per API key, per minute:

| Bucket | Limit | Applies to |
|---|---|---|
| General | 120/min | Reads and catalogue calls |
| Purchase | 20/min | Creating, cancelling and extending |
| Webhook test | 10/min | `POST /webhooks/endpoints/{id}/test` |

Every response carries your current standing:

```
RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 1784889460
```

Exceeding a limit returns **429** with `Retry-After` in seconds. Wait that long rather than retrying tighter — hammering a 429 only extends it.

Need more? Email support@smsz.net with your key name and expected volume; the general limit is adjustable per key.

## Idempotency
Network failures are ambiguous: a request that times out may or may not have bought a number. Send an `Idempotency-Key` on anything that spends money and retrying becomes safe.

```bash
curl -X POST https://www.smsz.net/api/v1/activations \
  -H "Authorization: Bearer $SMSZ_API_KEY" \
  -H "Idempotency-Key: 3f9a1b7c-5d2e-4a8f-9c1b-2e7d4a6f8b3c" \
  -H "Content-Type: application/json" \
  -d '{"country": "US", "service": "telegram"}'
```

Retry with the same key and you get **the original response replayed**, marked `Idempotent-Replayed: true`. No second purchase, no second charge.

- Use a fresh UUID per logical operation. Keys are remembered for **24 hours**.
- Reusing a key with a *different* body returns `idempotency_key_reused` (422) — that is nearly always a bug where a constant was used instead of a per-request value.
- Retrying while the first attempt is still running returns `idempotency_request_in_progress` (409). Wait a moment and try again.
- Only successful responses are stored. A failed request can be retried with the same key.

Supported on `POST /activations`, `POST /rentals` and `POST /rentals/{id}/extend`.

## Pagination
List endpoints return a consistent envelope:

```json
{
  "object": "list",
  "data": [],
  "has_more": true,
  "total": 214
}
```

Page with `limit` (1-100, default 25) and `offset`:

```bash
curl "https://www.smsz.net/api/v1/activations?limit=50&offset=50" \
  -H "Authorization: Bearer $SMSZ_API_KEY"
```

Lists are always newest-first. `GET /events` additionally accepts `after=<event id>`, which is the right way to consume it as a stream — offsets shift as new events land, but a cursor does not.

## For AI agents
Machine-readable descriptions of this API:

| Resource | URL |
|---|---|
| OpenAPI 3.1 specification | [`/api/v1/openapi.json`](/api/v1/openapi.json) |
| These docs as one Markdown file | [`/api/llms-full.txt`](/api/llms-full.txt) |
| Short index for model context | [`/llms.txt`](/llms.txt) |

The OpenAPI document is unauthenticated, so client generators and agents can read it before a key exists.

**Notes for autonomous callers**

- Read `GET /pricing/activations` before buying. Prices and stock move constantly; do not assume a country and service pair is available.
- Always send an `Idempotency-Key` on purchases. If a call fails ambiguously, retry with the *same* key rather than issuing a new purchase.
- Treat 402 `insufficient_balance` as terminal — a human has to top up. Do not retry it.
- Respect `Retry-After` on 429. Do not retry 4xx errors other than 429.
- An activation costs real money on every successful `POST /activations`. There is no test mode; do not call it to "check whether it works" — use `GET /ping` for that.

## Endpoint reference

## Account

### `GET /ping`

**Verify your API key** — Confirms a key is valid and reports which account and scopes it carries. Make this your first call when setting up an integration.

Scope: `account:read`

Response:

```json
{
  "object": "ping",
  "ok": true,
  "api_version": "2026-07-01",
  "account_email": "you@example.com",
  "key_name": "Production server",
  "scopes": [
    "account:read",
    "activations:write"
  ]
}
```

### `GET /account`

**Retrieve your account** — Returns your current balance and how many activations and rentals are live.

Scope: `account:read`

Response:

```json
{
  "object": "account",
  "id": "cmg7w1a2b0000l208ff11aaaa",
  "email": "you@example.com",
  "username": null,
  "balance": 42.75,
  "currency": "USD",
  "created_at": "2026-01-04T08:00:00.000Z",
  "active_activations": 1,
  "active_rentals": 2
}
```

### `GET /transactions`

**List transactions** — Your ledger: purchases, refunds and deposits, newest first.

Scope: `account:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `type` | query | no | Filter by transaction type. |
| `status` | query | no | Filter by status. |
| `limit` | query | no | Page size, 1-100. |
| `offset` | query | no | Rows to skip. |

Response:

```json
{
  "object": "list",
  "data": [
    {
      "id": "cmg7xd4u90008l208p7q1rstu",
      "object": "transaction",
      "type": "sms_purchase",
      "status": "completed",
      "amount": -0.62,
      "currency": "USD",
      "description": "SMS purchase for telegram (US)",
      "created_at": "2026-07-24T10:15:03.000Z"
    }
  ],
  "has_more": false,
  "total": 1
}
```

## Catalogue

### `GET /countries`

**List countries** — Every country we sell numbers in. Use `code` when creating an activation.

Scope: `activations:read`

Response:

```json
{
  "object": "list",
  "data": [
    {
      "code": "US",
      "name": "United States",
      "full_name": "United States of America",
      "slug": "united-states",
      "phone_code": "+1",
      "continent": "north_america"
    }
  ],
  "has_more": false,
  "total": 1
}
```

### `GET /services`

**List services** — Every service you can verify. Use `slug` when creating an activation.

Scope: `activations:read`

Response:

```json
{
  "object": "list",
  "data": [
    {
      "slug": "telegram",
      "name": "Telegram",
      "full_name": "Telegram Messenger",
      "popular": true
    }
  ],
  "has_more": false,
  "total": 1
}
```

### `GET /pricing/activations`

**Live activation prices** — Current prices and stock, queried live from our providers. You must pass `country`, `service`, or both — an unfiltered sweep would price every country against every service.

Scope: `activations:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `country` | query | no | Country code, slug or name, e.g. `US`. |
| `service` | query | no | Service slug, e.g. `telegram`. |

Response:

```json
{
  "object": "list",
  "data": [
    {
      "country": "US",
      "country_name": "United States",
      "service": "telegram",
      "service_name": "Telegram",
      "price": 0.62,
      "currency": "USD",
      "available": 418,
      "success_rate": 92
    }
  ],
  "has_more": false,
  "total": 1
}
```

### `GET /pricing/rentals`

**Live rental offers** — Purchasable long-term rental offers. Pass an offer’s `offer_id` straight to `POST /rentals` — it is an opaque, short-lived token that carries everything needed to fill the order.

Scope: `rentals:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `country` | query | no | Filter by ISO country code. |
| `days` | query | no | Filter by rental length in days. |

Response:

```json
{
  "object": "list",
  "data": [
    {
      "offer_id": "o1.Xn9pQ2s.7Kd1fA.k3mZq0vR8tYw",
      "country": "GB",
      "country_name": "United Kingdom",
      "duration_days": 30,
      "price": 14.5,
      "currency": "USD",
      "available": 62
    }
  ],
  "has_more": false,
  "total": 1
}
```

## Activations

### `POST /activations`

**Buy an activation** — Buys a number for one verification and debits your balance. If the provider cannot fill the order, nothing is charged. Poll `/activations/{id}/messages` for the code, or subscribe to `activation.message.received`.

Scope: `activations:write` · Supports `Idempotency-Key` · Purchase rate limit

| Body field | Type | Required | Description |
|---|---|---|---|
| `country` | string | yes | Country code, slug or name, e.g. `US`. |
| `service` | string | yes | Service slug, e.g. `telegram`. |
| `operator` | string | no | Optional operator. Omit to let us pick the cheapest available. |

Request:

```json
{
  "country": "US",
  "service": "telegram"
}
```

Response:

```json
{
  "id": "cmg7x2k9a0001l208hq3v7bqz",
  "object": "activation",
  "status": "pending",
  "phone_number": "+12025550147",
  "country": "US",
  "country_name": "United States",
  "service": "telegram",
  "service_name": "Telegram",
  "operator": "any",
  "price": 0.62,
  "currency": "USD",
  "created_at": "2026-07-24T10:15:03.000Z",
  "expires_at": "2026-07-24T10:30:03.000Z",
  "messages": []
}
```

### `GET /activations`

**List activations** — Your activations, newest first, each with any messages received.

Scope: `activations:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `status` | query | no | Filter by status. |
| `limit` | query | no | Page size, 1-100. |
| `offset` | query | no | Rows to skip. |

Response:

```json
{
  "object": "list",
  "data": [
    {
      "id": "cmg7x2k9a0001l208hq3v7bqz",
      "object": "activation",
      "status": "pending",
      "phone_number": "+12025550147",
      "country": "US",
      "country_name": "United States",
      "service": "telegram",
      "service_name": "Telegram",
      "operator": "any",
      "price": 0.62,
      "currency": "USD",
      "created_at": "2026-07-24T10:15:03.000Z",
      "expires_at": "2026-07-24T10:30:03.000Z",
      "messages": []
    }
  ],
  "has_more": false,
  "total": 1
}
```

### `GET /activations/{id}`

**Retrieve an activation** — One activation and its messages.

Scope: `activations:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Activation id. |

Response:

```json
{
  "id": "cmg7x2k9a0001l208hq3v7bqz",
  "object": "activation",
  "status": "pending",
  "phone_number": "+12025550147",
  "country": "US",
  "country_name": "United States",
  "service": "telegram",
  "service_name": "Telegram",
  "operator": "any",
  "price": 0.62,
  "currency": "USD",
  "created_at": "2026-07-24T10:15:03.000Z",
  "expires_at": "2026-07-24T10:30:03.000Z",
  "messages": []
}
```

### `GET /activations/{id}/messages`

**Poll for the code** — Asks the provider directly, so a message that has not reached us by webhook yet still appears. Poll every 3-5 seconds; webhooks are the better integration if you can host an endpoint.

Scope: `activations:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Activation id. |

Response:

```json
{
  "object": "list",
  "data": [
    {
      "id": "cmg7xb1s70006l208r9y2mnop",
      "object": "message",
      "sender": "Telegram",
      "text": "Telegram code 51284",
      "code": "51284",
      "received_at": "2026-07-24T10:16:44.000Z"
    }
  ],
  "has_more": false,
  "total": 1
}
```

### `POST /activations/{id}/cancel`

**Cancel an activation** — Cancels an unused activation and refunds it. Once a message has arrived the activation has delivered what it was bought for, so the cancellation succeeds with `refund_amount: 0`.

Scope: `activations:write` · Purchase rate limit

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Activation id. |

Response:

```json
{
  "id": "cmg7x2k9a0001l208hq3v7bqz",
  "object": "activation",
  "status": "refunded",
  "phone_number": "+12025550147",
  "country": "US",
  "country_name": "United States",
  "service": "telegram",
  "service_name": "Telegram",
  "operator": "any",
  "price": 0.62,
  "currency": "USD",
  "created_at": "2026-07-24T10:15:03.000Z",
  "expires_at": "2026-07-24T10:30:03.000Z",
  "messages": [],
  "refund_amount": 0.62,
  "refund_reason": "Full refund - No SMS received"
}
```

### `POST /activations/{id}/finish`

**Finish an activation** — Closes an activation once you have used the code, releasing the number back to the provider. If no message ever arrived, finishing also refunds the purchase.

Scope: `activations:write`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Activation id. |

Response:

```json
{
  "id": "cmg7x2k9a0001l208hq3v7bqz",
  "object": "activation",
  "status": "completed",
  "phone_number": "+12025550147",
  "country": "US",
  "country_name": "United States",
  "service": "telegram",
  "service_name": "Telegram",
  "operator": "any",
  "price": 0.62,
  "currency": "USD",
  "created_at": "2026-07-24T10:15:03.000Z",
  "expires_at": "2026-07-24T10:30:03.000Z",
  "messages": [],
  "refund_amount": 0
}
```

## Rentals

### `POST /rentals`

**Order a rental** — Rents a number for days or months. Take an `offer_id` from `GET /pricing/rentals` and pass it back — the offer already fixes the country, length and price.

Scope: `rentals:write` · Supports `Idempotency-Key` · Purchase rate limit

| Body field | Type | Required | Description |
|---|---|---|---|
| `offer_id` | string | yes | The `offer_id` from `GET /pricing/rentals`. Offers expire after 30 minutes — fetch a fresh one if yours is rejected. |
| `service` | string | no | Optional. Rent one service on the number instead of the whole number, which is cheaper. |
| `auto_renew` | boolean | no | Optional. Renew automatically at expiry, where the offer supports it. |

Request:

```json
{
  "offer_id": "o1.Xn9pQ2s.7Kd1fA.k3mZq0vR8tYw"
}
```

Response:

```json
{
  "id": "cmg7x9p2r0004l208d1w4kzab",
  "object": "rental",
  "status": "active",
  "phone_number": "+447700900123",
  "country": "GB",
  "country_name": "United Kingdom",
  "service": "full",
  "service_name": "Full Rent",
  "nickname": null,
  "auto_renew": false,
  "price": 14.5,
  "currency": "USD",
  "created_at": "2026-07-24T10:20:11.000Z",
  "expires_at": "2026-08-23T10:20:11.000Z",
  "messages": []
}
```

### `GET /rentals`

**List rentals** — Your long-term rentals, newest first.

Scope: `rentals:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `status` | query | no | Filter by status. |
| `limit` | query | no | Page size, 1-100. |
| `offset` | query | no | Rows to skip. |

Response:

```json
{
  "object": "list",
  "data": [
    {
      "id": "cmg7x9p2r0004l208d1w4kzab",
      "object": "rental",
      "status": "active",
      "phone_number": "+447700900123",
      "country": "GB",
      "country_name": "United Kingdom",
      "service": "full",
      "service_name": "Full Rent",
      "nickname": null,
      "auto_renew": false,
      "price": 14.5,
      "currency": "USD",
      "created_at": "2026-07-24T10:20:11.000Z",
      "expires_at": "2026-08-23T10:20:11.000Z",
      "messages": []
    }
  ],
  "has_more": false,
  "total": 1
}
```

### `GET /rentals/{id}`

**Retrieve a rental** — One rental and its messages.

Scope: `rentals:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Rental id. |

Response:

```json
{
  "id": "cmg7x9p2r0004l208d1w4kzab",
  "object": "rental",
  "status": "active",
  "phone_number": "+447700900123",
  "country": "GB",
  "country_name": "United Kingdom",
  "service": "full",
  "service_name": "Full Rent",
  "nickname": null,
  "auto_renew": false,
  "price": 14.5,
  "currency": "USD",
  "created_at": "2026-07-24T10:20:11.000Z",
  "expires_at": "2026-08-23T10:20:11.000Z",
  "messages": []
}
```

### `GET /rentals/{id}/messages`

**List rental messages** — Every message received on the rental, newest first. Polls the provider before answering.

Scope: `rentals:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Rental id. |

Response:

```json
{
  "object": "list",
  "data": [
    {
      "id": "cmg7xb1s70006l208r9y2mnop",
      "object": "message",
      "sender": "Telegram",
      "text": "Telegram code 51284",
      "code": "51284",
      "received_at": "2026-07-24T10:16:44.000Z"
    }
  ],
  "has_more": false,
  "total": 1
}
```

### `POST /rentals/{id}/extend`

**Extend a rental** — Adds time to a live rental and charges your balance. Expired rentals cannot be extended — order a new one.

Scope: `rentals:write` · Supports `Idempotency-Key` · Purchase rate limit

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Rental id. |

| Body field | Type | Required | Description |
|---|---|---|---|
| `days` | integer | yes | Days to add, 1-365. |
| `auto_renew` | boolean | no | Optional. Renew automatically at expiry, where the rental supports it. |

Request:

```json
{
  "days": 30
}
```

Response:

```json
{
  "id": "cmg7x9p2r0004l208d1w4kzab",
  "object": "rental",
  "status": "active",
  "phone_number": "+447700900123",
  "country": "GB",
  "country_name": "United Kingdom",
  "service": "full",
  "service_name": "Full Rent",
  "nickname": null,
  "auto_renew": false,
  "price": 14.5,
  "currency": "USD",
  "created_at": "2026-07-24T10:20:11.000Z",
  "expires_at": "2026-08-23T10:20:11.000Z",
  "messages": [],
  "extended_hours": 720,
  "amount_charged": 14.5
}
```

### `POST /rentals/{id}/cancel`

**Cancel a rental** — Cancels and refunds a rental. Only possible within 120 minutes of purchase and only if no message has been received — that is the window our providers give us.

Scope: `rentals:write` · Purchase rate limit

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Rental id. |

Response:

```json
{
  "id": "cmg7x9p2r0004l208d1w4kzab",
  "object": "rental",
  "status": "refunded",
  "phone_number": "+447700900123",
  "country": "GB",
  "country_name": "United Kingdom",
  "service": "full",
  "service_name": "Full Rent",
  "nickname": null,
  "auto_renew": false,
  "price": 14.5,
  "currency": "USD",
  "created_at": "2026-07-24T10:20:11.000Z",
  "expires_at": "2026-08-23T10:20:11.000Z",
  "messages": [],
  "refund_amount": 14.5,
  "refund_reason": "Full refund - No SMS received"
}
```

## Webhooks

### `POST /webhooks/endpoints`

**Create a webhook endpoint** — Registers an HTTPS URL to receive events. The response contains the signing `secret` — this is the only time it is returned, so store it now.

Scope: `webhooks:write`

| Body field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes | HTTPS URL to deliver to. Must be publicly reachable. |
| `events` | array | no | Event types to subscribe to. Defaults to `["*"]` (everything). |
| `description` | string | no | Optional label, up to 160 characters. |

Request:

```json
{
  "url": "https://example.com/hooks/smsz",
  "events": [
    "activation.message.received",
    "rental.message.received"
  ],
  "description": "Production listener"
}
```

Response:

```json
{
  "id": "cmg7xf7w1000al208z3a5vwxy",
  "object": "webhook_endpoint",
  "url": "https://example.com/hooks/smsz",
  "description": "Production listener",
  "events": [
    "activation.message.received",
    "rental.message.received"
  ],
  "status": "active",
  "api_version": "2026-07-01",
  "secret": "whsec_p9Qk…",
  "created_at": "2026-07-24T10:30:00.000Z",
  "last_success_at": null,
  "last_error_at": null,
  "last_error": null
}
```

### `GET /webhooks/endpoints`

**List webhook endpoints** — Your configured endpoints. Secrets are never included.

Scope: `webhooks:read`

Response:

```json
{
  "object": "list",
  "data": [],
  "has_more": false,
  "total": 0
}
```

### `GET /webhooks/endpoints/{id}`

**Retrieve a webhook endpoint** — One endpoint, including when it last succeeded or failed.

Scope: `webhooks:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Endpoint id. |

### `PATCH /webhooks/endpoints/{id}`

**Update a webhook endpoint** — Change the URL, subscriptions or description. Send `status: "active"` to re-enable an endpoint we disabled after repeated failures — this also resets its failure counter.

Scope: `webhooks:write`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Endpoint id. |

| Body field | Type | Required | Description |
|---|---|---|---|
| `url` | string | no | New HTTPS URL. |
| `events` | array | no | Replacement subscription list. |
| `description` | string | no | New label. |
| `status` | string | no | Enable or disable the endpoint. One of: `active`, `disabled`. |

Request:

```json
{
  "events": [
    "*"
  ]
}
```

### `DELETE /webhooks/endpoints/{id}`

**Delete a webhook endpoint** — Removes the endpoint and any deliveries still queued for it.

Scope: `webhooks:write`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Endpoint id. |

Response:

```json
{
  "id": "cmg7xf7w1000al208z3a5vwxy",
  "object": "webhook_endpoint",
  "deleted": true
}
```

### `POST /webhooks/endpoints/{id}/test`

**Send a test event** — Delivers a real, correctly signed event with obviously fake data, and reports exactly what your endpoint answered. Use it to verify signature checking before going live.

Scope: `webhooks:write`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes | Endpoint id. |

Response:

```json
{
  "object": "webhook_test",
  "endpoint_id": "cmg7xf7w1000al208z3a5vwxy",
  "delivered": true,
  "response_status": 200,
  "duration_ms": 143,
  "error": null
}
```

### `GET /webhooks/deliveries`

**List deliveries** — What was sent, what your endpoint answered, how many attempts it took and when the next retry is due. Start here when events are not arriving.

Scope: `webhooks:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `endpoint_id` | query | no | Filter to one endpoint. |
| `status` | query | no | Filter by delivery status. |
| `limit` | query | no | Page size, 1-100. |
| `offset` | query | no | Rows to skip. |

### `GET /events`

**List events** — Every event on your account, whether or not you have a webhook endpoint. Poll this with `after` set to the last event id you processed if you cannot host an endpoint. Retained for 30 days.

Scope: `webhooks:read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `type` | query | no | Filter by event type. |
| `object_id` | query | no | Filter to one activation or rental. |
| `after` | query | no | Return only events after this event id. |
| `limit` | query | no | Page size, 1-100. |
| `offset` | query | no | Rows to skip. |

## Error codes

| Code | HTTP | Meaning |
|---|---|---|
| `missing_api_key` | 401 | No Authorization header was sent. |
| `invalid_api_key` | 401 | The key is not recognised. |
| `expired_api_key` | 401 | The key passed its expiry date. |
| `revoked_api_key` | 401 | The key was revoked in the dashboard. |
| `insufficient_scope` | 403 | The key lacks the scope this endpoint needs. |
| `ip_not_allowed` | 403 | The calling IP is not on the key’s allowlist. |
| `account_blocked` | 403 | The account cannot use the API. |
| `invalid_body` | 400 | The request body was not valid JSON. |
| `missing_parameter` | 400 | A required field was absent. See `param`. |
| `invalid_parameter` | 400 | A field was unusable. See `param`. |
| `insufficient_balance` | 402 | Your balance does not cover the purchase. |
| `resource_not_found` | 404 | No such object on this account. |
| `number_unavailable` | 409 | No stock for that country and service right now. |
| `not_cancellable` | 409 | Outside the cancellation window, or already used. |
| `not_extendable` | 409 | That extension length is not offered for this rental. |
| `resource_conflict` | 409 | The object is not in a state that allows this. |
| `idempotency_request_in_progress` | 409 | The same Idempotency-Key is still running. Retry shortly. |
| `idempotency_key_reused` | 422 | That Idempotency-Key was used with a different body. |
| `rate_limit_exceeded` | 429 | Too many requests. See `Retry-After`. |
| `internal_error` | 500 | Our fault. Quote the `request_id` to support. |
| `provider_rejected` | 502 | An upstream provider refused the order. |
| `provider_unavailable` | 503 | An upstream provider is temporarily down. |

