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.
- completedcode arrived, charge kept
- expiredwindow closed, refunded
- cancelledcancelled, refunded
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.| Status | What it means | Money |
|---|---|---|
active | A number has been issued and is waiting for an SMS. Lasts until a code arrives, you cancel, or expires_at passes. | Charged |
completed | A code arrived. code and seconds_to_code are set. | Charge kept |
expired | The window closed with no code. | Refunded in full |
cancelled | Ended before a code arrived, either by your DELETE or because the number was withdrawn early. | Refunded in full |
failed | No 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.
{
"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
}| Field | Type | Description |
|---|---|---|
| id | string | Identifier of the order, starting with ord_. Use it to read and cancel the order. |
| status | string | active, completed, cancelled, expired or failed. See the lifecycle. |
| service | string | Service slug of the route, as you sent it. |
| country | string | Country slug of the route, as you sent it. |
| phone | string | null | The 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. |
| price | string | What the order cost, in US dollars. It is the live price at the moment of creation and never changes afterwards. |
| code | string | null | The verification code, as a string so leading zeros survive. null until the status is completed. |
| created_at | string | When the order was created and charged. ISO 8601, UTC. |
| expires_at | string | null | When 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_code | integer | null | Whole 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.
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 field | Type | Description |
|---|---|---|
| service | stringrequired | Service slug, as returned by GET /routes. |
| country | stringrequired | Country slug, as returned by GET /routes. |
| max_price | stringoptional | The 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. |
| Header | Type | Description |
|---|---|---|
| Content-Type | stringrequired | application/json |
| Idempotency-Key | stringoptional | A 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: readcode. You are done.expiredorcancelled: no code came and the price is back in your balance. Create a new order if you still need a number.
{
"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.
curl "https://fastotp.io/api/v1/orders?status=active&limit=20" \
-H "Authorization: Bearer $FASTOTP_KEY"| Query | Type | Description |
|---|---|---|
| status | stringoptional | Only orders with this status: active, completed, cancelled, expired or failed. Leave it out for all of them. |
| limit | integeroptional | How many orders to return. Default 50, maximum 100. |
{
"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.
curl -X DELETE https://fastotp.io/api/v1/orders/ord_9f2k1x \
-H "Authorization: Bearer $FASTOTP_KEY"{
"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_afterin the body, and theRetry-Afterheader, 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 iscompletedand the code is there to use.
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
200where the first response was201. - 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.