01 / Quick Start
Your first request in three steps
Create an account
Open a PacketExchange account with your company details.
Generate an API key
In the dashboard, open Developers > API keys to create live and test keys. Keys are created from a dashboard login only.
Make your first request
Run the request on the right to list UK voice routes with their price and quality.
curl -X GET "https://packetexchange.io/api/v1/routes?country=United%20Kingdom&type=voice" \
-H "Authorization: Bearer wmmn_live_sk_your_token_here" \
-H "Accept: application/json"02 / Quick starts
Four things to build first
Copy a request, swap in a test key and run it. Test keys go through routing and pricing without delivering anything or touching your live balance.
Send an SMS
Leave out routeId and Smart Routing picks the route for the destination: cheapest, best_quality or balanced.
- Billed per segment: 160 characters, or 70 with Unicode.
- The status is the send-time result,
sentorfailed. A failed send is not charged. - Up to 1,000 recipients in one request with
POST /comms/sms/bulk.
curl -X POST https://packetexchange.io/api/v1/comms/sms \
-H "Authorization: Bearer wmmn_test_sk_..." \
-H "Content-Type: application/json" \
-d '{"to": "+447700900123", "from": "Acme", "message": "Your order has shipped", "strategy": "balanced"}'{ "success": true, "data": {
"messageId": "…", "to": "+447700900123", "from": "Acme",
"status": "sent", "segments": 1, "cost": "…", "submittedAt": "…"
} }Verify a phone number, by SMS or voice
The Verify API makes the code, sends it and checks it. Set channel to sms or voice; a voice code is read aloud on a short call. Only a hash of the code is stored.
- Codes are 4 to 10 digits (6 by default) and expire after 10 minutes, or set
expirySecondsfrom 60 to 3,600. - Five check attempts per code, and an approved code cannot be used again.
- Voice in English, Spanish, French, German, Portuguese and Hindi; SMS also in Arabic.
- Up to 5 codes per number per hour, 30 seconds apart. You pay only for the SMS or call that carries the code.
- Needs the
verify:writescope. A test key simulates the send and returns the code so you can finish the flow.
# 1. Send the code
curl -X POST https://packetexchange.io/api/v1/verify/start \
-H "Authorization: Bearer wmmn_test_sk_..." \
-H "Content-Type: application/json" \
-d '{"to": "+447700900123", "channel": "voice", "language": "en", "brand": "Acme"}'
# 2. Check what the user typed
curl -X POST https://packetexchange.io/api/v1/verify/check \
-H "Authorization: Bearer wmmn_test_sk_..." \
-H "Content-Type: application/json" \
-d '{"verificationId": "<from step 1>", "code": "482913"}'// /verify/start
{ "success": true, "data": {
"verificationId": "…", "channel": "voice", "status": "pending",
"expiresAt": "…", "maxAttempts": 5
} }
// /verify/check
{ "success": true, "data": { "status": "approved", "attemptsRemaining": 4 } }Read a code aloud (voice OTP)
Already have your own code logic? POST /comms/voice-otp calls the number, reads the digits one by one, repeats them and hangs up. Pass your own code, or leave it out and we generate one.
- Repeated twice by default (
repeat1 to 3), in the same six spoken languages as Verify. - Billed as a short call on the route's increment, with no speech surcharge.
- Needs the
voice:sendscope. The call is placed straight away; its outcome and cost follow on the call record.
curl -X POST https://packetexchange.io/api/v1/comms/voice-otp \
-H "Authorization: Bearer wmmn_test_sk_..." \
-H "Content-Type: application/json" \
-d '{"to": "+447700900123", "code": "482913", "language": "es", "brand": "Acme"}'{ "success": true, "data": {
"voiceOtpId": "…", "callId": "…", "status": "initiated",
"to": "+447700900123", "language": "es", "codeLength": 6, "repeat": 2
} }Price a number before you send
Every route listed for a number, priced at the rate it would charge for that number (the longest matching prefix on its deck), cheapest first. It is public: no key needed.
- Pass
type=voiceortype=sms. Up to 30 lookups a minute. - The same lookup powers Price a number on the marketplace.
- To see which of your own routes Smart Routing would pick, use
GET /routes/resolvewith your key.
curl "https://packetexchange.io/api/v1/routes/price-number?number=447700900123&type=voice"{ "success": true, "data": {
"number": "447700900123", "type": "voice", "unit": "min", "total": …,
"routes": [ { "destination": "…", "matchedPrefix": "…", "rate": "…",
"billingIncrement": "…", "cliType": "…", "exchangeScore": … } ]
} }# Authenticate via Bearer token
curl https://packetexchange.io/api/v1/account \
-H "Authorization: Bearer wmmn_live_sk_abc123"03 / Authentication
Secure by default
PacketExchange authenticates requests with either a session Bearer JWT or an API key. Create and manage API keys in the dashboard under Developers > API keys.
- All API requests must be made over HTTPS. Calls made over plain HTTP will fail.
- Pass your credential in the Authorization header: a Bearer JWT or an API key.
- Test keys use the prefix
wmmn_test_sk_and simulate calls/SMS without placing real traffic or touching your live balance. - Keys can be restricted to scopes that gate what they can do (a key created with no scopes has full access):
voice:send,sms:send,dialer:write,routes:read,account:read,purchases:write,offers:write,billing:write,numbers:read,numbers:write,account:write,routes:write,cdr:numbers,accelerator:read,accelerator:write,application:write,switch:manage,verify:write,webhooks:write. Scopes can only be narrowed after creation, never broadened. - Sensitive account management - listing, creating or revoking API keys, registering webhooks, requesting payouts - is session-only: it requires a dashboard login, and an API key gets a 403. A leaked key can never list its siblings or mint itself new keys. Each operation in the reference below states its access rule and the scope a scoped key needs.
04 / Endpoint Reference
Every endpoint, from the live API
Generated from the API's own route table and the request and response schemas declared beside each route, so it always matches what is deployed. Each group has its own page: parameters, schemas, errors, code in cURL, Node.js and Python, and a console to run the request with a test key.
Platform API
- Authentication19
- Account34
- API keys4
- API usage1
- Webhooks10
- Marketplace routes43
- Route checks7
- Rate sheets21
- Route blends7
- Purchases14
- Offers7
- Route access6
- Route messages3
- Route reports10
- Markets3
- Voice and SMS7
- Verify5
- Number lookup1
- Phone numbers52
- Billing22
- Top-ups13
- Payouts2
- Dialer49
- AI voice agents9
- CLI tests13
- Do not call5
- Interconnections11
- Connections8
- Application Manager24
- Revenue share numbers26
- Notifications5
- Support5
- Compliance6
- Onboarding4
- Status8
- System3
05 / Postman and Insomnia
The whole API, ready to import
A Postman collection and an Insomnia export with all 801 requests, grouped as in the reference, each with its sample body. Your key goes in one variable, set in an environment on your machine.
Postman
- In Postman, choose Import, then Link, and paste the collection URL below. Or download the file and drop it in.
- Import the test key environment too, select it, and set
apiKeyto awmmn_test_sk_key. - Open any request and send it. Requests that take an
X-Idempotency-Keyfill in a fresh value each time.
https://packetexchange.io/postman/packetexchange.postman_collection.jsonInsomnia
- Download the export, then in Insomnia choose Import and pick the file.
- Switch to the Test key environment and set
apiKey. A Live key environment is there for when you are ready.
Prefer to generate your own client? The raw OpenAPI document is at /api/v1/docs/json. Test keys go through routing and pricing without delivering anything or touching your live balance.
06 / Building an app
Reselling to your own customers
If your product has end users who make calls - a dialler, a softphone, a web phone - each of them is a sub-account under your operator account. They hold their own balance, present their own caller ID, and are billed at your retail rate while you are billed wholesale. The margin is yours.
How we know whose call it is
Your traffic arrives from one IP over one trunk, so the source address cannot tell your customers apart. We attribute a call by the SIP username it authenticates with - never by the caller ID. Presenting a customer's number in From sets what the callee sees; it does not decide who pays.
Resolution order, first match wins:
- a whitelisted source IP, which maps to your operator account
- a SIP username matching one of your route purchases
- a SIP username matching one of your sub-accounts
The catch worth knowing up front: step 1 short-circuits the rest. If your sending IP is whitelisted, every call bills your operator account and per-customer billing never runs. To bill sub-accounts, authenticate each call with that sub-account's SIP credentials instead of relying on IP authorisation - and ask us to remove the IP from your whitelist so the two cannot conflict.
Where SIP credentials come from
We generate them - you never set them. The password is shown once, in the create response, and is not retrievable afterwards. Store it when you receive it.
/ v1/ application/ sub-accounts/ v1/ application/ sub-accounts/ :id/ sip-passwordOn your switch, set the SIP auth username and password per call from the calling customer's credentials. One trunk is enough - you do not need one per customer.
You do not have to buy a route
Termination can come from the marketplace, or from a carrier you contract yourself. If you already have a carrier, list them as a private route on your own account and send over it directly - no purchase, and nobody else can see or buy it. Your own routes and your purchases are treated identically when we pick a route for a call.
SIP connection details
Send to 5.9.65.215:5060, failing over to 94.130.218.11:5060. UDP or TCP. Numbers in E.164 without a leading +. DTMF is RFC2833. Codecs PCMU, PCMA, G729.
When you authenticate per call with a sub-account's credentials:
- Realm
- 5.9.65.215
- Username
- the bare
sub_…value, no@domain - Algorithm
- MD5
- qop
- not offered - omit it
- REGISTER
- not required; answer the challenge per call
The realm never changes. It stays 5.9.65.215 even when you send to the secondary address. Deriving it from the address you dialled, or from your own host, produces a hash that cannot match - the symptom is a correct-looking digest that gets challenged again with the same nonce.
The order to build it in
- Get termination. Buy a route, or list a carrier you already have as a private route. Check it covers your destinations with
GET /v1/routes/resolvebefore you build anything on top of it. - Fund your account. Sub-account balances are transferred out of yours, so yours has to have money in it first.
- Create a sub-account per customer. Store the SIP password from the create response - it is shown once.
- Fund each customer with
POST /v1/application/sub-accounts/:id/credit, or give them a credit limit if you bill in arrears. - Send a call authenticated as that sub-account. If it bills your operator account instead of theirs, your sending IP is still whitelisted - ask us to remove it.
- Then buy each customer a number and present it as their caller ID. Not required to make calls work - any valid E.164 caller ID is accepted - so do it after the call path is proven.
Giving each customer a number
A customer needs a number they own before they can present it as caller ID. Browse live inventory, then buy - the number is charged to the account that buys it and appears immediately.
/ v1/ dids/ catalog/ countries/ v1/ dids/ catalog/ v1/ dids/ buy/ v1/ dids/ to-cli-set07 / Errors & Conventions
One envelope, everywhere
Every response is JSON. Success responses wrap their payload in { "success": true, "data": ... }; list endpoints paginate with cursor + limit (default 25, max 100) and return nextCursor / hasMore. Monetary amounts are USD, serialised as 6-decimal strings ("0.035000").
For VALIDATION_ERROR, error.details is an array of { path, message }, one per failing field. The table below is every error code the API sends, generated from the API itself.
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body, query or path failed validation, or a business rule refused the input. Insufficient balance for a billable action is also reported this way, with a message that starts "Insufficient balance".details: An array of { path, message }, one per failing field. A business-rule refusal usually carries only the message; a few attach an object instead (Switch trunk activation sends { code: "TRUNK_NOT_READY", blockers: [...] }). |
| 400 | INVALID_INPUT | A value passed validation but is not in a storable format - most often a malformed id (not a UUID) in the path or query, a number out of range, or text containing characters that cannot be stored (for example a NUL byte). |
| 400 | NO_SESSION | Sessions: the current session could not be determined, so "revoke others" was refused. |
| 400 | BAD_REQUEST | The request could not be parsed, for example malformed JSON or an unsupported content type. |
| 401 | UNAUTHORIZED | No credential, or the token or API key is invalid, expired or revoked. |
| 401 | MISSING_TOKEN | POST /auth/refresh was called without a refresh token. |
| 402 | SWITCH_SUBSCRIPTION_REQUIRED | The Switch endpoint needs an active Switch plan or trial on the account. |
| 403 | FORBIDDEN | Authenticated but not allowed. For a scoped API key the message names the missing scope ("API key missing required scope: sms:send"); session-only endpoints answer "This action requires an interactive session (dashboard login), not an API key." |
| 403 | TEST_KEY_NOT_ALLOWED | A test key (wmmn_test_sk_) asked for something that has no test mode: a Switch change (any non-GET request under /switch) or an x402 top-up, which settles real USDC into the live balance. Use a live key. Test keys may still read Switch data. |
| 403 | CSRF_BLOCKED | A cross-site request tried to use the browser refresh cookie. |
| 403 | KYC_REQUIRED | The action needs a verified identity first (Account > Verification). |
| 404 | NOT_FOUND | The resource does not exist, or it is not visible to your account. |
| 409 | CONFLICT | The record already exists, is referenced elsewhere, or a request with the same X-Idempotency-Key is still in progress. |
| 409 | STALE_WRITE | Someone else changed this Switch record after you loaded it. Reload and re-apply your change.details: An object describing the newer version that won. |
| 409 | INVOICE_REVIEW_STALE | Switch invoicing: the data changed since the invoice review was built. Review again, then issue. |
| 409 | INVOICE_UNRATED_USAGE | Switch invoicing: unrated usage sits inside the invoice period. Rate it (or exclude it) before issuing.details: An object describing the blocking usage. |
| 409 | SWITCH_NO_SUBSCRIPTION | Switch billing: there is no Switch subscription on this account to manage. |
| 409 | EXPORT_NOT_READY | CDR exports: GET /billing/exports/{id}/download was called before the export finished (or after it failed). Poll the job until it is done. |
| 410 | EXPORT_EXPIRED | CDR exports: the export finished, but its file has since been deleted by the retention sweep. Queue a new export. |
| 422 | VALIDATION_ERROR | An X-Idempotency-Key was reused with a different request body. Use a fresh key for a different request. |
| 429 | RATE_LIMITED | Too many requests. Wait for the number of seconds in the Retry-After header, then retry. |
| 429 | TOO_MANY_EXPORTS | You already have exports in progress. Wait for one to finish. |
| 500 | INTERNAL_ERROR | An unexpected server error. It has been logged; quote the X-Request-Id header when contacting support. |
| 502 | UPSTREAM_ERROR | A service we depend on failed. Retry shortly. |
| 502 | STRIPE_ERROR | Card payments: the card processor could not start the payment. Retry shortly. |
| 503 | SERVICE_UNAVAILABLE | Temporarily unavailable. Retry with backoff. |
| 503 | STRIPE_DISABLED | Card payments are not enabled on this platform yet. |
| 503 | SWITCH_BILLING_UNCONFIGURED | Switch billing: this plan cannot be bought online yet. Contact sales. |
| 503 | SWITCH_PRICE_MISMATCH | Switch billing: this plan cannot be bought online right now. Contact sales. |
| 503 | WIRE_UNAVAILABLE | Wire top-ups are not available right now. |
| 503 | AI_UNAVAILABLE | Switch AI Control cannot reach its model. Nothing was changed; the manual controls still work. |
Safe retries: send an X-Idempotency-Key header (any unique string) on money-moving POSTs - /comms/calls, /comms/sms, top-ups, number purchases - and retrying with the same key replays the original response instead of charging twice. Keys are held for 24 hours. The same key with a different body is rejected (422); a concurrent in-flight retry gets a 409.
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Human-readable description",
"details": [
{ "path": "email", "message": "Invalid email" }
]
}
}
// Every response also carries X-Request-Id (a UUID).
// Quote it when you contact support.08 / Webhooks
Signed events, pushed to you
Register an HTTPS endpoint from the dashboard (Developers > Webhooks) or with POST /v1/account/webhooks (dashboard session only). The response includes the endpoint's signing secret exactly once - store it; later reads only show the last 4 characters, and you can rotate it at any time.
Each delivery is an HTTP POST of { "event", "data", "timestamp" } with these headers: X-Webhook-Event (the event name), X-Webhook-Id (unique per delivery - use it to de-duplicate), X-PX-Timestamp (Unix seconds when this attempt was sent) and two signatures.
- Recommended:
X-PX-Signature: v1=followed by the hex HMAC-SHA256, keyed with your secret, of<X-PX-Timestamp>.<raw body>. Because the timestamp is signed, reject any delivery whose timestamp is more than 5 minutes from your clock to stop replays. - Legacy:
X-Webhook-Signature: sha256=followed by the hex HMAC-SHA256 of the raw body alone. It is still sent on every delivery during the transition, so receivers that verify it keep working. Move to v1 when you next touch your receiver.
Verify against the exact raw body bytes before parsing the JSON, and compare signatures in constant time.
Respond with any 2xx within 10 seconds to acknowledge. Failures are retried up to 5 attempts with an attempts-squared-minute backoff (1, 4, 9, 16 minutes); an endpoint that keeps failing is auto-disabled and you are notified. Send yourself a test ping with POST /v1/account/webhooks/:id/test. Every attempt, with its status, response code and time, is listed on the dashboard Webhooks page, where any delivery can be resent. From code: GET /v1/account/webhooks/deliveries (all endpoints, filter by endpoint, status or event), GET /v1/account/webhooks/deliveries/:deliveryId (with the payload) and POST /v1/account/webhooks/deliveries/:deliveryId/resend. A resend is a new delivery with its own id and a fresh timestamp; the payload is identical.
Subscribable events
call.completedcall.ringingcall.answeredcall.gatheredsms.sentsms.dlrsms.deliveredsms.failedcampaign.startedcampaign.completedtopup.confirmedbalance.lowoffer.receivedroute.purchasedsub_account.balance_lowsub_account.suspendedsub_account.resumedsub_account.topup_requestednumber.call.receivednumber.sms.receivednumber.voicemail.receivedinvoice.createdinvoice.issuedinvoice.sentinvoice.voidedinvoice.reissuedinvoice.paymentcredit_note.issuedpayable.creatednetting.runsell_rate.changedcost_rate.scheduledcost_rate.activatedcost_rate.rolled_backsub_account.margin_below_floor// Verify a PacketExchange webhook (Express)
import crypto from 'crypto';
const TOLERANCE_S = 300; // reject deliveries more than 5 minutes old
app.post('/webhooks/packetexchange',
express.raw({ type: 'application/json' }), // raw bytes, NOT express.json()
(req, res) => {
const ts = req.header('X-PX-Timestamp') || '';
const got = req.header('X-PX-Signature') || '';
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_S) {
return res.sendStatus(401); // missing, malformed or replayed
}
const expected = 'v1=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(ts + '.')
.update(req.body) // req.body is a Buffer here
.digest('hex');
if (got.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString());
console.log(req.header('X-Webhook-Event'), event.data);
res.sendStatus(200); // 2xx = delivered; anything else retries
});09 / Rate Limits
A single, fair per-credential limit
The rate limit is applied per credential - each API key (or session JWT) gets its own window, so one caller can't exhaust a shared bucket. Unauthenticated requests are limited per IP address. There are no separate plan tiers today; if you need a higher limit, contact us. Endpoints that place real traffic or guard an account are deliberately tighter; size your sending loop against their cap, not the default.
Default: 100 requests per second per caller (API key, session or IP address). The operations below have their own, tighter limit, and that limit wins.
A limited request gets 429 RATE_LIMITED with a Retry-After header.
10 / HTTP Clients
Works with everything
The API is plain JSON over HTTP, so any HTTP client in any language works. The interactive OpenAPI reference is live (raw JSON spec at /api/v1/docs/json). Prefer a typed client? The Node.js and Python SDKs are at the top of this page.
nothing to installcurl https://packetexchange.io/api/v1/routes?country=United+Kingdom \
-H "Authorization: Bearer wmmn_live_sk_..."Plain JSON over HTTP - every endpoint works this way
fetch (built in)const res = await fetch("https://packetexchange.io/api/v1/routes?country=United+Kingdom", {
headers: { Authorization: "Bearer wmmn_live_sk_..." },
});
const { data: routes } = await res.json();No dependency needed on Node 18 and later
requestsimport requests
r = requests.get(
"https://packetexchange.io/api/v1/routes",
params={"country": "United Kingdom"},
headers={"Authorization": "Bearer wmmn_live_sk_..."},
)
routes = r.json()["data"]Any HTTP client works; requests is the common one
curl (bundled)$ch = curl_init("https://packetexchange.io/api/v1/routes?country=United+Kingdom");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer wmmn_live_sk_..."]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$routes = json_decode(curl_exec($ch), true)["data"];No package required
Your first request is minutes away
Free account, keys on signup, and a global team across time zones for help with routing and integration. Prepaid from $5, no contract.
