You read the catalog, buy a number, then poll for the code. Three calls in the happy path, and no list-orders endpoint: store the id the create call returns.
Copy the ids exactly as you receive them. Country, service and carrier values are opaque. One can be a string (whatsapp) or a number (1012), they are not shared between catalogs, and they change or disappear as stock moves. Take each one from the catalog response and pass it straight back. Never guess one, and never reuse a value you cached earlier. Re-read the catalog before you order.
Quick start
Send your key in the X-API-Key header on every request.
BASE=https://api.socialcheap.net/v1
KEY=SCKEY_your_api_key
# Every id below is an example. Read each one from the catalog and copy it exactly.
PROVIDER=global # GET /sim/providers
COUNTRY=afghanistan # GET /sim/providers/$PROVIDER/countries
SERVICE=linkedin # GET /sim/providers/$PROVIDER/countries/$COUNTRY/services
CARRIER=virtual4 # GET /sim/providers/$PROVIDER/countries/$COUNTRY/services/$SERVICE/pricing
# Price the service, then buy with the carrier you were just quoted
curl -s "$BASE/sim/providers/$PROVIDER/countries/$COUNTRY/services/$SERVICE/pricing" \
-H "X-API-Key: $KEY"
curl -s -X POST "$BASE/sim/orders" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d "{\"provider\":\"$PROVIDER\",\"country\":\"$COUNTRY\",\"service\":\"$SERVICE\",\"carrier\":\"$CARRIER\"}"
# Read the code, using the id the create call returned
curl -s "$BASE/sim/orders/SCG-NLQXAZLSEW" \
-H "X-API-Key: $KEY"const base = "https://api.socialcheap.net/v1";
const headers = {
"X-API-Key": process.env.SOCIALCHEAP_API_KEY,
"Content-Type": "application/json",
};
const order = await fetch(`${base}/sim/orders`, {
method: "POST",
headers,
// Each id is an example. Read them from the catalog and copy them exactly.
body: JSON.stringify({
provider: "global", // GET /sim/providers
country: "afghanistan", // GET .../countries
service: "linkedin", // GET .../services
carrier: "virtual4", // GET .../pricing
}),
}).then((r) => r.json());
// Poll until the code arrives
while (true) {
const { data } = await fetch(`${base}/sim/orders/${order.data.id}`, {
headers,
}).then((r) => r.json());
if (data.code) {
console.log(data.code);
break;
}
await new Promise((r) => setTimeout(r, 3000));
}import os, time, requests
base = "https://api.socialcheap.net/v1"
headers = {"X-API-Key": os.environ["SOCIALCHEAP_API_KEY"]}
# Each id is an example. Read them from the catalog and copy them exactly.
order = requests.post(f"{base}/sim/orders", headers=headers, json={
"provider": "global", # GET /sim/providers
"country": "afghanistan", # GET .../countries
"service": "linkedin", # GET .../services
"carrier": "virtual4", # GET .../pricing
}).json()
# Poll until the code arrives
while True:
data = requests.get(f"{base}/sim/orders/{order['data']['id']}",
headers=headers).json()["data"]
if data["code"]:
print(data["code"])
break
time.sleep(3)Store the id from the create response; it is how you read the order afterwards. Poll every 3 to 5 seconds while status is active, and stop once code is set.
Authentication
Keys are issued per account from the dashboard. One key is live at a time: issuing a new one revokes the previous key immediately.
X-API-Key: SCKEY_your_api_key
Accept: application/json| Response | Meaning |
|---|---|
401 | Key missing, unknown, or revoked |
403 | Account suspended |
Authenticated account
GET /v1/me returns the account the key belongs to.
{
"msg": "Authenticated",
"data": {
"id": "USR-8f14e45f",
"username": "ada",
"email": "ada@example.com",
"balance": 15250.0
}
}| Field | Type | Notes |
|---|---|---|
id | string | Your account id, a stable handle for your own records |
username | string | Display only |
email | string | Display only |
balance | number | Naira, major units, directly comparable to price and cost |
No path parameter and no account id: the key identifies the account. As a GET, it spends read budget.
Responses
Success and failure share one envelope.
{
"msg": "Countries",
"data": [{ "code": "usa", "name": "USA", "iso": "us" }]
}Every answer is msg plus exactly one payload key. The key is the signal: a failure never arrives as "data": null:
| Status | Key | Value |
|---|---|---|
2xx | data | The payload: a list, or an object |
4xx/5xx | errors | A field map (422), or [] when nothing is field-specific |
{
"msg": "Invalid API key",
"errors": []
}Action endpoints return "data": [] on success. Branch on the HTTP status; msg is a human-readable label.
| Status | Meaning |
|---|---|
200 | Success |
201 | Order created |
400 | Cannot be completed: balance, availability, or the action was refused |
401 | Key missing, invalid, or revoked |
403 | Account suspended |
404 | Unknown order, or an order on another account |
422 | Body or query failed validation |
429 | Rate limited |
500 | Server error; retry with backoff |
A 400 is not always a complaint about your request. The catalog can refuse the action (This catalog does not support ban), or the number can go out of stock between pricing and purchase. Re-read pricing and try again later.
Validation errors (422)
Field-keyed, so each message pins to the input it came from:
{
"msg": "Unprocessable entity",
"errors": {
"carrier": ["Field required"]
}
}errorsmaps a field to one or more messages, and the key is the field name.- A problem that belongs to the whole body is keyed
body; a nested field uses a dotted path (params.count). - More than one field can fail at once. Ordering
premiumwithout a country and a carrier names both.
Prices and balances are naira in major units: 1500.0 is ₦1,500, directly comparable to the balance from /me.
Rate limits
Limits apply per key and per IP address, and the lower of the two applies.
| Scope | Read (GET, HEAD) | Write (everything else) |
|---|---|---|
| Per API key | 100 / minute | 20 / minute |
| Per IP address | 100 / minute | 20 / minute |
Services that call from the same address share that IP budget. A heavy integration should arrive from its own egress IP.
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Allowance for the bucket |
X-RateLimit-Remaining | Requests left in the window |
X-RateLimit-Reset | Unix timestamp when the window resets |
Retry-After | Seconds to wait |
These are returned with every limited response, including 429. On a 429, wait out Retry-After and back off exponentially if it repeats.
Catalog
The catalog is a tree: catalog, country, service, then carrier pricing. One tree per provider, and the ids are not shared between them: usa is global's, 1 is ultra's.
Values are opaque: a string or a number, and subject to change. Read each one, use it, then read it again before you order.
Every service list is country-scoped. There is no catalog-wide one. Walk a country at a time and pick from the services it has stock for.
GET /sim/providers
GET /sim/providers/{provider}/countries
GET /sim/providers/{provider}/countries/{country}/services
GET /sim/providers/{provider}/countries/{country}/services/{service_id}/pricing
GET /sim/providers/{provider}/areacodes/sim/providersThe catalogs you can order from. id is what you pass as provider.
{
"msg": "Providers",
"data": [
{
"id": "global",
"name": "Global",
"actions": ["cancel", "ban", "finish"]
},
{ "id": "premium", "name": "Premium", "actions": [] },
{ "id": "ultra", "name": "Ultra", "actions": ["cancel"] }
]
}| Field | Type | Notes |
|---|---|---|
id | string | Pass as provider |
name | string | Display only |
actions | string[] | Order actions this catalog supports. Check it before you offer cancel, ban or finish in a UI: premium supports none, and an unsupported action answers 400 |
/sim/providers/{provider}/countriesCodes are per catalog: the same country is usa in global and 1 in ultra, and its name differs too. Never carry a code across providers; take it from this list each time. Sending one catalog's code to another is refused with a 400.
{
"msg": "Countries",
"data": [{ "code": "usa", "name": "USA", "iso": "us" }]
}| Field | Type | Notes |
|---|---|---|
code | string or number | Pass as country, exactly as returned |
name | string | Display only |
iso | string or null | ISO 3166 alpha-2, for display or mapping |
/sim/providers/{provider}/countries/{country}/servicesServices with stock in that country.
{
"msg": "Services",
"data": [{ "code": "whatsapp", "name": "WhatsApp" }]
}| Field | Type | Notes |
|---|---|---|
code | string or number | Pass as service, exactly as returned |
name | string | Display only |
/sim/providers/{provider}/countries/{country}/services/{service_id}/pricingThe carriers that can serve this service, and what each costs. Read it immediately before ordering; it is the last step of the walk.
Out-of-stock carriers are left out, so this list is the set you can order from.
premium serves a single country and a single carrier, so it answers one row.
{
"msg": "Pricing",
"data": [{ "carrier": "virtual4", "name": "virtual4", "price": 459.85 }]
}| Field | Type | Notes |
|---|---|---|
carrier | string | Pass as carrier. An opaque id, never a network name. Send the one from the quoted row |
name | string | Display only. The readable label for that row: virtual4, Foxtrot |
price | number | Naira. What the order costs |
/sim/providers/{provider}/areacodesArea codes, for services that require one. premium is the only catalog that offers them; every other answers [].
{
"msg": "Area codes",
"data": [{ "code": "212", "region": "New York" }]
}| Field | Type | Notes |
|---|---|---|
code | string | Pass in area_codes |
region | string or null | Display only |
Orders
/sim/ordersCreates an order and returns 201 with the order object.
{
"provider": "global",
"country": "afghanistan",
"service": "linkedin",
"carrier": "virtual4"
}| Field | Required | Notes |
|---|---|---|
provider | yes | A catalog id |
country | yes | The code from the countries list |
service | yes | A service code |
carrier | yes | From the pricing row you quoted |
area_codes | no | Only for services that require one |
capability | no | premium only, see below |
capability is read by the premium catalog and ignored by global and ultra, which accept the field and do nothing with it. Three values are valid; the default is sms.
| Value | Meaning |
|---|---|
sms | Default when omitted |
voice | Voice verification |
smsAndVoiceCombo | Both |
It changes the premium price, and the pricing endpoint cannot express it, so the cost in this response is the quote. Send one of the three values, or omit the field.
There is one body for every catalog: country and carrier are always required, so a client needs a single code path. global and ultra price per carrier; premium serves one country (usa) and one carrier row, and takes the value from that row. A missing field returns 422 naming it.
cost in the create response is the amount charged.
{
"msg": "Order created",
"data": {
"id": "SCG-NLQXAZLSEW",
"number": "+93788503317",
"service": "linkedin",
"country": "afghanistan",
"status": "active",
"code": null,
"message": null,
"expires_at": "2026-10-15T09:38:53.484428Z",
"cost": 459.85,
"provider": "global"
}
}| Response | Meaning |
|---|---|
400 | Balance too low, the number is no longer available, or the request was refused. Re-read pricing and retry |
503 | Purchases are paused for that catalog; retry shortly |
/sim/orders/{order_id}Reads one order. Once the SMS arrives, code and message are filled in.
{
"msg": "Order",
"data": {
"id": "SCG-NLQXAZLSEW",
"number": "+93788503317",
"service": "linkedin",
"country": "afghanistan",
"status": "active",
"code": "428462",
"message": "[LinkedIn] 428462 is your verification code, valid for 5 minutes.",
"expires_at": "2026-10-15T09:38:53.484428Z",
"cost": 459.85,
"provider": "global"
}
}| Field | Type | Notes |
|---|---|---|
id | string | Your reference for the order (SCG-, SCP- or SCU- by catalog). Store it; there is no list endpoint |
number | string or null | The assigned number, in E.164 form |
service | string or null | The service you sent |
country | string or null | The country for this order, lowercased. Can be null |
status | string | active, completed, banned, cancelled, or expired |
code | string or null | The most recent code. Null until one arrives |
message | string or null | The full SMS text. Some messages carry the code only in the body |
expires_at | string or null | RFC 3339. UTC is written Z, with fractional seconds only when they are non-zero |
cost | number | Naira charged |
provider | string | Catalog id |
Only the most recent code is returned. If the number receives a second SMS, poll again to read it.
| Status | Meaning |
|---|---|
active | Live, keep polling |
completed | Finished by you |
banned | Banned by you |
cancelled | Cancelled before the code arrived |
expired | Validity window elapsed. The charge is returned |
Only active is worth polling; every other status is final.
An expired order is refunded automatically.
/sim/orders/{id}/finishClose the order when you are done with the number. Returns data: []. finish does not refund.
It marks the order completed, so it is only available once a code has arrived. A fresh order answers 400.
POST /sim/orders/{id}/cancel cancel and, where eligible, refund
POST /sim/orders/{id}/ban block the number from reusecancel and ban refund to your wallet while the number has not delivered a code and the window has not elapsed, so cancel early rather than after the SMS arrives.
actions from /sim/providers says what a catalog supports, not what a given order will accept. An eligible action can still be refused, so offer it and handle the 400.