Skip to content
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

SMS, voice calls, one-time codes by SMS or voice, phone numbers and wholesale routes, on one REST API with scoped keys and signed webhooks. Everything the dashboard does, you can do from code, at the carrier's rate plus a fee capped at $0.001.

01 / Quick Start

Your first request in three steps

  1. Create an account

    Open a PacketExchange account with your company details.

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

  3. 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"

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, sent or failed. 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"}'
Response
{ "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 expirySeconds from 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:write scope. 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"}'
Response
// /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 (repeat 1 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:send scope. 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"}'
Response
{ "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=voice or type=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/resolve with your key.
curl "https://packetexchange.io/api/v1/routes/price-number?number=447700900123&type=voice"
Response
{ "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

Switch API


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

  1. In Postman, choose Import, then Link, and paste the collection URL below. Or download the file and drop it in.
  2. Import the test key environment too, select it, and set apiKey to a wmmn_test_sk_ key.
  3. Open any request and send it. Requests that take an X-Idempotency-Key fill in a fresh value each time.
https://packetexchange.io/postman/packetexchange.postman_collection.json
Live key environment, for when you are ready

Insomnia

  1. Download the export, then in Insomnia choose Import and pick the file.
  2. 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:

  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 hold it FOR a customer - your own balance pays, now and at every renewal (numbers:write, idempotency-keyed)
POST/v1/dids/to-cli-set
Make owned numbers usable as caller IDs

07 / 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.

StatusCodeMeaning
400VALIDATION_ERRORThe 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: [...] }).
400INVALID_INPUTA 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).
400NO_SESSIONSessions: the current session could not be determined, so "revoke others" was refused.
400BAD_REQUESTThe request could not be parsed, for example malformed JSON or an unsupported content type.
401UNAUTHORIZEDNo credential, or the token or API key is invalid, expired or revoked.
401MISSING_TOKENPOST /auth/refresh was called without a refresh token.
402SWITCH_SUBSCRIPTION_REQUIREDThe Switch endpoint needs an active Switch plan or trial on the account.
403FORBIDDENAuthenticated 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."
403TEST_KEY_NOT_ALLOWEDA 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.
403CSRF_BLOCKEDA cross-site request tried to use the browser refresh cookie.
403KYC_REQUIREDThe action needs a verified identity first (Account > Verification).
404NOT_FOUNDThe resource does not exist, or it is not visible to your account.
409CONFLICTThe record already exists, is referenced elsewhere, or a request with the same X-Idempotency-Key is still in progress.
409STALE_WRITESomeone else changed this Switch record after you loaded it. Reload and re-apply your change.details: An object describing the newer version that won.
409INVOICE_REVIEW_STALESwitch invoicing: the data changed since the invoice review was built. Review again, then issue.
409INVOICE_UNRATED_USAGESwitch invoicing: unrated usage sits inside the invoice period. Rate it (or exclude it) before issuing.details: An object describing the blocking usage.
409SWITCH_NO_SUBSCRIPTIONSwitch billing: there is no Switch subscription on this account to manage.
409EXPORT_NOT_READYCDR exports: GET /billing/exports/{id}/download was called before the export finished (or after it failed). Poll the job until it is done.
410EXPORT_EXPIREDCDR exports: the export finished, but its file has since been deleted by the retention sweep. Queue a new export.
422VALIDATION_ERRORAn X-Idempotency-Key was reused with a different request body. Use a fresh key for a different request.
429RATE_LIMITEDToo many requests. Wait for the number of seconds in the Retry-After header, then retry.
429TOO_MANY_EXPORTSYou already have exports in progress. Wait for one to finish.
500INTERNAL_ERRORAn unexpected server error. It has been logged; quote the X-Request-Id header when contacting support.
502UPSTREAM_ERRORA service we depend on failed. Retry shortly.
502STRIPE_ERRORCard payments: the card processor could not start the payment. Retry shortly.
503SERVICE_UNAVAILABLETemporarily unavailable. Retry with backoff.
503STRIPE_DISABLEDCard payments are not enabled on this platform yet.
503SWITCH_BILLING_UNCONFIGUREDSwitch billing: this plan cannot be bought online yet. Contact sales.
503SWITCH_PRICE_MISMATCHSwitch billing: this plan cannot be bought online right now. Contact sales.
503WIRE_UNAVAILABLEWire top-ups are not available right now.
503AI_UNAVAILABLESwitch 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.

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

OperationLimit
POST /account/switch-subscription/checkout10 / 5 minutes
POST /account/switch-subscription/portal10 / 5 minutes
POST /account/switch-trial-application5 / 10 minutes
POST /account/webhooks/deliveries/{deliveryId}/resend30 / minute
POST /ai-agents/{id}/simulate20 / minute
POST /ai-agents/draft20 / minute
POST /auth/2fa/verify15 / 5 minutes
POST /auth/confirm-email-change10 / 10 minutes
POST /auth/forgot-password5 / hour
POST /auth/invite/redeem10 / 10 minutes
POST /auth/login20 / 5 minutes
POST /auth/register200 / hour
POST /auth/resend-verification-public5 / hour
POST /auth/reset-password15 / hour
POST /comms/calls10 / second
POST /comms/sms10 / second
POST /comms/voice-otp10 / second
POST /dialer/agent/ask20 / minute
POST /dialer/contacts/parse20 / minute
POST /dids/listing-request5 / 10 minutes
POST /first-call/readiness20 / minute
POST /first-call/switch-address10 / minute
POST /kyc/start10 / minute
POST /listings/{token}/claim20 / minute
GET /lookup/{number}60 / minute
GET /pricing/ai-voice60 / minute
POST /public/listings10 / 10 minutes
GET /public/listings/{token}30 / minute
POST /public/listings/parse6 / minute
GET /public/numbers/catalog30 / minute
GET /public/numbers/catalog/countries15 / minute
GET /public/numbers/catalog/summary15 / minute
GET /public/numbers/catalog/types15 / minute
GET /purchases/route-for60 / minute
POST /revshare/ivr-configs/preview20 / minute
POST /routes30 / minute
POST /routes/{id}/publish-by-country6 / minute
POST /routes/{id}/rate-sheets10 / minute
POST /routes/{id}/rate-sheets/quick10 / minute
POST /routes/{id}/screening/test20 / minute
POST /routes/{id}/sip-trace10 / minute
POST /routes/autopilot/draft10 / minute
POST /routes/blends30 / minute
POST /routes/blends/propose20 / minute
PATCH /routes/my-endpoints/{id}60 / minute
POST /routes/my/bulk-sip-endpoint10 / minute
POST /routes/my/bulk-sms-delivery10 / minute
GET /routes/price-number30 / minute
POST /routes/probe-endpoint20 / minute
POST /routes/sms-endpoint/bulk6 / minute
GET /status/feed.xml120 / minute
GET /status/incidents120 / minute
GET /status/incidents/{id}120 / minute
GET /status/page120 / minute
POST /status/subscribe5 / hour
GET /status/subscribe/confirm30 / 10 minutes
GET /status/unsubscribe30 / 10 minutes
POST /status/unsubscribe120 / minute
POST /support/tickets5 / hour
POST /switch/cdrs/{id}/retest10 / minute
POST /switch/dnc/import10 / minute
POST /switch/dnc/scrub10 / minute
POST /switch/invoices/{id}/send60 / 10 minutes
POST /switch/rate-decks/apply20 / minute
POST /switch/rate-decks/parse10 / minute
POST /switch/suppliers/{id}/test-call6 / minute
POST /switch/suppliers/{id}/test-sms20 / minute
POST /switch/suppliers/test-sms20 / minute
POST /topups/crypto10 / minute
POST /verify/check20 / second
POST /verify/start10 / second

A limited request gets 429 RATE_LIMITED with a Retry-After header.

Need more headroom? Talk to us

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.

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

Start building

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.