Skip to content

No code, no charge. Every route is measured, and failing routes are removed.

How we measure

FastOTP APIdocumentationv1

Base URL https://fastotp.io/api/v1

API documentation ยท 2 of 4

Orders: lifecycle, polling, cancelling and refunds

An order is one phone number bought for one verification: it is charged when created, stays active for up to 20 minutes, and is refunded in full unless a code arrives.

On this page

Lifecycle

An order has five possible statuses. active is the only one that changes; the other four are final.

POST /ordersactive
  • completedcode arrived, charge kept
  • expiredwindow closed, refunded
  • cancelledcancelled, refunded
POST /ordersfailedno number could be issued, refunded
An order is active from the moment it is created, then ends in exactly one of three states. failed is the fourth ending and is reached straight from creation, without the order ever being active.
Order statuses
StatusWhat it meansMoney
activeA number has been issued and is waiting for an SMS. Lasts until a code arrives, you cancel, or expires_at passes.Charged
completedA code arrived. code and seconds_to_code are set.Charge kept
expiredThe window closed with no code.Refunded in full
cancelledEnded before a code arrived, either by your DELETE or because the number was withdrawn early.Refunded in full
failedNo number could be issued. The order never became active and has no phone.Refunded in full

You rarely receive a failed order as a response. When a number cannot be issued, POST /orders answers with an error such as no_numbers or upstream_unavailable. If your balance had already been charged, the charge is returned before that response is sent and the order is kept as failed, so it shows up when you list orders.

The order object

Creating, reading and listing orders all return this object. The fields are always present; the ones that have no value yet are null.

JSONExample order, waiting for its code
{
  "id": "ord_9f2k1x",
  "status": "active",
  "service": "whatsapp",
  "country": "indonesia",
  "phone": "+6283150707440",
  "price": "0.13",
  "code": null,
  "created_at": "2026-10-12T08:55:30Z",
  "expires_at": "2026-10-12T09:15:30Z",
  "seconds_to_code": null
}
Fields of the order object
FieldTypeDescription
idstringIdentifier of the order, starting with ord_. Use it to read and cancel the order.
statusstringactive, completed, cancelled, expired or failed. See the lifecycle.
servicestringService slug of the route, as you sent it.
countrystringCountry slug of the route, as you sent it.
phonestring | nullThe number to receive the SMS on, in international format with a leading +. null on a failed order, and for the moment before a number has been issued.
pricestringWhat the order cost, in US dollars. It is the live price at the moment of creation and never changes afterwards.
codestring | nullThe verification code, as a string so leading zeros survive. null until the status is completed.
created_atstringWhen the order was created and charged. ISO 8601, UTC.
expires_atstring | nullWhen the number stops waiting for a code, at most 20 minutes after creation. null on a failed order, and for the moment before a number has been issued.
seconds_to_codeinteger | nullWhole seconds between the number being issued and the code arriving. null until the status is completed.

Create an order

POST/ordersscope orders:create

Buys a number on one route and charges your balance. The response is 201 Created with the order, already active and carrying the phone number.

ShellRequest
curl -X POST https://fastotp.io/api/v1/orders \
  --max-time 30 \
  -H "Authorization: Bearer $FASTOTP_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c2a9e-8f0b-4c57-9d0e-2b6a3c1d7e44" \
  -d '{"service": "whatsapp", "country": "indonesia", "max_price": "0.15"}'
Body of POST /orders
Body fieldTypeDescription
servicestringrequiredService slug, as returned by GET /routes.
countrystringrequiredCountry slug, as returned by GET /routes.
max_pricestringoptionalThe most you agree to pay, as a decimal string. If the live price is higher the call fails with price_changed and nothing is charged. Without it you pay the live price, whatever it is.
Headers of POST /orders
HeaderTypeDescription
Content-Typestringrequiredapplication/json
Idempotency-KeystringoptionalA unique string of up to 255 characters, such as a UUID. Send it on every order; see idempotency.

The price is read from live supply while the request runs, which is why the call can take longer than the others. Set a client timeout of at least 30 seconds, and if it does time out, repeat it with the same Idempotency-Key.

This call can fail with validation_error, insufficient_balance, route_not_on_sale, price_changed, no_numbers, upstream_unavailable, service_unavailable or rate_limited. In every one of those cases your balance is unchanged when the response arrives. Each code is explained in the error reference.

Poll for the code

GET/orders/{id}scope orders:read

Returns the order as it is now. Call it every 3 to 5 seconds while the status is active and stop as soon as it is anything else:

  • completed: read code. You are done.
  • expired or cancelled: no code came and the price is back in your balance. Create a new order if you still need a number.
JSONExample response ยท 200 OK, code delivered
{
  "id": "ord_9f2k1x",
  "status": "completed",
  "service": "whatsapp",
  "country": "indonesia",
  "phone": "+6283150707440",
  "price": "0.13",
  "code": "482913",
  "created_at": "2026-10-12T08:55:30Z",
  "expires_at": "2026-10-12T09:15:30Z",
  "seconds_to_code": 11
}

Wait for the status to change instead of comparing the clock with expires_at yourself: an order turns expired shortly after that time, not at the exact second. Polling faster than every three seconds does not make a code arrive sooner and uses up your budget of 120 status polls per minute.

List orders

GET/ordersscope orders:read

Returns your orders, newest first, as { "orders": [...] }. Each item is a full order object.

ShellRequest
curl "https://fastotp.io/api/v1/orders?status=active&limit=20" \
  -H "Authorization: Bearer $FASTOTP_KEY"
Query parameters of GET /orders
QueryTypeDescription
statusstringoptionalOnly orders with this status: active, completed, cancelled, expired or failed. Leave it out for all of them.
limitintegeroptionalHow many orders to return. Default 50, maximum 100.
JSONExample response ยท 200 OK
{
  "orders": [
    {
      "id": "ord_9f2k1x",
      "status": "active",
      "service": "whatsapp",
      "country": "indonesia",
      "phone": "+6283150707440",
      "price": "0.13",
      "code": null,
      "created_at": "2026-10-12T08:55:30Z",
      "expires_at": "2026-10-12T09:15:30Z",
      "seconds_to_code": null
    }
  ]
}

Cancel an order

DELETE/orders/{id}scope orders:cancel

Cancels an active order and refunds it at once. Two conditions apply: at least 2 minutes must have passed since created_at, and no code may have arrived.

ShellRequest
curl -X DELETE https://fastotp.io/api/v1/orders/ord_9f2k1x \
  -H "Authorization: Bearer $FASTOTP_KEY"
JSONExample response ยท 200 OK
{
  "id": "ord_9f2k1x",
  "status": "cancelled",
  "refunded": "0.13"
}

refunded is the amount returned to your balance, which is always the full price of the order.

The two ways a cancel is refused both leave the order exactly as it was:

  • cancel_too_early (409): the order is not yet 2 minutes old. retry_after in the body, and the Retry-After header, give the seconds left. Keep polling in the meantime; the code may still arrive.
  • not_cancellable (409): the order has already ended. Fetch it. If the code arrived while your cancel was on its way, the status is completed and the code is there to use.
HTTPExample response to an early cancel
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Retry-After: 74

{
  "error": {
    "code": "cancel_too_early",
    "message": "An order can be cancelled two minutes after it starts.",
    "retry_after": 74
  }
}

Cancelling is optional. An order you leave alone expires on its own and is refunded just the same; cancelling only returns the money sooner.

Refunds

You pay only for a code that was delivered. The full price of an order returns to your balance, automatically and without a request from you, when the order ends as:

  • failed: no number could be issued;
  • expired: the window closed with no code;
  • cancelled: it was cancelled before a code arrived.

A completed order is not refunded: the code was delivered, which is what the price pays for.

Idempotency

A request to create an order can time out after the order was created. Without protection, sending it again would buy a second number. The Idempotency-Key header removes that risk.

  • Generate one unique key for each purchase you intend to make, and send it on POST /orders.
  • If you repeat the request with the same key within 24 hours, no second order is created. You get the original order back as it is now, with status 200 where the first response was 201.
  • A key is tied to an order only once one has been created. If the first attempt ended in an error, such as no_numbers, nothing is tied to the key and repeating the request simply tries again.
  • The body is not compared. A key that already has an order always returns that order, so never reuse a key for a different purchase.
  • Keys belong to your account, not to one API key: every key of the account shares them, and another account using the same string is unaffected.

In practice: create the key before the first attempt, keep it for every retry of that one purchase, and discard it when you have an order. Both complete scripts in the quickstart do exactly this, and retrying safely covers the backoff around it.