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, go to Developers > API Keys to generate your live and test keys.
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"# Authenticate via Bearer token
curl https://packetexchange.io/api/v1/account \
-H "Authorization: Bearer wmmn_live_sk_abc123"02 / Authentication
Secure by default
PacketExchange authenticates requests with either a session Bearer JWT or an API key. Generate and manage API keys in your dashboard under Settings > 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. Scopes can only be narrowed after creation, never broadened. - Sensitive account management - 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 mint itself new keys.
03 / Endpoint Reference
Start with these endpoints
The groups below are the endpoints most integrations start with. The complete, always-current reference - every endpoint with its request schema - is the interactive OpenAPI reference, generated from the live route table.
Routes
/v1/routes/v1/routes/:id/v1/routes/v1/routes/resolve/v1/routes/:id/testCalls
/v1/comms/calls/v1/billing/cdrsDialer
/v1/dialer/campaigns/v1/dialer/campaigns/v1/dialer/campaigns/:id/controlSMS
/v1/comms/sms/v1/billing/cdrsAccount & Billing
/v1/account/v1/account/balance/v1/billing/transactions/v1/billing/summaryKeys & Webhooks
/v1/account/api-keys/v1/account/api-keys/v1/account/webhooks/v1/account/webhooks04 / 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-set05 / 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").
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid body/params - also insufficient balance |
| 401 | UNAUTHORIZED | Missing or invalid credential |
| 403 | FORBIDDEN | Key lacks a scope, or the action is session-only |
| 404 | NOT_FOUND | Missing, or not owned by you |
| 409 | CONFLICT | Duplicate resource / concurrent request |
| 429 | RATE_LIMITED | Over a rate limit - back off |
| 500 | INTERNAL_ERROR | Our fault - contact support |
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": [ ... ] // optional - e.g. per-field zod issues
}
}06 / 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 three headers: X-Webhook-Event (the event name), X-Webhook-Id (unique per delivery - use it to de-duplicate) and X-Webhook-Signature: sha256= followed by the hex HMAC-SHA256 of the exact raw body bytes, keyed with your secret. Verify against the raw bytes before parsing the JSON.
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 and inspect every attempt via GET /v1/account/webhooks/:id/deliveries.
Subscribable events
call.completedsms.sentsms.dlrcampaign.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';
app.post('/webhooks/packetexchange',
express.raw({ type: 'application/json' }), // raw bytes, NOT express.json()
(req, res) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer here
.digest('hex');
const got = req.header('X-Webhook-Signature') || '';
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
});07 / 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. Requests over the limit receive an HTTP 429. There are no separate plan tiers today; if you need a higher limit, contact us.
Endpoints that place real traffic are deliberately tighter: POST /v1/comms/calls and POST /v1/comms/sms are capped at 10 requests / second per credential. Size your sending loop against these caps, not the global default.
08 / 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).
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
Need help integrating?
Reach our team for help with routing, dialplan configuration, and API integration.
Email Support
Write to our team and we'll get back to you.
Contact form
Send us a message from the support page.
