Markets open
A developer typing at a laptop beside a large monitor in a lamp-lit studio in the evening
Developer reference

The completetelecom API

Search and buy routes, originate calls, send SMS, run campaigns and settle, over one REST API with scoped keys and webhooks. Everything the dashboard does, you can do from code.

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.

bash
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

GET/v1/routes
List marketplace routes
GET/v1/routes/:id
Get route details
POST/v1/routes
List a new route
GET/v1/routes/resolve
Smart Routing preview
POST/v1/routes/:id/test
Run a connectivity probe

Calls

POST/v1/comms/calls
Make a single outbound call (voice:send)
GET/v1/billing/cdrs
List call + message history (CDRs)

Dialer

POST/v1/dialer/campaigns
Create campaign (dialer:write)
GET/v1/dialer/campaigns
List campaigns
POST/v1/dialer/campaigns/:id/control
Start / pause / stop (dialer:write)

SMS

POST/v1/comms/sms
Send an SMS - Smart-Routed or pinned (sms:send)
GET/v1/billing/cdrs
Delivery history + status (CDRs)

Account & Billing

GET/v1/account
Get your account (account:read)
GET/v1/account/balance
Current balance
GET/v1/billing/transactions
List transactions
GET/v1/billing/summary
Billing summary

Keys & Webhooks

POST/v1/account/api-keys
Create an API key
GET/v1/account/api-keys
List API keys
POST/v1/account/webhooks
Register a webhook
GET/v1/account/webhooks
List webhooks

04 / 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:

  1. a whitelisted source IP, which maps to your operator account
  2. a SIP username matching one of your route purchases
  3. 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.

POST/v1/application/sub-accounts
Create a customer - returns sipUsername + sipPassword ONCE, with an API key
POST/v1/application/sub-accounts/:id/sip-password
Lost it? Rotate - returns the new password once, old stops working immediately

On 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

  1. 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/resolve before you build anything on top of it.
  2. Fund your account. Sub-account balances are transferred out of yours, so yours has to have money in it first.
  3. Create a sub-account per customer. Store the SIP password from the create response - it is shown once.
  4. Fund each customer with POST /v1/application/sub-accounts/:id/credit, or give them a credit limit if you bill in arrears.
  5. 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.
  6. 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.

GET/v1/dids/catalog/countries
Countries with inventory - returns the countryId you filter by
GET/v1/dids/catalog
Live inventory. Filter with countryId (a UUID), NOT an ISO code - unknown params are ignored
POST/v1/dids/buy
Buy a number. Pass subAccountId to buy it FOR a customer - their balance pays and the number is theirs (numbers:write, idempotency-keyed)
POST/v1/dids/to-cli-set
Make owned numbers usable as caller IDs

05 / 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").

StatusCodeMeaning
400VALIDATION_ERRORInvalid body/params - also insufficient balance
401UNAUTHORIZEDMissing or invalid credential
403FORBIDDENKey lacks a scope, or the action is session-only
404NOT_FOUNDMissing, or not owned by you
409CONFLICTDuplicate resource / concurrent request
429RATE_LIMITEDOver a rate limit - back off
500INTERNAL_ERROROur 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.

json
{
  "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

100requests / second

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.

Need more headroom? Talk to us

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).

cURLnothing to install
curl https://packetexchange.io/api/v1/routes?country=United+Kingdom \
  -H "Authorization: Bearer wmmn_live_sk_..."

Plain JSON over HTTP - every endpoint works this way

Nodefetch (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

Pythonrequests
import 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

PHPcurl (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

Integration Support

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.