Skip to content

Developer reference

The complete telecom 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.

801

operations, generated from the live API

36

Platform API groups

10

Switch API groups

23

key scopes, narrowed after creation only

Quick start

Your first request in three steps

  1. 1

    Create an account

    Open a PacketExchange account with your company details.

    Open an account
  2. 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.

    API keys
  3. 3

    Make your first request

    Run this request to list UK voice routes with their price and quality.

bash
curl "https://packetexchange.io/api/v1/routes?country=United%20Kingdom&type=voice" \
  -H "Authorization: Bearer wmmn_live_sk_your_token_here" \
  -H "Accept: application/json"

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 } }

Verify a phone number by call-in

No code at all: your user calls one of your own phone numbers from the phone being verified. The call is matched by caller ID and ended without being answered, so it is free for them. Set it up once, then start with channel set to call_in.

  • Set up once: buy a phone number in the DID Store, in the country of the people you verify, then switch on call-in for it with POST /verify/call-in/numbers (or in the control panel). While it is on, calls to it are used for verification only and do not ring.
  • Each call-in number verifies phone numbers in its own country. Starting one for a country where you have no call-in number is a 400 that says how to add one, or to use sms or voice.
  • $0.01 per approved verification from your balance, plus the number's monthly price. An expired verification is free, and there is no message or call to pay for.
  • Expires after 5 minutes by default (expirySeconds 60 to 3,600). Pass your own reference: starting again with it while one is pending returns the pending verification.
  • Learn the result from GET /verify/{id} (pending, approved or expired) or the verification.approved and verification.expired webhooks. On a test key, POST /verify/{id}/simulate-call approves a pending verification as if the user had called.
  • A caller ID is weaker proof than a code. Use call-in for sign-up and phone confirmation; ask for a code before account recovery, payments or other high-risk actions.
# 0. Once: switch on call-in for a number you bought in the DID Store
curl -X POST https://packetexchange.io/api/v1/verify/call-in/numbers \
  -H "Authorization: Bearer wmmn_test_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"phoneNumberId": "<from GET /verify/call-in/numbers>"}'

# 1. Start, and show the user callNumberDisplay
curl -X POST https://packetexchange.io/api/v1/verify/start \
  -H "Authorization: Bearer wmmn_test_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"to": "+17735550100", "channel": "call_in", "reference": "user-8812"}'

# 2. Poll until status is approved or expired (or use the verification.approved webhook)
curl https://packetexchange.io/api/v1/verify/<verificationId> \
  -H "Authorization: Bearer wmmn_test_sk_..."

Response

// /verify/start
{ "success": true, "data": {
  "verificationId": "…", "reference": "user-8812", "channel": "call_in",
  "status": "pending", "callNumber": "+17735550142",
  "callNumberDisplay": "+1 (773) 555-0142", "price": "0.010000",
  "expiresAt": "…", "instructions": "Call +1 (773) 555-0142 from the phone you are verifying. …"
} }

// GET /verify/{id} once the call arrives
{ "success": true, "data": {
  "status": "approved", "callerNumber": "+17735550100",
  "callReceivedAt": "…", "callerIdAttestation": "A", "approvedAt": "…"
} }

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": … } ]
} }

Download the A-Z price list (Excel)

The whole marketplace in one workbook: one row per listing and destination network, a tab per tier (International CLI, NCLI, Qualified, Untested Routes), EEA caller ID prices, SMS and the changes since yesterday. Rebuilt every day at 05:00 UTC.

  • GET /price-list.xlsx is the day's list at marketplace list prices. GET /price-list/me.xlsx is your own copy, with your private prices and accepted offers in place of the list price.
  • Needs an account: a dashboard session or a key with the routes:read scope. Up to 20 downloads per account per 24 hours.
  • GET /price-list/meta (public) says when the current list was built and how many rows each tab holds.
curl -o price-list.xlsx https://packetexchange.io/api/v1/price-list.xlsx \
  -H "Authorization: Bearer wmmn_live_sk_..."

Response

HTTP/1.1 200 OK
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="PacketExchange-A-Z-price-list-YYYY-MM-DD.xlsx"

Authentication

Secure by default

PacketExchange authenticates requests with 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 API key in the Authorization header as a Bearer token.
  • Test keys use the prefix wmmn_test_sk_ and simulate calls and 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). 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 states its access rule and the scope a scoped key needs.

Every scope a key can carry

  • 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
  • greetings:read
  • greetings:write
  • revshare:read
  • revshare:write
# Authenticate via Bearer token
curl https://packetexchange.io/api/v1/account \
  -H "Authorization: Bearer wmmn_live_sk_abc123"

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

36 groups

Switch API

10 groups

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. 1In Postman, choose Import, then Link, and paste the collection URL below. Or download the file and drop it in.
  2. 2Import the test key environment too, select it, and set apiKey to a wmmn_test_sk_ key.
  3. 3Open 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. 1Download the export, then in Insomnia choose Import and pick the file.
  2. 2Switch 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 /openapi.json. Test keys go through routing and pricing without delivering anything or touching your live balance.

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. 1a whitelisted source IP, which maps to your operator account
  2. 2a SIP username matching one of your route purchases
  3. 3a 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. 1Get 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. 2Fund your account. Sub-account balances are transferred out of yours, so yours has to have money in it first.
  3. 3Create a sub-account per customer. Store the SIP password from the create response: it is shown once.
  4. 4Fund each customer with POST /v1/application/sub-accounts/:id/credit, or give them a credit limit if you bill in arrears.
  5. 5Send a call authenticated as that sub-account. If it bills your operator account instead of theirs, your sending IP is still whitelisted, so ask us to remove it.
  6. 6Then 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

Errors and 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.

An error response
{
  "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.
  • 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.

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.

// 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
  });

Subscribable events

  • call.completed
  • call.ringing
  • call.answered
  • call.gathered
  • sms.sent
  • sms.dlr
  • sms.delivered
  • sms.failed
  • campaign.started
  • campaign.completed
  • topup.confirmed
  • balance.low
  • offer.received
  • route.purchased
  • sub_account.balance_low
  • sub_account.suspended
  • sub_account.resumed
  • sub_account.topup_requested
  • number.call.received
  • number.sms.received
  • number.voicemail.received
  • verification.approved
  • verification.expired
  • invoice.created
  • invoice.issued
  • invoice.sent
  • invoice.voided
  • invoice.reissued
  • invoice.payment
  • credit_note.issued
  • payable.created
  • netting.run
  • sell_rate.changed
  • cost_rate.scheduled
  • cost_rate.activated
  • cost_rate.rolled_back
  • sub_account.margin_below_floor
  • revshare.call.received
  • revshare.number.taken
  • revshare.number.released

Rate limits

A single, fair limit per credential

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

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

Need more headroom? Talk to us

SDKs and 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 /openapi.json). Prefer a typed client? Every SDK is on the SDKs page.

# Nothing to install: plain JSON over HTTP
curl https://packetexchange.io/api/v1/routes?country=United+Kingdom \
  -H "Authorization: Bearer wmmn_live_sk_..."

Your first request is minutes away

A global team across time zones for help with routing and integration. Prepaid from $5, no contract.