API reference

Sell eSIM data plans and virtual numbers from your own product. One API key, one prepaid balance, and the same orders you see when you sign in — no separate reseller ledger to reconcile.

Base URLhttps://simarea.com/api/v1

Introduction

Everything SIMAREA sells is reachable over HTTP with an API key: the catalog, your balance, buying an eSIM data plan or a phone number, reading the SMS that arrives on it, and canceling an order.

There is one account and one prepaid USD balance behind both surfaces. An order placed through this API is the same order you see when you sign in to the website — same row, same balance, same refund rules. There is no separate reseller ledger to reconcile.

  • eSIM data plans — pick a destination, buy a plan, hand the customer a QR code to install.
  • Virtual numbers — rent a dedicated non-VoIP line by the term and keep its full message history.

The two are separate product families and never collapse into one label. They differ in how long they last, what they cost, and how you buy them — so the reference below is organised by family, and each family section carries every call you need for that product start to finish.

DetailValue
Base URLhttps://simarea.com/api/v1
AuthAuthorization: Bearer sk_live_…
Content typeapplication/json
CurrencyUSD, always, as integer cents in a string
VersioningThe version is in the path, so a future v2 cannot change what v1 returns

Quickstart

  • Create a SIMAREA account and top up your balance.
  • Issue an API key from the API keys panel in your account. It is shown once, at issue time, and cannot be recovered — store it before you close the dialog.
  • Check the key works and see what it can do.
  • Read a price, then buy.
cURL
# 1 — is my key alive, and what may it do?
curl https://simarea.com/api/v1/ \
  -H "Authorization: Bearer sk_live_..."

# 2 — what does a US virtual number cost right now, and for which terms?
curl "https://simarea.com/api/v1/catalog/offers/?family=virtual_number&country=usa" \
  -H "Authorization: Bearer sk_live_..."

# 3 — rent one. The Idempotency-Key is required.
curl -X POST https://simarea.com/api/v1/orders/ \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "family": "virtual_number", "countrySlug": "usa", "serviceSlug": null, "duration": "oneWeek" }'

# 4 — poll the order and read the messages it receives.
curl https://simarea.com/api/v1/orders/o_5rT8w1Nq3vHc/ \
  -H "Authorization: Bearer sk_live_..."

eSIM plans are not purchasable yet.

That is why the purchase above is a virtual number. The eSIM section explains the reason, and what you can build and test in the meantime.

Authentication

Issue a key from the API keys panel in your account and send it as a bearer token on every request.

cURL
curl https://simarea.com/api/v1/balance/ \
  -H "Authorization: Bearer sk_live_..."

The header is the only way in.

There is no ?api_key= query parameter and there will not be one. Query strings land in web-server access logs, in browser history, in Referer headers on outbound links, and in the error reports of every proxy in between. A key in a URL is a key that has leaked.

A missing, unknown, revoked or expired key — and a key belonging to a suspended account — all answer 401 with the same code, deliberately: telling a caller which of those it was tells an attacker which of those it was.

Revocation takes effect on the very next request. Nothing about a key is cached, anywhere, for any length of time. If you believe a key has leaked, revoke it and the next call made with it fails.

Issue one key per integration. Rate limits are per key, so a burst in your bot cannot throttle your storefront, and revoking one does not take the others down with it.

Each key carries a set of permissions. A key that lacks the one a route requires gets 403, and nothing is charged.

PermissionWhat it allows
readEvery GET: the catalog, your balance, your order list, one order, and the API root.
orderCreate and cancel virtual number and eSIM orders — families virtual_number and esim.
adminA key-level wildcard implying the two above. It grants nothing beyond the routes on this page: there is no administrative surface on the public API.

Response format

One thing comes back under a singular key, many under a plural one, and a failure under error. A response is never a bare JSON array, so a list can grow a sibling field — as orders did when it gained nextCursor — without breaking your client.

Response
{ "order":  { ... } }                              one order
{ "orders": [ { ... } ], "nextCursor": null }      a list
{ "error":  { "code": "...", "message": "..." } }  a failure
  • Money is a decimal string of integer cents, never a JSON number — "1234" is $12.34. A JSON number is an IEEE double and money does not survive one. Every amount is USD.
  • Dates are ISO 8601 strings in UTC, always with the Z.
  • Order ids are the `o_…` form. That is the only order identifier that exists outside our database, and it is what every route takes and returns.
  • Every response carries `cache-control: no-store`. These answers are per-key by construction and must not be held by a shared cache.
  • Every response carries `x-request-id`. Log it. Quoting it turns a support conversation about "an order this morning" into one about a single request.
  • No upstream is ever named. Which carrier or supplier fills an order is not part of this API and appears in no field. Do not build logic that depends on inferring it.

Every path ends in a slash. Read this before your first POST.

POST /api/v1/orders — without the trailing slash — answers 308 Permanent Redirect to /api/v1/orders/. A 308 preserves the method and the body, so a correct client follows it and the order is placed exactly once.

Several HTTP libraries drop the request body when they follow a redirect. When that happens the second request arrives empty, and the failure reads as "the API rejected my order for no reason". Write the slash and the hop never happens. The same applies to every GET.

This API is for server-to-server use and sends no CORS headers. Calling it from a browser will fail, and that is the intended behavior: a key that reaches a browser is a key your customers can read.

Idempotency

Idempotency-Key is required on every purchase.

A request without it is a 400. We do not generate one for you, and that is the whole point: a key the server invents is a fresh key on every attempt, so every retry would become a second order — which on this product means a second debit and a second number.

  • Generate one key per purchase intent, not per HTTP attempt. A UUID is ideal. It may be 8 to 200 characters of letters, digits and _ . : -.
  • Retry with the same key and the same body and you get the same order back, with nothing charged twice. This is the safe behavior after a timeout, a dropped connection or a 5xx — and after a timeout it is the *only* safe behavior, because a request that timed out may well have succeeded.
  • Reuse a key with a different body and the request is refused with 409 rather than being answered with the earlier order. Changing what you are buying means a new key.
  • Keys are scoped to your account, so yours can never collide with another customer's, and nobody can probe whether you used a particular value.
  • Reordering the JSON, adding whitespace, or sending "serviceSlug": null instead of omitting it all count as the same purchase. A retry that re-serialises its body is still a retry.

Store the key alongside your own order record before you send the request, not after. A key you generated and then lost is a key you cannot retry with.

Pagination

GET /orders/ is the only route that pages. It returns up to limit orders newest-first, plus a nextCursor.

  • limit is 1 to 200 and defaults to 50.
  • nextCursor is an opaque string. Send it back as ?cursor= for the next page. Do not parse it, do not build one, and do not assume its format is stable.
  • nextCursor is null when you have reached the end. That is the only stop condition — an empty page is not one, and neither is a page shorter than limit.
  • Keep family, open and limit identical for the whole walk. Changing a filter mid-walk makes the cursor mean something you did not intend.
  • A malformed cursor is a 400, never a silent restart at page one. An integration that silently restarts loops over the same page forever and nobody notices until the bill does.

Ordering is by creation time, newest first. New orders appear at the *front*, so a walk that takes a while will not see orders created after it started — which is what you want when you are reconciling.

Rate limits

The limit is per key, per 60-second window, and it is a property of the key. Issue one key per integration and a burst in one will not throttle the others. Your own limit is on the API root response, so you never have to discover it by hitting it.

Headers
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1755273600     unix seconds, when the window resets

Over the limit is 429 with a Retry-After header in seconds. Obey that header rather than a fixed back-off — it is computed from the database clock, so every one of our instances agrees on it.

When you are polling an order, poll on a schedule you can defend. There are no outbound webhooks in this version, so polling is the mechanism — but a tight loop spends your window on nothing and will throttle the purchase you actually needed to make.

Errors

Every failure is { "error": { "code", "message" } } at the status below. Switch on `code`, never on `message` — the code is the contract and the message is prose we may reword.

retryable means repeating the identical call may succeed. Anything else needs a change on your side, or a report to us with the x-request-id.

402 response
HTTP/1.1 402

{
  "error": {
    "code": "insufficient_funds",
    "message": "Insufficient balance",
    "details": {
      "balanceCents": "120",
      "requiredCents": "150",
      "shortfallCents": "30"
    }
  }
}

A details object appears only on the codes marked below, and only those. It is part of the published contract for those codes and is not a general debugging channel.

CodeHTTPRetryabledetailsMeaning
bad_request400no—The request is malformed — a missing header, an unparseable body, an unknown filter value.
config_error500no—A server-side misconfiguration. Not a client error; report it.
forbidden403no—The key is valid but its permissions do not include the one this route requires.
idempotency_conflict409no—This Idempotency-Key already names a different order. Use a fresh key for a new order.
illegal_transition500no—The order was in an unexpected status when the change was applied. Nothing was charged. Report it if it repeats.
insufficient_funds402noyesThe account balance does not cover the order. details carries the shortfall in cents.
internal_error500no—An unhandled server-side failure. Report it with the time of the call.
invalid_amount500no—A price or amount failed validation. Server-side; report it.
invalid_argument400no—A pagination or filter argument was out of range or the wrong shape.
invalid_ledger_entry500no—A ledger entry failed validation. Server-side; report it.
invalid_order_input500no—An order field was rejected before anything was charged.
invalid_pricing_rule500no—A pricing rule is malformed. Server-side; report it.
invalid_refund_reason500no—A refund was attempted with an unknown reason. Server-side; report it.
invalid_signature401no—A webhook signature did not verify. Not reachable from /api/v1.
ledger_post_failed500no—A balance movement could not be recorded. Nothing was charged; the request is safe to retry.
not_found404no—No such route, order, country, service or destination for this key's account.
order_create_conflict409yes—The Idempotency-Key collided with an order that then rolled back. Retry with the same key.
order_family_mismatch500no—An order was routed to the wrong family's provisioner. Server-side; report it.
order_not_cancellable409no—The order is in a status that cannot be cancelled — it is already completed, expired, failed or cancelled.
order_not_found404no—The order named by the request does not exist.
order_transition_failed500no—The order's status could not be advanced. Server-side; report it.
out_of_stock409no—No provider could supply this product right now. Nothing was charged.
provider_not_registered500no—No adapter is registered for the provider this order needs. Server-side; report it.
provider_unavailable503yes—A provider's circuit breaker is open. Safe to retry shortly.
rate_limited429yesyesOver the key's rate_limit for the current 60-second window. Retry-After says when to try again.
rental_not_priced409no—No pricing rule covers this rental term, so no price could be quoted. Nothing was charged.
sms_already_received409no—This order has already received an SMS, so it cannot be cancelled or refunded. The code you were sent is yours.
unauthorized401no—No Authorization: Bearer sk_live_… header, or the key is unknown, revoked, expired, or belongs to a blocked account.
upstream_error502no—A provider rejected or failed the call. Nothing was charged, or the charge was refunded.
upstream_timeout504yes—A provider did not answer in time. Safe to retry.
user_not_found404no—The account behind the key could not be read. Server-side; report it.

Nothing is charged on a failure.

A purchase that fails before the debit never takes the money; a purchase that fails after it is refunded automatically. If you ever see a debit with no corresponding order, that is a bug and we want the x-request-id.

OpenAPI, Postman and llms.txt

This page, the OpenAPI document, the Postman collection and the llms.txt reference are all generated from one description of the API. They cannot disagree with each other, and none of them can disagree with the routes.

  • OpenAPI 3.1 — import into your generator of choice for a typed client in any language.
  • Postman collection — import straight into Postman or Insomnia. Set the apiKey collection variable and every request is ready to send.
  • llms.txt — the whole reference as plain Markdown, for pasting into an AI assistant that is writing your client.

If you are asking a model to write your integration, give it the llms.txt file rather than a screenshot of this page. It contains the same rules in the same order, and none of the layout.

Product family

eSIM data plans

An eSIM is a data plan for a destination. You pick a destination, pick a plan, buy it, and the order comes back with everything the customer needs to install the profile — a QR image, an activation code and an SM-DP+ address — plus how much data it has used.

eSIM plans are not purchasable yet, and that is deliberate.

GET /catalog/packages/ returns an empty list on every account today. eSIM pricing is blocked on a unit question with our supplier that has not been settled, so no plan is priced and none is offered for sale. Your key authenticates, the route works, the list is empty — it is not a fault at your end and not a fault at ours.

GET /catalog/destinations/ answers in full in the meantime, and its syncedPackageCount tells you how many plans exist per destination regardless of pricing. Build and test your destination mapping now; plans appear the day they are priced, with no change to any shape on this page.

  • Read pricingUnit before priceCents: bundle is the whole plan, per_day is a daily rate and you choose the term with periodNum.
  • dataMb of null means unlimited, never zero.
  • An eSIM order is provisioning until the profile exists. Poll the order; qrCodeUrl and acCode fill in when it is installable.
GET/api/v1/catalog/destinations/read

List eSIM destinations

Every place an eSIM plan can cover — single countries, regions and global plans — in one list.

Build your destination mapping against syncedPackageCount, which counts what our supplier carries regardless of whether it is priced yet. packageCount counts only what is on sale.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "destinations": Destination[] } — see Destination.

Errors

unauthorized rate_limited — every one is described in Errors.

List destinations

Request
curl https://simarea.com/api/v1/catalog/destinations/ \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "destinations": [
    {
      "slug": "thailand",
      "kind": "country",
      "name": "Thailand",
      "countryCode": "th",
      "isPopular": true,
      "fromBundlePriceCents": null,
      "fromPerDayPriceCents": null,
      "packageCount": 0,
      "syncedPackageCount": 18,
      "isListed": true
    },
    {
      "slug": "europe-esim",
      "kind": "region",
      "name": "Europe",
      "countryCode": null,
      "isPopular": true,
      "fromBundlePriceCents": null,
      "fromPerDayPriceCents": null,
      "packageCount": 0,
      "syncedPackageCount": 26,
      "isListed": true
    }
  ]
}
GET/api/v1/catalog/packages/read

List eSIM data plans

The eSIM plans on sale, optionally for one destination. code is what you send back as packageCode when you buy.

Read pricingUnit before you read priceCents. On a bundle the price is the total for the plan; on a per_day package it is a daily rate and you choose the term at purchase with periodNum.

Query parameters

NameRequiredDescription
destination
string
optionalNarrow to one destination slug. An unknown slug is a 404.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "packages": Package[] } — see Package.

Worth knowing

  • This route currently returns an EMPTY LIST, and that is the correct answer rather than a fault. eSIM pricing is blocked on an unresolved unit question with our supplier, so no plan is priced and none is offered for sale. Your key authenticates, the route works, the list is empty. An eSIM purchase attempted today is refused and nothing is charged.
  • The destinations route answers in full in the meantime, so the mapping half of your integration can be built and tested now.

Errors

unauthorized not_found bad_request rate_limited — every one is described in Errors.

Plans for one destination

Request
curl "https://simarea.com/api/v1/catalog/packages/?destination=thailand" \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "packages": []
}
POST/api/v1/orders/order

Buy

One route, two bodies, told apart by family. The body carries no price: the order is priced from live supply at the moment of purchase, and the response tells you what was actually charged.

A success is a 201 with the order read back AFTER it was created, so its status is current rather than the one it held a moment earlier.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.
Idempotency-KeyrequiredOne value per purchase intent, NOT per HTTP attempt. 8 to 200 characters of letters, digits and _ . : -; a UUID is ideal. Absent is a 400 — we never generate one for you.

Request body

FieldTypeDescription
familystringWhich product you are buying. It decides which of the fields below apply. One of: virtual_number, esim
countrySlug optionalstringvirtual_number only, and required there. From the countries route.
serviceSlug optionalstring | nullvirtual_number only, and optional there — send null or omit it for a line that accepts any service.
duration optionalstringvirtual_number only, and required there. The term you are renting for. One of: halfDay, oneDay, 3Days, oneWeek, twoWeeks, oneMonth, threeMonths, sixMonths, twelveMonths
autoRenew optionalbooleanvirtual_number only. Defaults to false. When true we charge your balance for another term as this one ends, so the line does not lapse.
destinationSlug optionalstringesim only, and required there. From the destinations route.
packageCode optionalstringesim only, and required there. The code of a package, sent back verbatim.
periodNum optionalinteger | nullesim only, and only on a package whose pricingUnit is per_day: how many days you are buying. Ignored on a bundle.

Returns

201 · { "order": Order } — see Order.

Worth knowing

  • The permission required is order, for a virtual number and an eSIM alike. A key without it gets a 403, and nothing is charged.
  • A slug that does not resolve is a 404. It is never quietly substituted for something near it.
  • Nothing is charged on any failure. Where a debit has already happened when the supplier fails, it is refunded automatically.

Errors

unauthorized forbidden bad_request not_found insufficient_funds out_of_stock idempotency_conflict rate_limited upstream_error upstream_timeout provider_unavailable — every one is described in Errors.

Buy an eSIM data plan

Request
curl -X POST https://simarea.com/api/v1/orders/ \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: 9d24a7e0-esim-001" \
  -H "Content-Type: application/json" \
  -d '{ "family": "esim",
        "destinationSlug": "thailand",
        "packageCode": "TH-7D-1GB",
        "periodNum": 7 }'
201 response
{
  "order": {
    "publicId": "o_2wF6y9Bk4mTd",
    "family": "esim",
    "status": "provisioning",
    "priceCents": "420",
    "createdAt": "2026-08-24T09:31:47.104Z",
    "completedAt": null,
    "esim": {
      "destinationSlug": "thailand",
      "qrCodeUrl": null,
      "acCode": null,
      "smdpAddress": null,
      "iccid": null,
      "dataTotalMb": "1024",
      "dataUsedMb": "0",
      "expiresAt": null
    }
  }
}
GET/api/v1/orders/{publicId}/read

Read one order

One order and everything currently known about it, including every SMS received on a number order and the install details on an eSIM.

This is the route you poll while an order is in flight. There are no outbound webhooks in this version.

Path parameters

NameRequiredDescription
publicId
string
requiredThe order's o_… id.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "order": Order } — see Order.

Worth knowing

  • An order belonging to another account answers 404, not 403. A key can only ever see its own account's orders.
  • Poll no faster than the rate-limit headers allow.

Errors

unauthorized not_found rate_limited — every one is described in Errors.

An installable eSIM

Request
curl https://simarea.com/api/v1/orders/o_2wF6y9Bk4mTd/ \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "order": {
    "publicId": "o_2wF6y9Bk4mTd",
    "family": "esim",
    "status": "active",
    "priceCents": "420",
    "createdAt": "2026-08-24T09:31:47.104Z",
    "completedAt": null,
    "esim": {
      "destinationSlug": "thailand",
      "qrCodeUrl": "https://qr.example.net/8ac1f0e2b7.png",
      "acCode": "LPA:1$smdp.example.net$K2-XXXXXXXX",
      "smdpAddress": "smdp.example.net",
      "iccid": "8900000000000000000",
      "dataTotalMb": "1024",
      "dataUsedMb": "37",
      "expiresAt": "2026-08-31T09:33:02.000Z"
    }
  }
}
GET/api/v1/orders/read

List your orders

Your orders, newest first, across every family unless you narrow it. This is also the eSIM list and the number list — there is no separate route for either, because two doors onto one list are two things to keep in step.

Pages are walked with cursor. Send the nextCursor from the previous response to get the next page; nextCursor is null on the last one.

Query parameters

NameRequiredDescription
family
string
optionalOnly this product family. Absent means every family. One of: virtual_number, esim
open
boolean
optional1 or true returns only orders still in flight. Absent means no filter, which is not the same request as open=0.
limit
integer
optional1 to 200. Defaults to 50.
cursor
string
optionalThe nextCursor from the previous page. Opaque — do not parse it or construct one. A malformed value is a 400 rather than a silent restart at page one.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "orders": Order[], "nextCursor": string | null } — see Order.

FieldTypeDescription
nextCursorstring | nullPass as ?cursor= for the next page. null when this was the last page. Present only alongside orders.

Worth knowing

  • Keep the filters identical while you page. Changing family or open mid-walk makes the cursor mean something you did not intend.

Errors

unauthorized bad_request rate_limited — every one is described in Errors.

Your eSIMs

Request
curl "https://simarea.com/api/v1/orders/?family=esim" \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "orders": [],
  "nextCursor": null
}

Page through every order

Request
curl "https://simarea.com/api/v1/orders/?limit=200" \
  -H "Authorization: Bearer sk_live_..."

# then, while nextCursor is not null:
curl "https://simarea.com/api/v1/orders/?limit=200&cursor=MjAyNi0wOC0yNFQwOToxNDowMy4yMjFafG9fOGtRMm00WHI5ZExw" \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "orders": [ /* 200 orders */ ],
  "nextCursor": "MjAyNi0wOC0yNFQwOToxNDowMy4yMjFafG9fOGtRMm00WHI5ZExw"
}
POST/api/v1/orders/{publicId}/cancel/read, plus order

Cancel and refund an order

Stops an order and returns what it refunded. The rules are exactly the website's own cancel button — this route is not a second policy.

Path parameters

NameRequiredDescription
publicId
string
requiredThe order's o_… id.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "cancel": Cancel } — see Cancel.

Worth knowing

  • Canceling an order that our own expiry sweeper closed a moment earlier answers 200, not an error. You asked for it to stop; it has stopped.
  • An order that has already received an SMS cannot be canceled or refunded — the code you paid for arrived. That is a 409.
  • A rental that ran its full term refunds nothing, because it delivered everything it promised.

Errors

unauthorized forbidden not_found sms_already_received order_not_cancellable rate_limited — every one is described in Errors.

Already closed by the sweeper

Request
curl -X POST https://simarea.com/api/v1/orders/o_8kQ2m4Xr9dLp/cancel/ \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "cancel": {
    "status": "expired",
    "refundedCents": "0"
  }
}
Product family

Virtual numbers

A virtual number is a dedicated non-VoIP line you rent for a term — from half a day up to a year. It is yours for that whole term, it takes messages from any service unless you rented it for a specific one, and its full message history stays on the order.

  • Send serviceSlug: null (or omit it) for a line that accepts any service. Name a service only when you need one guaranteed to work with it.
  • autoRenew: true charges your balance for another term as the current one ends, so the line does not lapse. It defaults to false.
  • Canceling mid-term follows the same rules as the website's cancel button. A rental that ran its full term refunds nothing — it delivered what it promised.
  • Messages arrive on the order. Poll GET /orders/{publicId}/ and read the rental.messages array.
GET/api/v1/catalog/countries/read

List every country

Every country we sell numbers in, popular ones first — the same order the website uses, so a list you cache and a page a customer reads agree.

It is NOT filtered by what is in stock this minute. That is a property of one family at one moment and the offers route answers it; a country list that shrank when a pool emptied would silently break a mapping you built last week.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "countries": Country[] } — see Country.

Errors

unauthorized rate_limited — every one is described in Errors.

List countries

Request
curl https://simarea.com/api/v1/catalog/countries/ \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "countries": [
    { "slug": "usa", "name": "United States", "code": "us", "phoneCode": "+1", "isPopular": true },
    { "slug": "united-kingdom", "name": "United Kingdom", "code": "gb", "phoneCode": "+44", "isPopular": true }
  ]
}
GET/api/v1/catalog/services/read

List every service

Every app or website a number can be bought for, popular ones first. slug is what a purchase body takes.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "services": Service[] } — see Service.

Errors

unauthorized rate_limited — every one is described in Errors.

List services

Request
curl https://simarea.com/api/v1/catalog/services/ \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "services": [
    { "slug": "telegram", "name": "Telegram", "isPopular": true },
    { "slug": "whatsapp", "name": "WhatsApp", "isPopular": true }
  ]
}
GET/api/v1/catalog/offers/read

Live prices and stock

What can be bought right now and for how much. Each row is the cheapest live price for a country, a service and a term across every supplier we have enabled.

family is required and there is no "all families" mode: the families do not share a price shape, and a single list would force you to branch on a field anyway.

Query parameters

NameRequiredDescription
family
string
requiredWhich product to price. One of: virtual_number, esim
country
string
optionalNarrow to one country slug.
service
string
optionalNarrow to one service slug.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "offers": Offer[] } — see Offer.

Worth knowing

  • A slug that does not exist is a 404, not an empty list. An unknown family is a 400. Neither is ever silently widened to "everything".
  • Prices move with supply. Quote from this route at the moment you show a price, and do not cache it for longer than you would be happy to be wrong for.

Errors

unauthorized forbidden bad_request not_found rate_limited — every one is described in Errors.

Virtual number prices

Request
curl "https://simarea.com/api/v1/catalog/offers/?family=virtual_number&country=usa" \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "offers": [
    {
      "family": "virtual_number",
      "countrySlug": "usa",
      "serviceSlug": null,
      "durationSlug": "oneWeek",
      "priceCents": "900",
      "stock": null
    },
    {
      "family": "virtual_number",
      "countrySlug": "usa",
      "serviceSlug": null,
      "durationSlug": "oneMonth",
      "priceCents": "2400",
      "stock": null
    }
  ]
}
POST/api/v1/orders/order

Buy

One route, two bodies, told apart by family. The body carries no price: the order is priced from live supply at the moment of purchase, and the response tells you what was actually charged.

A success is a 201 with the order read back AFTER it was created, so its status is current rather than the one it held a moment earlier.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.
Idempotency-KeyrequiredOne value per purchase intent, NOT per HTTP attempt. 8 to 200 characters of letters, digits and _ . : -; a UUID is ideal. Absent is a 400 — we never generate one for you.

Request body

FieldTypeDescription
familystringWhich product you are buying. It decides which of the fields below apply. One of: virtual_number, esim
countrySlug optionalstringvirtual_number only, and required there. From the countries route.
serviceSlug optionalstring | nullvirtual_number only, and optional there — send null or omit it for a line that accepts any service.
duration optionalstringvirtual_number only, and required there. The term you are renting for. One of: halfDay, oneDay, 3Days, oneWeek, twoWeeks, oneMonth, threeMonths, sixMonths, twelveMonths
autoRenew optionalbooleanvirtual_number only. Defaults to false. When true we charge your balance for another term as this one ends, so the line does not lapse.
destinationSlug optionalstringesim only, and required there. From the destinations route.
packageCode optionalstringesim only, and required there. The code of a package, sent back verbatim.
periodNum optionalinteger | nullesim only, and only on a package whose pricingUnit is per_day: how many days you are buying. Ignored on a bundle.

Returns

201 · { "order": Order } — see Order.

Worth knowing

  • The permission required is order, for a virtual number and an eSIM alike. A key without it gets a 403, and nothing is charged.
  • A slug that does not resolve is a 404. It is never quietly substituted for something near it.
  • Nothing is charged on any failure. Where a debit has already happened when the supplier fails, it is refunded automatically.

Errors

unauthorized forbidden bad_request not_found insufficient_funds out_of_stock idempotency_conflict rate_limited upstream_error upstream_timeout provider_unavailable — every one is described in Errors.

Rent a virtual number

Request
curl -X POST https://simarea.com/api/v1/orders/ \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: 3f0b7c11-rental-2026-08" \
  -H "Content-Type: application/json" \
  -d '{ "family": "virtual_number",
        "countrySlug": "usa",
        "serviceSlug": null,
        "duration": "oneWeek",
        "autoRenew": false }'
201 response
{
  "order": {
    "publicId": "o_5rT8w1Nq3vHc",
    "family": "virtual_number",
    "status": "active",
    "priceCents": "900",
    "createdAt": "2026-08-24T09:20:11.550Z",
    "completedAt": null,
    "rental": {
      "phone": "+12025550188",
      "countrySlug": "usa",
      "serviceSlug": null,
      "durationSlug": "oneWeek",
      "rentedUntil": "2026-08-31T09:20:11.550Z",
      "autoRenew": false,
      "messages": []
    }
  }
}
GET/api/v1/orders/{publicId}/read

Read one order

One order and everything currently known about it, including every SMS received on a number order and the install details on an eSIM.

This is the route you poll while an order is in flight. There are no outbound webhooks in this version.

Path parameters

NameRequiredDescription
publicId
string
requiredThe order's o_… id.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "order": Order } — see Order.

Worth knowing

  • An order belonging to another account answers 404, not 403. A key can only ever see its own account's orders.
  • Poll no faster than the rate-limit headers allow.

Errors

unauthorized not_found rate_limited — every one is described in Errors.

An installable eSIM

Request
curl https://simarea.com/api/v1/orders/o_2wF6y9Bk4mTd/ \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "order": {
    "publicId": "o_2wF6y9Bk4mTd",
    "family": "esim",
    "status": "active",
    "priceCents": "420",
    "createdAt": "2026-08-24T09:31:47.104Z",
    "completedAt": null,
    "esim": {
      "destinationSlug": "thailand",
      "qrCodeUrl": "https://qr.example.net/8ac1f0e2b7.png",
      "acCode": "LPA:1$smdp.example.net$K2-XXXXXXXX",
      "smdpAddress": "smdp.example.net",
      "iccid": "8900000000000000000",
      "dataTotalMb": "1024",
      "dataUsedMb": "37",
      "expiresAt": "2026-08-31T09:33:02.000Z"
    }
  }
}
GET/api/v1/orders/read

List your orders

Your orders, newest first, across every family unless you narrow it. This is also the eSIM list and the number list — there is no separate route for either, because two doors onto one list are two things to keep in step.

Pages are walked with cursor. Send the nextCursor from the previous response to get the next page; nextCursor is null on the last one.

Query parameters

NameRequiredDescription
family
string
optionalOnly this product family. Absent means every family. One of: virtual_number, esim
open
boolean
optional1 or true returns only orders still in flight. Absent means no filter, which is not the same request as open=0.
limit
integer
optional1 to 200. Defaults to 50.
cursor
string
optionalThe nextCursor from the previous page. Opaque — do not parse it or construct one. A malformed value is a 400 rather than a silent restart at page one.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "orders": Order[], "nextCursor": string | null } — see Order.

FieldTypeDescription
nextCursorstring | nullPass as ?cursor= for the next page. null when this was the last page. Present only alongside orders.

Worth knowing

  • Keep the filters identical while you page. Changing family or open mid-walk makes the cursor mean something you did not intend.

Errors

unauthorized bad_request rate_limited — every one is described in Errors.

Virtual numbers you hold

Request
curl "https://simarea.com/api/v1/orders/?family=virtual_number&open=1" \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "orders": [
    {
      "publicId": "o_5rT8w1Nq3vHc",
      "family": "virtual_number",
      "status": "active",
      "priceCents": "900",
      "createdAt": "2026-08-19T11:02:44.008Z",
      "completedAt": null,
      "rental": {
        "phone": "+12025550188",
        "countrySlug": "usa",
        "serviceSlug": null,
        "durationSlug": "oneWeek",
        "rentedUntil": "2026-08-26T11:02:44.008Z",
        "autoRenew": true,
        "messages": []
      }
    }
  ],
  "nextCursor": null
}

Page through every order

Request
curl "https://simarea.com/api/v1/orders/?limit=200" \
  -H "Authorization: Bearer sk_live_..."

# then, while nextCursor is not null:
curl "https://simarea.com/api/v1/orders/?limit=200&cursor=MjAyNi0wOC0yNFQwOToxNDowMy4yMjFafG9fOGtRMm00WHI5ZExw" \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "orders": [ /* 200 orders */ ],
  "nextCursor": "MjAyNi0wOC0yNFQwOToxNDowMy4yMjFafG9fOGtRMm00WHI5ZExw"
}
POST/api/v1/orders/{publicId}/cancel/read, plus order

Cancel and refund an order

Stops an order and returns what it refunded. The rules are exactly the website's own cancel button — this route is not a second policy.

Path parameters

NameRequiredDescription
publicId
string
requiredThe order's o_… id.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "cancel": Cancel } — see Cancel.

Worth knowing

  • Canceling an order that our own expiry sweeper closed a moment earlier answers 200, not an error. You asked for it to stop; it has stopped.
  • An order that has already received an SMS cannot be canceled or refunded — the code you paid for arrived. That is a 409.
  • A rental that ran its full term refunds nothing, because it delivered everything it promised.

Errors

unauthorized forbidden not_found sms_already_received order_not_cancellable rate_limited — every one is described in Errors.

Already closed by the sweeper

Request
curl -X POST https://simarea.com/api/v1/orders/o_8kQ2m4Xr9dLp/cancel/ \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "cancel": {
    "status": "expired",
    "refundedCents": "0"
  }
}

Account

Two routes that are not about a product: what you can spend, and what the key in your hand is allowed to do.

GET/api/v1/balance/read

Read your prepaid balance

Everything you buy — on the API and on the website — is paid for out of one prepaid USD balance. This is that balance.

Check it before a batch of purchases rather than after a failure: a purchase that cannot be covered is refused with insufficient_funds, and that error carries the exact shortfall so you can top up by the right amount.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "balance": Balance } — see Balance.

Errors

unauthorized rate_limited — every one is described in Errors.

Read the balance

Request
curl https://simarea.com/api/v1/balance/ \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "balance": {
    "currency": "USD",
    "balanceCents": "12500",
    "referralBalanceCents": "0"
  }
}
GET/api/v1/read

Check your key and discover the API

Answers what this version is, where its machine-readable descriptions live, and exactly what the key you just used is allowed to do — including its rate limit.

Call it first when you set an integration up. A 200 means the key is live, carries the permissions it lists, and belongs to an account in good standing; a 401 means it is missing, unknown, revoked, expired, or belongs to a suspended account.

Headers

NameRequiredDescription
AuthorizationrequiredBearer followed by your secret key.

Returns

200 · { "api": ApiRoot } — see ApiRoot.

Errors

unauthorized rate_limited — every one is described in Errors.

Check a key

Request
curl https://simarea.com/api/v1/ \
  -H "Authorization: Bearer sk_live_..."
200 response
{
  "api": {
    "version": "v1",
    "documentationUrl": "https://simarea.com/reseller/api/",
    "openapiUrl": "https://simarea.com/reseller/api/openapi.json",
    "postmanUrl": "https://simarea.com/reseller/api/simarea.postman_collection.json",
    "families": ["virtual_number", "esim"],
    "key": {
      "keyId": "a1b2c3d4e5f6",
      "permissions": ["read", "order"],
      "rateLimit": 60
    }
  }
}

Data shapes

Every object that crosses the wire. A field marked optional may be absent from the object entirely; a type ending in | null is present and may be JSON null. Those are different, and a client that treats them as the same will crash on the one it did not expect.

Country

A country you can buy a number in. The slug is what every purchase body and every filter takes — never the name and never the ISO code.

FieldTypeDescription
slugstringThe identifier you buy against.
namestringDisplay name.
codestringISO 3166-1 alpha-2, lower case.
phoneCodestringInternational dialling prefix, with the plus.
isPopularbooleanWhether the site lists this country in its popular set. A display hint, nothing more.

Service

An app or website a number can receive a verification code from. brand_color, initials and any success-rate figure are deliberately not published: the first two are presentation for our own pages, and the third mixes two upstream scales under one column and would be a number with no defined meaning.

FieldTypeDescription
slugstringThe identifier you buy against.
namestringDisplay name.
isPopularbooleanDisplay hint. Not a ranking.

Offer

One buyable line: the cheapest live price for a country, a service and a term, across every supplier we have enabled. This is the only route that answers *what can I buy right now, and for how much*.

FieldTypeDescription
familystringEchoes the family you asked for. One of: virtual_number, esim
countrySlugstring | nullThe country this line is in.
serviceSlugstring | nullThe service this line covers. null on a virtual number line that accepts any service.
durationSlugstringThe term: one of the rental terms for a virtual number.
priceCentsstring | nullInteger cents as a decimal string. null means the line exists but is not currently priced, so it cannot be bought — it is returned rather than hidden so your reconciliation can see it. We never invent a price to fill the gap.
stockinteger | nullHow many the supplier says are left. null means the supplier publishes no figure at all — the line is real, priced and buyable, and its depth is simply unknown. Zero never appears: a line the supplier calls empty is not returned. Treating null as zero hides inventory you could have sold.

Destination

Somewhere an eSIM data plan covers. The three kinds share one namespace: a single country, a region such as Europe, or a global plan.

FieldTypeDescription
slugstringThe identifier you buy against.
kindstringWhich of the three trees it belongs to. One of: country, region, global
namestringDisplay name.
countryCodestring | nullISO 3166-1 alpha-2 for a single-country destination; null for regions and global plans.
isPopularbooleanDisplay hint.
fromBundlePriceCentsstring | nullCheapest whole-plan price here, in integer cents as a string. null when nothing is priced.
fromPerDayPriceCentsstring | nullCheapest day-pass rate here, in integer cents as a string. null when nothing is priced.
packageCountintegerHow many plans are on sale here right now.
syncedPackageCountintegerHow many plans our supplier carries here, whether or not we can price them yet. Build your destination mapping against this and it will not shift under you.
isListedbooleanWhether the destination has its own page on the website.

Package

One eSIM data plan. code is what you send back as packageCode when you buy.

FieldTypeDescription
codestringSend this back as packageCode to buy the plan.
namestringDisplay name.
destinationSlugstring | nullWhich destination it covers.
pricingUnitstringWhat priceCents means. bundle is the total for the whole plan. per_day is a daily rate and the term is chosen at purchase with periodNum. Most of the catalog is day passes, so a client that ignores this quotes a month at one day's price. One of: bundle, per_day
priceCentsstringInteger cents as a decimal string, read according to pricingUnit.
dataMbstring | nullData allowance in megabytes, as a string. null means unlimited — never zero.
daysinteger | nullValidity in days for a bundle. null on a day pass, where you choose the term at purchase.

Balance

Your prepaid wallet. Both figures are integer cents as decimal strings, in USD.

FieldTypeDescription
currencystringAlways USD. One of: USD
balanceCentsstringWhat you can spend on the API and the website. Integer cents as a string.
referralBalanceCentsstringEarnings from the reward programs, held separately. Not spendable until you move it across in your account.

Message

One SMS received on a number order.

FieldTypeDescription
fromstring | nullSender, when the supplier reports one.
textstringThe full message body.
codestring | nullThe verification code, extracted for you. null when the message carried nothing that looked like one — the full text is always there either way.
receivedAtstringISO 8601, UTC.

Order

Every purchase, in one shape. The two family-specific blocks are mutually exclusive: exactly one of rental and esim is present, and which one is decided by family.

An order placed through this API is the same row as an order placed on the website. It appears in the account, it draws on the same balance, and it is refunded by the same rules.

FieldTypeDescription
publicIdstringThe order identifier, o_ followed by an opaque string. It is the ONLY order id that exists outside our database and it is what every route takes and returns.
familystringWhich product this is. One of: virtual_number, esim
statusstringWhere the order is. created, provisioning, waiting, active and received are still in flight; the rest are finished. One of: created, provisioning, waiting, active, received, completed, cancelled, expired, failed, refunded
priceCentsstringWhat was actually charged, in integer cents as a string.
createdAtstringISO 8601, UTC.
completedAtstring | nullWhen the order finished, or null while it is still in flight.
rental optionalOrderRentalPresent only when family is virtual_number.
esim optionalOrderEsimPresent only when family is esim.

OrderRental

The virtual number half of an order: a dedicated line held for a term.

FieldTypeDescription
phonestring | nullThe number in international form.
countrySlugstringCountry the line is in.
serviceSlugstring | nullThe service it was rented for, or null for a line that accepts any service.
durationSlugstringThe term it was rented for.
rentedUntilstring | nullWhen the term ends.
autoRenewbooleanWhether we will charge your balance for another term when this one ends.
messagesMessage[]Everything received, oldest first.

OrderEsim

The eSIM half of an order: everything the customer needs to install the profile, plus what it has used.

FieldTypeDescription
destinationSlugstringWhat the plan covers.
qrCodeUrlstring | nullA QR image the customer can scan to install. null until the profile is issued. Treat it as opaque and do not parse it — it is issued by the provisioning system, not by us, and its host and path are not part of this contract. If you want the image served from your own domain, fetch it once when the order becomes installable and re-host it; do not hot-link it into a page you control.
acCodestring | nullThe activation code, for manual installation on a device that cannot scan.
smdpAddressstring | nullThe SM-DP+ server address.
iccidstring | nullThe profile's ICCID once it exists.
dataTotalMbstring | nullAllowance in megabytes as a string. null means unlimited.
dataUsedMbstringUsed so far, in megabytes as a string.
expiresAtstring | nullWhen the plan stops working.

Cancel

What a cancel actually did.

FieldTypeDescription
statusstringThe status the order ended in.
refundedCentsstringWhat THIS call moved back to your balance, in integer cents as a string. "0" when the order had already been closed and refunded — by our own expiry sweeper, say — and "0" again for a rental that ran its full term, which is a completed sale rather than a failure to deliver.

ApiRoot

What the API is and what your key can do with it. The cheapest way to check a key is valid, and the only way to read your own rate limit without being throttled first.

FieldTypeDescription
versionstringThe version segment in the path.
documentationUrlstringThis page.
openapiUrlstringThe OpenAPI 3.1 document.
postmanUrlstringThe Postman collection.
familiesstring[]The product families this version sells.
keyApiRootKeyThe key you authenticated with.

ApiRootKey

Your own key, as the server sees it. The secret is never echoed.

FieldTypeDescription
keyIdstringThe public half of the key, for identifying it in support.
permissionsstring[]What this key may do.
rateLimitintegerRequests allowed per 60-second window.

Error

Every failure, at every status. Switch on code, never on message: the code is the contract and the message is prose we may reword.

FieldTypeDescription
codestringThe machine-readable failure. See the error reference.
messagestringA human sentence. Not a contract.
details optionalobjectPresent only on the codes marked in the error reference, and only those. It is part of the published contract for those codes and is not a general debugging channel.

Start with the catalog

Reading the catalog costs nothing, so you can build and test your integration against live data before your first purchase.

This page documents what the API does, not what it costs. Volume pricing, settlement and support commitments are covered on the reseller page. Found something here that does not match what the API did? Send us the x-request-id from the response and we will find the exact call — [email protected].