# PacketExchange > PacketExchange is the live carrier marketplace for wholesale voice and SMS, connecting carriers, operators and developers on one exchange. Carriers list voice and SMS routes in about two minutes from their own rate sheet and reach buyers worldwide, with every call settled to the seller as it ends. Operators run a hosted Class-4 switch with no hardware, operated in plain English through AI Control, with traffic, margin and settlement in one live view. Developers get one API key and one prepaid balance for SMS, voice calls, the Verify API (one-time codes by SMS or voice call), phone numbers, AI voice agents, least-cost routing and caller ID tests, at the route rate plus a platform fee of 2%, capped at $0.001 per message or call. Key facts: - REST API base URL: https://packetexchange.io/api/v1. JSON over HTTPS. Authenticate with `Authorization: Bearer `. - API keys: live keys (`wmmn_live_sk_...`) send real traffic and charge the balance; test keys (`wmmn_test_sk_...`) simulate against test credit and return `simulated: true`. Keys can be limited to scopes such as `sms:send`, `voice:send`, `verify:write`, `routes:read`, `numbers:write`, `dialer:write` and `billing:write`. - Pricing: the route rate plus 2% (0.5% over your own SIP trunk), capped at $0.001 per message or call. Prepaid: top up from $5 by card or crypto, from $100 by wire, or with USDC on Base over x402 ($5 to $50,000). No contract, no monthly fee. - Money fields are USD decimal strings with exactly 6 places, for example "0.012500". - Errors: `{ "success": false, "error": { "code", "message", "details" } }`. 429 responses carry Retry-After. Send `X-Idempotency-Key` on requests that send, dial or buy. - The SMS send response status is the send-time outcome (accepted, sent or failed). Delivered or failed follows on GET /comms/sms/{id} and the sms.delivered / sms.failed webhooks only when the route returns a carrier receipt; a message that fails on its receipt is refunded. - Route quality on listings: `measured` carries ASR, ACD, NER and median PDD measured by PacketExchange on real buyer calls, once a listing has at least 50 calls from at least two buyers (null before that); expectedAsr, expectedAcd and expectedPdd are the figures the seller states. Seller identities are never exposed. - MCP: https://packetexchange.io/mcp/http (Streamable HTTP, stateless). Send the API key as the connection's `Authorization: Bearer` header; no tool takes a key argument. - Webhooks are signed: `X-PX-Signature: v1=.">`. Reject timestamps more than 5 minutes old. ## Carriers and operators - [List your routes](https://packetexchange.io/list-instantly): Sell voice and SMS termination: upload the rate sheet you already have, destinations and rates are read for you, and you sign in at the end. Free to list; the seller keeps its full rate. - [The Switch and AI Control](https://packetexchange.io/solutions/switch): A hosted Class-4 switch with least-cost routing, failover, A-Z rating, invoicing and netting, run in plain English through AI Control. No hardware. Includes PacketScope SIP packet views and CallGuard call screening. - [Why PacketExchange](https://packetexchange.io/why): How the platform widens margins: no hardware or licences, live settlement, automated operations. - [Marketplace](https://packetexchange.io/marketplace): Every listed route with its rate, caller ID type, capacity and quality. ## Developer docs - [Developer platform](https://packetexchange.io/developers): What the API does, how to start and what it costs. - [API reference](https://packetexchange.io/api-docs): Every endpoint, request, response, error code, scope and rate limit. - [API reference index](https://packetexchange.io/api-docs/reference): Every operation, grouped, each linking to its parameters and schemas. - [OpenAPI specification](https://packetexchange.io/api/v1/docs/json): The OpenAPI 3 document for the whole REST API, as JSON. - [SDKs](https://packetexchange.io/developers/sdks): Node.js and Python SDKs, the OpenAPI spec and the MCP server. - [Example gallery](https://packetexchange.io/developers/examples): Runnable examples for every product in eight languages. - [API changelog](https://packetexchange.io/developers/changelog): Dated changes to the API and what keeps working. - [Documentation](https://packetexchange.io/docs): Guides for the marketplace, the Switch and the API. ## Quickstarts - [Send an SMS](https://packetexchange.io/developers/quickstarts/send-sms): Send a transactional text from a number or an alphanumeric sender, and get its cost back in the response. Endpoints: POST /comms/sms, GET /comms/sms/{messageId}. - [Verify a phone number by SMS](https://packetexchange.io/developers/quickstarts/verify-sms): Text a one-time code and check what the user typed. We generate the code and keep only a hash of it. Endpoints: POST /verify/start, POST /verify/check. - [Verify a phone number by voice call](https://packetexchange.io/developers/quickstarts/verify-voice): Call the user and read a one-time code aloud, digit by digit, in English, Spanish, French, German, Portuguese or Hindi. Endpoints: POST /verify/start, POST /verify/check. - [Make a phone call](https://packetexchange.io/developers/quickstarts/make-a-call): Place an outbound call on a wholesale route and get back its outcome, duration and exact cost when it ends. Endpoints: POST /comms/calls. - [Build an AI voice agent](https://packetexchange.io/developers/quickstarts/ai-voice-agents): Create an agent from a script and rules, rehearse it in a simulated conversation, then attach it to an outbound campaign. Endpoints: POST /ai-agents, POST /ai-agents/{id}/simulate, PUT /dialer/campaigns/{id}. - [Buy a phone number](https://packetexchange.io/developers/quickstarts/buy-a-number): Search the number store by digits, buy a number and point its calls at your SIP server or another phone. Endpoints: GET /dids/search, POST /dids/buy, PATCH /dids/{id}/routing. - [Price a number (least-cost routing)](https://packetexchange.io/developers/quickstarts/price-a-number): See every route that serves a number at the rate it would charge for it, cheapest first, then preview which one Smart Routing would pick. Endpoints: GET /routes/price-number, GET /routes/resolve. - [Test a caller ID on a route](https://packetexchange.io/developers/quickstarts/caller-id-testing): Place a real test call over a route to a handset in the destination country and see which caller ID it displayed. Endpoints: POST /cli-tests, GET /cli-tests/{id}, GET /cli-tests/quota. - [Check routes before you buy](https://packetexchange.io/developers/quickstarts/check-routes): Place one short real call over up to three routes, read a plain verdict for each, then buy the ones that work with your routing order set. Endpoints: POST /routes/check, GET /routes/check/{id}, GET /routes/check/coverage, POST /routes/check/{id}/purchase, POST /routes/check/sms. - [Receive webhooks and verify signatures](https://packetexchange.io/developers/quickstarts/webhooks): Receive sends, completed calls and inbound messages as signed POSTs, and reject anything forged or replayed. Headers: X-PX-Signature, X-PX-Timestamp, X-Webhook-Event, X-Webhook-Id. - [Connect an AI agent over MCP](https://packetexchange.io/developers/quickstarts/mcp): Give Claude, Cursor or your own agent the marketplace as tools: price routes, verify numbers, send and check its balance. Endpoints: POST https://packetexchange.io/mcp/http. ## AI agents - [Build with AI agents](https://packetexchange.io/developers/agents): MCP set-up for Claude Code, Cursor, VS Code, Claude Desktop and Codex, the tool list and x402. - [MCP server](https://packetexchange.io/mcp): The hosted MCP server and every tool with its parameters. - [x402 top-ups](https://packetexchange.io/x402): Fund the balance with USDC on Base over HTTP 402: a balance top-up, not per-request payment. - [Full text for agents](https://packetexchange.io/llms-full.txt): This file plus every quickstart inlined with code and response shapes. ## Pricing - [Developer pricing](https://packetexchange.io/pricing/developers): The fee model and live rates for API traffic. - [Pricing](https://packetexchange.io/pricing): Pricing overview for carriers, the Switch and developers. - [Wholesale pricing](https://packetexchange.io/pricing/wholesale): Pricing for carriers trading on the marketplace. - [Price a number](https://packetexchange.io/developers/quickstarts/price-a-number): Every route for a number at the rate it would charge, cheapest first: GET https://packetexchange.io/api/v1/routes/price-number?number=&type=voice (no key needed). - [Marketplace](https://packetexchange.io/marketplace): Every listed route with its price and caller ID type. ## Products - [SMS messaging](https://packetexchange.io/solutions/sms): Send A2P SMS worldwide on routes bought direct from carriers, at wholesale rates, from one API. - [OTP & verification](https://packetexchange.io/solutions/verification): Send one-time codes by SMS or by a call that reads them aloud, check them with one request, and pay only for the message or call. - [Bulk & marketing SMS](https://packetexchange.io/solutions/marketing): Send promotions, reminders and lifecycle messages in bulk, on operator-direct routes priced from the wholesale market. - [Voice & numbers](https://packetexchange.io/solutions/voice): Outbound termination, inbound numbers and IVR over carrier routes, with the CLI type of every route stated before you buy. - [AI voice agents](https://packetexchange.io/solutions/ai-voice): Write the brief in plain English and the agent calls every number in the campaign: a natural voice, a real conversation and the answers captured as data, over wholesale routes you choose. - [Dialer & outbound](https://packetexchange.io/solutions/dialer): Run voice and SMS campaigns over the carrier routes you choose, with concurrency, calling windows, retries, CLI rotation and DNC suppression under your control. - [Phone numbers](https://packetexchange.io/solutions/numbers): Give your business a local presence in the markets you serve. Buy local, national, mobile and toll-free numbers, point them at your SIP switch or any phone, and take calls in about a minute. - [Wholesale marketplace](https://packetexchange.io/solutions/exchange): Buy voice termination from carriers who list their own routes, compare price and stated quality before you send a call, and pay as you go. Selling? List your routes and reach buyers without chasing bilateral contracts. - [Global connectivity](https://packetexchange.io/solutions/connectivity): Every route comes with its interconnect: a SIP trunk, digest credentials or IP authorisation, and standard codecs, issued with the purchase. - [Switch](https://packetexchange.io/solutions/switch): Bring your suppliers, sign your customers and keep the spread, with least-cost routing, A-Z rating, invoicing and settlement on one account. - [Smart Routing](https://packetexchange.io/solutions/smart-routing): Every call goes down the cheapest route you hold, and in a route group on your Switch it rolls to the next the moment a supplier fails. - [Revenue Turbine](https://packetexchange.io/solutions/route-blending): Revenue Turbine blends several routes into one sellable product: one weighted rate, pooled capacity and a blended quality profile, sold on the marketplace or run on your own switch. - [Caller ID testing](https://packetexchange.io/solutions/caller-id-testing): Real calls to real handsets in the destination country, with the number you presented set beside the number that arrived. - [Route quality & disputes](https://packetexchange.io/solutions/route-quality): Every route shows its ASR, ACD and PDD before you buy, your own calls are measured on each purchase, and when a route slips there is a formal way to raise it and get it fixed. - [Conference introductions](https://packetexchange.io/solutions/introductions): One scan connects two carriers on the exchange, with each other's routes and rates on screen, instead of swapping cards and chasing email for a fortnight. ## Optional - [Platform status](https://packetexchange.io/status): Live health checks of the platform. - [Terms](https://packetexchange.io/terms): Terms of service. - [Privacy](https://packetexchange.io/privacy): Privacy policy. - [Contact](https://packetexchange.io/contact): Sales and support. --- # Quickstarts in full Every quickstart follows the same steps: get an API key (open an account, create a key in the dashboard, export it as PACKETEXCHANGE_API_KEY), fund the prepaid balance, make the request, handle the response, go further. Code is shown here in cURL, Node and Python; the web pages also have PHP, Go, Java, C# and Ruby. ## Send an SMS URL: https://packetexchange.io/developers/quickstarts/send-sms One POST sends a message on a wholesale route. Leave the route out and Smart Routing picks one for the destination; the response tells you the segments billed and exactly what they cost. - Endpoints: POST /comms/sms, GET /comms/sms/{messageId} - Scope for a restricted key: sms:send - Cost: The route rate per segment, plus 2% of the route rate, never more than $0.001 per message. The response states the exact cost. - Key: A key restricted to scopes needs sms:send. A test key runs the request through routing and pricing and returns simulated: true without sending anything. - Funding: The message is charged to your prepaid balance at the route rate per segment, plus the platform fee. Long messages split into segments: 160 GSM-7 characters (153 per part when split) or 70 Unicode (67 per part). ### Request Send to an E.164 number from a sender ID: a number, or up to 30 letters, digits and spaces. The X-Idempotency-Key header makes a retry safe: the same key never sends or bills the message twice. ```bash curl https://packetexchange.io/api/v1/comms/sms \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: $(uuidgen)" \ -d '{ "to": "+447700900123", "from": "Acme", "message": "Your order 1042 has shipped." }' ``` ```javascript import { randomUUID } from 'node:crypto'; const API = 'https://packetexchange.io/api/v1'; // One request to the API. Throws on any non-2xx answer with the API's error code. async function px(method, path, body, headers = {}) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`, 'Content-Type': 'application/json', ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`); return json; } // No routeId, so Smart Routing picks the route. The idempotency key makes a retry safe. const { data } = await px( 'POST', '/comms/sms', { to: '+447700900123', from: 'Acme', message: 'Your order 1042 has shipped.' }, { 'X-Idempotency-Key': randomUUID() }, ); console.log(data.messageId, data.status, data.segments, data.cost); ``` ```python import os import uuid import requests API = "https://packetexchange.io/api/v1" KEY = os.environ["PACKETEXCHANGE_API_KEY"] def px(method, path, body=None, headers=None, timeout=30): """One request to the API. Exits on any non-2xx answer with the API's error code.""" res = requests.request( method, API + path, json=body, headers={"Authorization": f"Bearer {KEY}", **(headers or {})}, timeout=timeout, ) data = res.json() if not res.ok: raise SystemExit(f"{res.status_code} {data['error']['code']}: {data['error']['message']}") return data # No routeId, so Smart Routing picks the route. The idempotency key makes a retry safe. sms = px( "POST", "/comms/sms", {"to": "+447700900123", "from": "Acme", "message": "Your order 1042 has shipped."}, headers={"X-Idempotency-Key": str(uuid.uuid4())}, )["data"] print(sms["messageId"], sms["status"], sms["segments"], sms["cost"]) ``` ### Response A 200 means the message was handed to the route. The status here is the send-time outcome; delivery comes later, from the carrier's receipt, on routes that return one. 200 OK POST /comms/sms (placeholders in angle brackets): ```json { "success": true, "data": { "messageId": "", "to": "+447700900123", "from": "Acme", "status": "accepted", "segments": 1, "cost": "", "submittedAt": "" } } ``` - `messageId`: Look the message up later with GET /comms/sms/{messageId}. - `status`: accepted, sent or failed at send time. GET /comms/sms/{messageId} later shows delivered or failed when the route returns a carrier receipt (routeReturnsReceipts says whether it does), and the sms.delivered and sms.failed webhooks fire on it. A message that fails on its receipt is refunded. - `segments`: How many segments were billed. - `cost`: What this message cost you, fee included, as a 6-decimal USD string. Do arithmetic with a decimal type. - `simulated`: Present and true on a test key: nothing was sent. - Anything other than 2xx carries { "success": false, "error": { "code", "message", "details" } }. VALIDATION_ERROR lists each bad field in details. - A 429 carries Retry-After in seconds. Wait that long, then retry with the same idempotency key. - Quote the X-Request-Id response header if you contact support. ## Verify a phone number by SMS URL: https://packetexchange.io/developers/quickstarts/verify-sms The Verify API makes the code, sends it and checks it, so your app never stores a secret. One call starts a verification, one call checks it. - Endpoints: POST /verify/start, POST /verify/check - Scope for a restricted key: verify:write - Cost: The SMS that carries the code, at the route rate plus 2% of the route rate, never more than $0.001. There is no per-verification fee, and checking a code is free. - Key: A key restricted to scopes needs verify:write. On a test key nothing is sent and the start response includes testCode, so a sandbox can complete the flow. - Funding: Each start sends one SMS, billed like any other message. Checks cost nothing. ### Request Start a verification for an E.164 number with channel "sms", then check the code the user enters. brand is the name in the message; leave it out and your company name is used. The sample waits for you to type the code you received. ```bash # 1. Send the code. PacketExchange generates it, texts the code and keeps only a hash. verification_id=$(curl -s https://packetexchange.io/api/v1/verify/start \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: $(uuidgen)" \ -d '{"to": "+447700900123", "channel": "sms", "brand": "Acme"}' | jq -r .data.verificationId) # 2. Check the code the user typed in. A wrong code is an answer ("denied"), not an error. read -r -p "Code: " code curl -s https://packetexchange.io/api/v1/verify/check \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"verificationId\": \"$verification_id\", \"code\": \"$code\"}" | jq .data ``` ```javascript import { randomUUID } from 'node:crypto'; import { createInterface } from 'node:readline/promises'; const API = 'https://packetexchange.io/api/v1'; // One request to the API. Throws on any non-2xx answer with the API's error code. async function px(method, path, body, headers = {}) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`, 'Content-Type': 'application/json', ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`); return json; } // 1. Send the code. PacketExchange generates it, texts the code and keeps only a hash. const { data: started } = await px( 'POST', '/verify/start', { to: '+447700900123', channel: 'sms', brand: 'Acme' }, { 'X-Idempotency-Key': randomUUID() }, ); // 2. Check the code the user typed in. A wrong code is an answer ("denied"), not an error. const rl = createInterface({ input: process.stdin, output: process.stdout }); const code = (await rl.question('Code: ')).trim(); rl.close(); const { data: result } = await px('POST', '/verify/check', { verificationId: started.verificationId, code }); console.log(result.status, result.attemptsRemaining); ``` ```python import os import uuid import requests API = "https://packetexchange.io/api/v1" KEY = os.environ["PACKETEXCHANGE_API_KEY"] def px(method, path, body=None, headers=None, timeout=30): """One request to the API. Exits on any non-2xx answer with the API's error code.""" res = requests.request( method, API + path, json=body, headers={"Authorization": f"Bearer {KEY}", **(headers or {})}, timeout=timeout, ) data = res.json() if not res.ok: raise SystemExit(f"{res.status_code} {data['error']['code']}: {data['error']['message']}") return data # 1. Send the code. PacketExchange generates it, texts the code and keeps only a hash. started = px( "POST", "/verify/start", {"to": "+447700900123", "channel": "sms", "brand": "Acme"}, headers={"X-Idempotency-Key": str(uuid.uuid4())}, )["data"] # 2. Check the code the user typed in. A wrong code is an answer ("denied"), not an error. code = input("Code: ").strip() result = px("POST", "/verify/check", {"verificationId": started["verificationId"], "code": code})["data"] print(result["status"], result["attemptsRemaining"]) ``` ### Response Start returns a verificationId, never the code on a live key. Check returns a status: only "approved" proves the user holds the number. 200 OK POST /verify/start (placeholders in angle brackets): ```json { "success": true, "data": { "verificationId": "", "to": "+447700900123", "channel": "sms", "status": "pending", "expiresAt": "", "maxAttempts": 5, "sendRef": "", "sendStatus": "accepted", "createdAt": "" } } ``` 200 OK POST /verify/check (placeholders in angle brackets): ```json { "success": true, "data": { "verificationId": "", "status": "approved", "attemptsRemaining": } } ``` - `status (check)`: approved, denied, expired or max_attempts. A wrong code is a normal 200 answer with status denied, not an HTTP error. - `reason`: On a check that was not approved: wrong_code, already_used or not_pending. - `expiresAt`: Codes expire after 600 seconds by default; set expirySeconds from 60 to 3600. - `maxAttempts`: Five wrong guesses and the verification is dead. An approved verification cannot be approved again. - Starts are rate limited: 5 per number per hour, a 30-second resend cooldown, 20 per number range per hour and 1,000 per account per day. A refusal is a 429 with RATE_LIMITED. - Embargoed, high-risk and do-not-contact destinations are refused before anything is sent. - Check with the same kind of key (test or live) that started the verification. ## Verify a phone number by voice call URL: https://packetexchange.io/developers/quickstarts/verify-voice Some numbers cannot receive texts, and some users prefer a call. Pass channel "voice" and the same Verify API rings the number, reads the code twice and hangs up. - Endpoints: POST /verify/start, POST /verify/check - Scope for a restricted key: verify:write - Cost: The call that reads the code, at the route rate per billing increment plus 2% of the route rate, never more than $0.001. No text-to-speech surcharge and no per-verification fee. - Key: A key restricted to scopes needs verify:write. On a test key no call is placed and the start response includes testCode. - Funding: Each start places one short call (capped at 120 seconds), billed like any API call. Unanswered calls cost nothing. Checks are free. ### Request Start with channel "voice", a language (en, es, fr, de, pt or hi) and the caller ID the call should present in from. Then check the code exactly as for SMS. The sample waits for you to type the code you heard. ```bash # 1. Send the code. PacketExchange generates it, reads the code aloud on a call and keeps only a hash. verification_id=$(curl -s https://packetexchange.io/api/v1/verify/start \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: $(uuidgen)" \ -d '{"to": "+447700900123", "channel": "voice", "from": "+14155550100", "language": "en", "brand": "Acme"}' | jq -r .data.verificationId) # 2. Check the code the user typed in. A wrong code is an answer ("denied"), not an error. read -r -p "Code: " code curl -s https://packetexchange.io/api/v1/verify/check \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"verificationId\": \"$verification_id\", \"code\": \"$code\"}" | jq .data ``` ```javascript import { randomUUID } from 'node:crypto'; import { createInterface } from 'node:readline/promises'; const API = 'https://packetexchange.io/api/v1'; // One request to the API. Throws on any non-2xx answer with the API's error code. async function px(method, path, body, headers = {}) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`, 'Content-Type': 'application/json', ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`); return json; } // 1. Send the code. PacketExchange generates it, reads the code aloud on a call and keeps only a hash. const { data: started } = await px( 'POST', '/verify/start', { to: '+447700900123', channel: 'voice', from: '+14155550100', language: 'en', brand: 'Acme' }, { 'X-Idempotency-Key': randomUUID() }, ); // 2. Check the code the user typed in. A wrong code is an answer ("denied"), not an error. const rl = createInterface({ input: process.stdin, output: process.stdout }); const code = (await rl.question('Code: ')).trim(); rl.close(); const { data: result } = await px('POST', '/verify/check', { verificationId: started.verificationId, code }); console.log(result.status, result.attemptsRemaining); ``` ```python import os import uuid import requests API = "https://packetexchange.io/api/v1" KEY = os.environ["PACKETEXCHANGE_API_KEY"] def px(method, path, body=None, headers=None, timeout=30): """One request to the API. Exits on any non-2xx answer with the API's error code.""" res = requests.request( method, API + path, json=body, headers={"Authorization": f"Bearer {KEY}", **(headers or {})}, timeout=timeout, ) data = res.json() if not res.ok: raise SystemExit(f"{res.status_code} {data['error']['code']}: {data['error']['message']}") return data # 1. Send the code. PacketExchange generates it, reads the code aloud on a call and keeps only a hash. started = px( "POST", "/verify/start", {"to": "+447700900123", "channel": "voice", "from": "+14155550100", "language": "en", "brand": "Acme"}, headers={"X-Idempotency-Key": str(uuid.uuid4())}, )["data"] # 2. Check the code the user typed in. A wrong code is an answer ("denied"), not an error. code = input("Code: ").strip() result = px("POST", "/verify/check", {"verificationId": started["verificationId"], "code": code})["data"] print(result["status"], result["attemptsRemaining"]) ``` ### Response Start returns as soon as the call is dialled: sendStatus is "initiated" and sendRef is the call id. Check answers the same way as for SMS. 200 OK POST /verify/start (placeholders in angle brackets): ```json { "success": true, "data": { "verificationId": "", "to": "+447700900123", "channel": "voice", "status": "pending", "expiresAt": "", "maxAttempts": 5, "sendRef": "", "sendStatus": "initiated", "createdAt": "" } } ``` 200 OK POST /verify/check (placeholders in angle brackets): ```json { "success": true, "data": { "verificationId": "", "status": "approved", "attemptsRemaining": } } ``` - `language`: en, es, fr, de, pt or hi for voice. SMS codes also support ar. - `from`: The E.164 caller ID the code call presents. - `sendStatus`: "initiated" for a voice start: the call is on its way. The final outcome is recorded when it ends. - `status (check)`: approved, denied, expired or max_attempts. - The same limits as SMS apply: 5 starts per number per hour, a 30-second resend cooldown and 1,000 per account per day. - Already generate your own codes? POST /comms/voice-otp reads a code you supply and returns a voice_otp id you can look up later. - Errors use the standard envelope: { "success": false, "error": { "code", "message", "details" } }. ## Make a phone call URL: https://packetexchange.io/developers/quickstarts/make-a-call One POST dials the number and holds the request open until the call ends, then answers with the final outcome from the call record: answered or not, how long, and what it cost. - Endpoints: POST /comms/calls - Scope for a restricted key: voice:send - Cost: Billable seconds at the route rate, rounded to the route billing increment, plus 2% of the route rate, never more than $0.001 per call. Unanswered calls cost nothing. - Key: A key restricted to scopes needs voice:send. A test key simulates the call against test credit and returns status "accepted" with simulated: true. - Funding: The worst-case cost for maxDuration is reserved from your balance when the call starts, and the unused part is refunded at hangup. ### Request Dial an E.164 number with the caller ID to present in from. maxDuration (10 to 3,600 seconds, default 300) hangs up for you. Leave routeId out and Smart Routing picks the route; set your HTTP timeout longer than maxDuration. ```bash # The request stays open until the call ends, so allow longer than maxDuration. curl --max-time 180 https://packetexchange.io/api/v1/comms/calls \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: $(uuidgen)" \ -d '{ "to": "+447700900123", "from": "+14155550100", "maxDuration": 60 }' ``` ```javascript import { randomUUID } from 'node:crypto'; const API = 'https://packetexchange.io/api/v1'; // One request to the API. Throws on any non-2xx answer with the API's error code. async function px(method, path, body, headers = {}) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`, 'Content-Type': 'application/json', ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`); return json; } // The request stays open until the call ends: answered and hung up, unanswered, or maxDuration. const { data: call } = await px( 'POST', '/comms/calls', { to: '+447700900123', from: '+14155550100', maxDuration: 60 }, { 'X-Idempotency-Key': randomUUID() }, ); console.log(call.status, call.durationSeconds, call.billableSeconds, call.cost, call.hangupCause); ``` ```python import os import uuid import requests API = "https://packetexchange.io/api/v1" KEY = os.environ["PACKETEXCHANGE_API_KEY"] def px(method, path, body=None, headers=None, timeout=30): """One request to the API. Exits on any non-2xx answer with the API's error code.""" res = requests.request( method, API + path, json=body, headers={"Authorization": f"Bearer {KEY}", **(headers or {})}, timeout=timeout, ) data = res.json() if not res.ok: raise SystemExit(f"{res.status_code} {data['error']['code']}: {data['error']['message']}") return data # The request stays open until the call ends, so the timeout must be longer than maxDuration. call = px( "POST", "/comms/calls", {"to": "+447700900123", "from": "+14155550100", "maxDuration": 60}, headers={"X-Idempotency-Key": str(uuid.uuid4())}, timeout=180, )["data"] print(call["status"], call["durationSeconds"], call["billableSeconds"], call["cost"], call["hangupCause"]) ``` ### Response The response arrives when the call is over, so it already carries the billed result. To get the call id at once instead, send async: true and follow the call with GET /comms/calls/{id} or the call webhooks. 200 OK POST /comms/calls (placeholders in angle brackets): ```json { "success": true, "data": { "callId": "", "to": "+447700900123", "from": "+14155550100", "status": "answered", "sipResponseCode": 200, "hangupCause": "", "durationSeconds": , "billableSeconds": , "cost": "", "billingIncrement": "", "startedAt": "", "completedAt": "" } } ``` - `status`: answered, no_answer, busy or failed. accepted appears only on a simulated test-key call. - `billableSeconds`: The duration rounded up to the route billing increment. - `cost`: What the call cost you, fee included, as a 6-decimal USD string. - `hangupCause, sipResponseCode`: Why the call ended, for your logs and retries. - A call that is not answered is a 200 with status no_answer, busy or failed, not an HTTP error. - Send an X-Idempotency-Key so a retried request can never dial twice. - If the balance cannot cover maxDuration at the route rate, the call is refused before anything is dialled. ## Build an AI voice agent URL: https://packetexchange.io/developers/quickstarts/ai-voice-agents Describe what the agent says and the rules it keeps. Rehearse it turn by turn with no call placed, then attach it to a dialer campaign that calls over the routes you choose. - Endpoints: POST /ai-agents, POST /ai-agents/{id}/simulate, PUT /dialer/campaigns/{id} - Scope for a restricted key: dialer:write - Cost: Creating and simulating an agent is free. On live calls, one flat rate per AI-connected minute ($0.25 by default, billed per second) is added to the route cost. - Key: A key restricted to scopes needs dialer:write. GET /ai-agents/voices lists the voices the agent can speak with. - Funding: Nothing is billed until the agent is on a live campaign call. Then the route is billed as a normal call, plus the AI-connected minutes. ### Request Create the agent with a name, the first thing it says and its instructions (systemPrompt), then send one line as the person on the call and read the agent's reply. ```bash # 1. Create the agent: what it says first, and the instructions it follows. agent_id=$(curl -s https://packetexchange.io/api/v1/ai-agents \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Appointment confirmation", "language": "en", "firstMessage": "Hello, this is Riverside Clinic calling to confirm your appointment tomorrow at 10:30.", "systemPrompt": "You confirm appointments for Riverside Clinic. Ask whether the person can attend tomorrow at 10:30. Keep every reply short and polite.", "maxCallSeconds": 180 }' | jq -r .data.id) # 2. Rehearse one turn. No call is placed and nothing is billed. curl -s "https://packetexchange.io/api/v1/ai-agents/$agent_id/simulate" \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message": "Yes, I can still make it."}' | jq .data ``` ```javascript const API = 'https://packetexchange.io/api/v1'; // One request to the API. Throws on any non-2xx answer with the API's error code. async function px(method, path, body, headers = {}) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`, 'Content-Type': 'application/json', ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`); return json; } // 1. Create the agent: what it says first, and the instructions it follows. const { data: agent } = await px('POST', '/ai-agents', { name: 'Appointment confirmation', language: 'en', firstMessage: 'Hello, this is Riverside Clinic calling to confirm your appointment tomorrow at 10:30.', systemPrompt: 'You confirm appointments for Riverside Clinic. Ask whether the person can attend tomorrow at 10:30. Keep every reply short and polite.', maxCallSeconds: 180, }); // 2. Rehearse one turn. No call is placed and nothing is billed. const { data: turn } = await px('POST', `/ai-agents/${agent.id}/simulate`, { message: 'Yes, I can still make it.' }); console.log(agent.id, turn.reply, turn.action); ``` ```python import os import requests API = "https://packetexchange.io/api/v1" KEY = os.environ["PACKETEXCHANGE_API_KEY"] def px(method, path, body=None, headers=None, timeout=30): """One request to the API. Exits on any non-2xx answer with the API's error code.""" res = requests.request( method, API + path, json=body, headers={"Authorization": f"Bearer {KEY}", **(headers or {})}, timeout=timeout, ) data = res.json() if not res.ok: raise SystemExit(f"{res.status_code} {data['error']['code']}: {data['error']['message']}") return data # 1. Create the agent: what it says first, and the instructions it follows. agent = px("POST", "/ai-agents", { "name": "Appointment confirmation", "language": "en", "firstMessage": "Hello, this is Riverside Clinic calling to confirm your appointment tomorrow at 10:30.", "systemPrompt": "You confirm appointments for Riverside Clinic. Ask whether the person can attend tomorrow at 10:30. Keep every reply short and polite.", "maxCallSeconds": 180, })["data"] # 2. Rehearse one turn. No call is placed and nothing is billed. turn = px("POST", f"/ai-agents/{agent['id']}/simulate", {"message": "Yes, I can still make it."})["data"] print(agent["id"], turn["reply"], turn["action"]) ``` ### Response Creating returns the agent. Each simulated turn returns what the agent would say and what it would do next. 201 Created POST /ai-agents (placeholders in angle brackets): ```json { "success": true, "data": { "id": "", "name": "Appointment confirmation", "language": "en", "firstMessage": "Hello, this is Riverside Clinic calling to confirm...", "systemPrompt": "You confirm appointments for Riverside Clinic...", "maxCallSeconds": 180, "enabled": , "createdAt": "" } } ``` 200 OK POST /ai-agents/{id}/simulate (placeholders in angle brackets): ```json { "success": true, "data": { "reply": "", "action": "continue", "captured": {} } } ``` - `action`: continue, end or transfer: what the agent would do after this turn. - `captured`: Structured details picked up this turn, such as a name or an intent. - `history (request)`: Pass earlier turns to simulate a whole conversation, up to 60 of them. - `guardrails (request)`: Hard rules the agent must never break, kept separate from the script. - Simulation is rate limited to 20 requests per minute. - To go live, PUT /dialer/campaigns/{id} with { "aiAgentId": "" } on a campaign that is a draft, ready or paused. - Errors use the standard envelope with a VALIDATION_ERROR per bad field. ## Buy a phone number URL: https://packetexchange.io/developers/quickstarts/buy-a-number Search by a digit pattern, pick a number group and a SKU, and buy it from code. Calls and texts to the number reach your app, or ring wherever you point it. - Endpoints: GET /dids/search, POST /dids/buy, PATCH /dids/{id}/routing - Scope for a restricted key: numbers:read, numbers:write - Cost: Each SKU lists its setup price and monthly price. Buying charges the setup price plus the first month; after that the number renews monthly from your balance. - Key: Searching needs numbers:read and buying needs numbers:write on a restricted key. Where identity verification applies, it must be complete before you buy. - Funding: Fund at least the setup price plus one month. Keep the balance above the monthly price: a renewal that fails suspends the number for a grace period before it is released. ### Request Run the sample with no arguments to search. Run it again with a groupId and a skuId from the results to buy. The idempotency key makes sure a retry orders one number, not two. Search: no arguments. Buy: pass . ```bash # 1. Search the catalogue. Results are top-level "hits", each with a groupId and SKUs. curl -s "https://packetexchange.io/api/v1/dids/search?pattern=1415&limit=5" \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" | jq '.hits[] | {groupId, dialingPrefix, country, city, skus}' # 2. Buy one. This charges the setup price plus the first month to your balance. curl https://packetexchange.io/api/v1/dids/buy \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: $(uuidgen)" \ -d "{\"groupId\": \"$GROUP_ID\", \"skuId\": \"$SKU_ID\"}" ``` ```javascript import { randomUUID } from 'node:crypto'; const API = 'https://packetexchange.io/api/v1'; // One request to the API. Throws on any non-2xx answer with the API's error code. async function px(method, path, body, headers = {}) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`, 'Content-Type': 'application/json', ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`); return json; } const [groupId, skuId] = process.argv.slice(2); if (!groupId) { // 1. Search the catalogue. Results are top-level `hits`, each with a groupId and SKUs. const { hits = [] } = await px('GET', '/dids/search?pattern=1415&limit=5'); for (const h of hits) { for (const s of h.skus) console.log(h.dialingPrefix, h.country, h.groupId, s.skuId, s.setupPrice, s.monthlyPrice); } } else { // 2. Buy one. This charges the setup price plus the first month to your balance. const { data: did } = await px('POST', '/dids/buy', { groupId, skuId }, { 'X-Idempotency-Key': randomUUID() }); console.log(did.id, did.status, did.number ?? 'pending', did.setupPrice, did.monthlyPrice); } ``` ```python import os import sys import uuid import requests API = "https://packetexchange.io/api/v1" KEY = os.environ["PACKETEXCHANGE_API_KEY"] def px(method, path, body=None, headers=None, timeout=30): """One request to the API. Exits on any non-2xx answer with the API's error code.""" res = requests.request( method, API + path, json=body, headers={"Authorization": f"Bearer {KEY}", **(headers or {})}, timeout=timeout, ) data = res.json() if not res.ok: raise SystemExit(f"{res.status_code} {data['error']['code']}: {data['error']['message']}") return data if len(sys.argv) < 3: # 1. Search the catalogue. Results are top-level "hits", each with a groupId and SKUs. for h in px("GET", "/dids/search?pattern=1415&limit=5").get("hits", []): for s in h["skus"]: print(h["dialingPrefix"], h["country"], h["groupId"], s["skuId"], s["setupPrice"], s["monthlyPrice"]) else: # 2. Buy one. This charges the setup price plus the first month to your balance. group_id, sku_id = sys.argv[1], sys.argv[2] did = px( "POST", "/dids/buy", {"groupId": group_id, "skuId": sku_id}, headers={"X-Idempotency-Key": str(uuid.uuid4())}, )["data"] print(did["id"], did["status"], did["number"] or "pending", did["setupPrice"], did["monthlyPrice"]) ``` ### Response Search results are top-level hits, not wrapped in data. A new number starts pending and gets its digits when provisioning completes. 200 OK GET /dids/search (placeholders in angle brackets): ```json { "success": true, "hits": [ { "groupId": "", "country": "", "city": "", "typeName": "", "dialingPrefix": "", "skus": [ { "skuId": "", "channels": , "setupPrice": , "monthlyPrice": } ] } ], "scannedPages": , "truncated": false } ``` 201 Created POST /dids/buy (placeholders in angle brackets): ```json { "success": true, "data": { "id": "", "number": null, "status": "pending", "pointMode": "unrouted", "setupPrice": "", "monthlyPrice": "", "autoRenew": , "nextRenewalAt": "", "graceDays": } } ``` - `status`: pending, active, suspended (a renewal failed), released or failed. - `number`: null until provisioning completes, then the E.164 number. - `pointMode, pointsTo`: Where calls go: unrouted, sip (host[:port]) or forward (an E.164 number). - `data.disabled`: If the number store is unavailable, search answers 200 with disabled: true and a message. - Route the number with PATCH /dids/{id}/routing and { "mode": "sip", "target": "sip.example.com:5060" }, with an optional backup. - Inbound calls and texts arrive as number.call.received and number.sms.received webhooks. - Porting existing numbers in is not offered. ## Price a number (least-cost routing) URL: https://packetexchange.io/developers/quickstarts/price-a-number Before you send, ask the marketplace what a number costs. Each route is priced for that exact number by its longest matching prefix, and the resolve call shows the route your account would use for each strategy. - Endpoints: GET /routes/price-number, GET /routes/resolve - Scope for a restricted key: routes:read (resolve only) - Cost: Free. Nothing is sent or dialled. - Key: price-number works without a key (30 requests per minute). resolve runs as your account, so it needs a key with routes:read and also sees private routes you have bought. - Funding: Nothing to fund. Pricing and previews are free on every account. ### Request Price a number for voice (per minute) or sms (per message), then resolve it with a strategy: cheapest, best_quality or balanced (the default when you send without a routeId). ```bash # 1. Every route that serves the number, priced for it, cheapest first. No key needed. curl -s "https://packetexchange.io/api/v1/routes/price-number?number=%2B447700900123&type=voice" | jq '.data.routes[:5][] | {rate, destination, billingIncrement, cliType, expectedAsr}' # 2. The route Smart Routing would pick for your account, with a strategy. Free, nothing is dialled. curl -s "https://packetexchange.io/api/v1/routes/resolve?to=%2B447700900123&type=voice&strategy=cheapest" \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" | jq .data.selected ``` ```javascript const API = 'https://packetexchange.io/api/v1'; // One request to the API. Throws on any non-2xx answer with the API's error code. async function px(method, path, body, headers = {}) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`, 'Content-Type': 'application/json', ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`); return json; } // 1. Every route that serves the number, priced for it, cheapest first. No key needed. const { data: priced } = await px('GET', '/routes/price-number?number=%2B447700900123&type=voice'); for (const r of priced.routes.slice(0, 5)) { console.log(`${r.rate}/${priced.unit}`, r.destination, r.billingIncrement, r.cliType, r.expectedAsr); } // 2. The route Smart Routing would pick for your account. Free, nothing is dialled. const { data: resolved } = await px('GET', '/routes/resolve?to=%2B447700900123&type=voice&strategy=cheapest'); console.log('selected:', resolved.selected?.id, resolved.selected?.price); ``` ```python import os import requests API = "https://packetexchange.io/api/v1" KEY = os.environ["PACKETEXCHANGE_API_KEY"] def px(method, path, body=None, headers=None, timeout=30): """One request to the API. Exits on any non-2xx answer with the API's error code.""" res = requests.request( method, API + path, json=body, headers={"Authorization": f"Bearer {KEY}", **(headers or {})}, timeout=timeout, ) data = res.json() if not res.ok: raise SystemExit(f"{res.status_code} {data['error']['code']}: {data['error']['message']}") return data # 1. Every route that serves the number, priced for it, cheapest first. No key needed. priced = px("GET", "/routes/price-number?number=%2B447700900123&type=voice")["data"] for r in priced["routes"][:5]: print(f"{r['rate']}/{priced['unit']}", r["destination"], r["billingIncrement"], r["cliType"], r["expectedAsr"]) # 2. The route Smart Routing would pick for your account. Free, nothing is dialled. selected = px("GET", "/routes/resolve?to=%2B447700900123&type=voice&strategy=cheapest")["data"]["selected"] print("selected:", selected and selected["id"], selected and selected["price"]) ``` ### Response price-number lists up to 100 routes, cheapest first; total counts all of them. resolve returns the selected route and up to four ranked alternatives. 200 OK GET /routes/price-number (placeholders in angle brackets): ```json { "success": true, "data": { "number": "447700900123", "type": "voice", "unit": "min", "total": , "routes": [ { "id": "", "destination": "", "matchedPrefix": "", "rate": "", "billingIncrement": "", "pricedBy": "deck", "cliType": "", "expectedAsr": "", "exchangeScore": <1-100, or null until it has traffic> } ], "notice": null } } ``` 200 OK GET /routes/resolve (placeholders in angle brackets): ```json { "success": true, "data": { "strategy": "cheapest", "selected": { "id": "", "destinationName": "", "price": "", "cliType": "", "matchedPrefix": "" }, "alternatives": [], "count": } } ``` - `rate`: What this route charges for this number, per minute or per message, before the platform fee. - `measured`: ASR, ACD, NER and median PDD measured on real buyer calls on this listing; null until it has carried at least 50 calls from at least two buyers. - `expectedAsr, expectedAcd`: Stated by the seller when they listed the route. - `exchangeScore`: A 1-100 score from the tier and the measured figures when the listing has recent ones, else the stated figures; null ("New") until the route has carried traffic. - `notice`: "sanctioned" when the destination is embargoed and no route can serve it. - This is the public list price: negotiated prices and private grants are not applied to price-number. - Seller identities are never included in either response. - Pass the chosen route's id as routeId on a send, or pass a strategy and let Smart Routing choose at send time. ## Test a caller ID on a route URL: https://packetexchange.io/developers/quickstarts/caller-id-testing A route can change or drop your caller ID on the way. A caller-ID test calls a real handset in the route's country and reports exactly what the screen showed, before you put traffic on the route. - Endpoints: POST /cli-tests, GET /cli-tests/{id}, GET /cli-tests/quota - Scope for a restricted key: dialer:write - Cost: $0.50 per test, charged only if the route rang. GET /cli-tests/quota returns the live price and how many tests you have left this hour. - Key: A key restricted to scopes needs dialer:write. You can test any public marketplace route before you buy it, a private route you hold, a route blend, or your own switch with POST /cli-tests/quick. - Funding: Keep enough balance for the tests you run. A test that never rang is not charged. ### Request Queue a test with the routeId, the caller ID to present (displayCli) and the route's destination country, then read the result until it is final. Pass a route id from the marketplace or from price-a-number. ```bash # 1. Queue a test: a real call over your route to a handset in the route's country. test_id=$(curl -s https://packetexchange.io/api/v1/cli-tests \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"routeId\": \"$ROUTE_ID\", \"displayCli\": \"+14155550100\", \"testCountry\": \"United Kingdom\"}" | jq -r .data.id) # 2. Read the result until it is final (completed, failed, not_tested or cancelled). curl -s "https://packetexchange.io/api/v1/cli-tests/$test_id" \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" | jq '.data | {status, displayCli, reportedCli, displayedCorrectly}' ``` ```javascript const API = 'https://packetexchange.io/api/v1'; // One request to the API. Throws on any non-2xx answer with the API's error code. async function px(method, path, body, headers = {}) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`, 'Content-Type': 'application/json', ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`); return json; } const routeId = process.argv[2]; // a marketplace route id // 1. Queue a test: a real call over your route to a handset in the route's country. const { data: queued } = await px('POST', '/cli-tests', { routeId, displayCli: '+14155550100', testCountry: 'United Kingdom', }); // 2. Read the result every 10 seconds until it is final. const FINAL = ['completed', 'failed', 'not_tested', 'cancelled']; let test = queued; while (!FINAL.includes(test.status)) { await new Promise((r) => setTimeout(r, 10_000)); ({ data: test } = await px('GET', `/cli-tests/${queued.id}`)); } console.log(test.status, test.reportedCli, test.displayedCorrectly); ``` ```python import os import sys import time import requests API = "https://packetexchange.io/api/v1" KEY = os.environ["PACKETEXCHANGE_API_KEY"] def px(method, path, body=None, headers=None, timeout=30): """One request to the API. Exits on any non-2xx answer with the API's error code.""" res = requests.request( method, API + path, json=body, headers={"Authorization": f"Bearer {KEY}", **(headers or {})}, timeout=timeout, ) data = res.json() if not res.ok: raise SystemExit(f"{res.status_code} {data['error']['code']}: {data['error']['message']}") return data route_id = sys.argv[1] # a marketplace route id # 1. Queue a test: a real call over your route to a handset in the route's country. test = px("POST", "/cli-tests", { "routeId": route_id, "displayCli": "+14155550100", "testCountry": "United Kingdom", })["data"] # 2. Read the result every 10 seconds until it is final. while test["status"] not in ("completed", "failed", "not_tested", "cancelled"): time.sleep(10) test = px("GET", f"/cli-tests/{test['id']}")["data"] print(test["status"], test["reportedCli"], test["displayedCorrectly"]) ``` ### Response The test starts queued and moves through dispatching and in_progress. A final result says what the handset displayed and whether it matched. 200 OK GET /cli-tests/{id} (placeholders in angle brackets): ```json { "success": true, "data": { "id": "", "routeId": "", "displayCli": "+14155550100", "testCountry": "United Kingdom", "testNumber": "", "status": "completed", "reportedCli": "", "displayedCorrectly": true, "resultNotes": "", "completedAt": "" } } ``` - `status`: scheduled, pending, dispatching, in_progress, then completed, failed, not_tested or cancelled. - `reportedCli`: What the handset actually showed, or null. - `displayedCorrectly`: true when it matched displayCli, false when it did not, null when unknown. - `recurrence (request)`: none, daily or weekly, to keep testing a route on a schedule. - Tests are limited per hour; GET /cli-tests/quota shows usedThisHour, limitPerHour and remaining. - testCountry must be the destination country of the route you test. - Errors use the standard envelope: { "success": false, "error": { "code", "message", "details" } }. ## Check routes before you buy URL: https://packetexchange.io/developers/quickstarts/check-routes Before you buy, learn whether a route actually carries calls to your destination right now. A route check places one short real call over each route you pick and tells you, in one sentence per route, whether it worked. - Endpoints: POST /routes/check, GET /routes/check/{id}, GET /routes/check/coverage, POST /routes/check/{id}/purchase, POST /routes/check/sms - Scope for a restricted key: voice:send - Cost: The route's rate for the seconds the check call was connected (usually about two), plus 2% of the route rate, never more than $0.001. A route that does not connect costs nothing, and checking your own listing is free. - Key: A key restricted to scopes needs voice:send to start a check, routes:read to read it and purchases:write to buy. You can check any public marketplace route before you buy it, or a private route you have access to. A test key returns a simulated result at once: no call, no charge. - Funding: A check is billed like a call, so it needs a funded balance. Limits: 10 checks a minute and 60 an hour per account. ### Request Send 1 to 3 route ids in your order: the first is your primary, the rest backups. Add the number you plan to call (or its dial code) so the check uses a line of the same kind, mobile or fixed. The call goes to a public line in the destination that answers by itself and is ended after about two seconds. Set mode to ring_me with your own number to have the routes ring your phone instead. Pass one to three route ids from the marketplace or from price-a-number, primary first. ```bash # 1. Check up to three routes, in your order: the first is your primary, the rest backups. check_id=$(curl -s https://packetexchange.io/api/v1/routes/check \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"routeIds\": [\"$ROUTE_1\", \"$ROUTE_2\"], \"number\": \"+923001234567\"}" | jq -r .data.checkId) # 2. Read the verdicts until status is done (usually well under a minute). curl -s "https://packetexchange.io/api/v1/routes/check/$check_id" \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" | jq '.data | {status, totalCost, routes: [.routes[] | {role, routeName, result, message}]}' # 3. Buy the routes that passed; they go to the top of your routing order, primary first. curl -s -X POST "https://packetexchange.io/api/v1/routes/check/$check_id/purchase" \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" | jq '.data.routingOrder' ``` ```javascript const API = 'https://packetexchange.io/api/v1'; // One request to the API. Throws on any non-2xx answer with the API's error code. async function px(method, path, body, headers = {}) { const res = await fetch(API + path, { method, headers: { Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`, 'Content-Type': 'application/json', ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`); return json; } const routeIds = process.argv.slice(2, 5); // 1 to 3 marketplace route ids, primary first // 1. Check the routes: one short real call over each to a line in the destination. const { data: started } = await px('POST', '/routes/check', { routeIds, number: '+923001234567' }); // 2. Read the verdicts every 2 seconds until the check is done. let check; do { await new Promise((r) => setTimeout(r, 2000)); ({ data: check } = await px('GET', `/routes/check/${started.checkId}`)); } while (check.status !== 'done'); for (const r of check.routes) console.log(r.role, r.routeName, r.result, r.message); // 3. Buy the routes that passed; they go to the top of your routing order, primary first. if (check.working.length) { const { data: bought } = await px('POST', `/routes/check/${check.id}/purchase`); console.log(bought.routingOrder); } ``` ```python import os import sys import time import requests API = "https://packetexchange.io/api/v1" KEY = os.environ["PACKETEXCHANGE_API_KEY"] def px(method, path, body=None, headers=None, timeout=30): """One request to the API. Exits on any non-2xx answer with the API's error code.""" res = requests.request( method, API + path, json=body, headers={"Authorization": f"Bearer {KEY}", **(headers or {})}, timeout=timeout, ) data = res.json() if not res.ok: raise SystemExit(f"{res.status_code} {data['error']['code']}: {data['error']['message']}") return data route_ids = sys.argv[1:4] # 1 to 3 marketplace route ids, primary first # 1. Check the routes: one short real call over each to a line in the destination. check_id = px("POST", "/routes/check", {"routeIds": route_ids, "number": "+923001234567"})["data"]["checkId"] # 2. Read the verdicts every 2 seconds until the check is done. while True: time.sleep(2) check = px("GET", f"/routes/check/{check_id}")["data"] if check["status"] == "done": break for r in check["routes"]: print(r["role"], r["routeName"], r["result"], r["message"]) # 3. Buy the routes that passed; they go to the top of your routing order, primary first. if check["working"]: print(px("POST", f"/routes/check/{check_id}/purchase")["data"]["routingOrder"]) ``` ### Response POST returns 202 with a checkId straight away. Read the check until status is done; each route carries its verdict and the sentence to show. 202 Accepted POST /routes/check (placeholders in angle brackets): ```json { "success": true, "data": { "checkId": "" } } ``` 200 OK GET /routes/check/{id} (placeholders in angle brackets): ```json { "success": true, "data": { "id": "", "status": "done", "mode": "network", "simulated": false, "number": "+923001234567", "totalCost": "", "routes": [ { "position": 0, "role": "primary", "routeId": "", "routeName": "", "result": "working", "message": "This route is working for your needs.", "checkType": "full", "destination": "Pakistan", "lineType": "mobile", "answerMs": 4210, "billableSeconds": 2, "cost": "", "paidFrom": "test_credit" }, { "position": 1, "role": "backup", "routeId": "", "routeName": "", "result": "no_capacity", "message": "No capacity on this route right now. Choose another route.", "checkType": "full", "sipCode": 503, "billableSeconds": 0, "cost": "0.000000" } ], "working": [""] } } ``` - `result`: working (answered), no_capacity (refused or no response), not_delivered (rang but never reached the line), false_answer (answered under 1.5 seconds after our INVITE, or under 2.5 seconds with no ringing: faster than a real line can answer, so the route almost certainly answered the call itself; not charged), inconclusive (no verdict, nothing counted against the route) or accepts (the lighter check below). - `answerMs`: Milliseconds from our INVITE to the answer, when the call was answered. The evidence behind false_answer. - `paidFrom`: Which wallet paid a charged leg: test_credit, balance or mixed. A check is a test call, so your test credit is used first and your balance only for the rest. GET /routes/check/coverage tells you, per route, whether we have a test line; when we do not, check with mode ring_me and a number of your own. - `checkType`: full is a call to a test line; light means we have no test line for that destination yet, so we only confirmed the route accepts calls and called no one; ring_me is your own phone; simulated is a test key. - `message`: The plain sentence to show a person. - `working`: The route ids that passed, in your order. - POST /routes/check/{id}/purchase buys the routes that passed and puts them at the top of your routing order, the first as primary. Routes you already bought are reused, and a route that cannot be bought is reported with its reason. - Route names in the response never identify the seller. - For an SMS route, POST /routes/check/sms with { "routeId", "number" } sends one fixed test message to your own mobile (scope sms:send). Read it with the same GET /routes/check/{id}: accepted, then delivered once a carrier receipt confirms it, or refused with an errorCode. A refused message is not charged. - Errors use the standard envelope: { "success": false, "error": { "code", "message", "details" } }; too many checks answer 429 RATE_LIMITED. ## Receive webhooks and verify signatures URL: https://packetexchange.io/developers/quickstarts/webhooks Every delivery is signed with your endpoint's secret and a timestamp. Check the signature over the raw body, refuse anything older than five minutes, and answer quickly with a 2xx. - Endpoints: X-PX-Signature, X-PX-Timestamp, X-Webhook-Event, X-Webhook-Id - Cost: Free. - Key: Create webhook endpoints in the dashboard under Webhooks. API keys cannot create or edit them, so a leaked key can never redirect your events. The signing secret is shown once when you create the endpoint. - Funding: Nothing to fund. Webhooks cost nothing to receive. ### Request X-PX-Signature is v1= followed by the hex HMAC-SHA256 of ".", keyed with your secret. Verify it on the exact bytes you received, before parsing the JSON. The cURL tab sends your receiver a correctly signed test delivery. Set PACKETEXCHANGE_WEBHOOK_SECRET to the endpoint's signing secret. The receivers listen on port 3000. ```bash # Send your receiver a signed test delivery, exactly as PacketExchange signs one: # X-PX-Signature: v1=."> body='{"event":"ping","data":{},"timestamp":"2026-01-01T00:00:00.000Z"}' ts=$(date +%s) sig=$(printf '%s.%s' "$ts" "$body" | openssl dgst -sha256 -hmac "$PACKETEXCHANGE_WEBHOOK_SECRET" | sed 's/^.* //') curl -i http://localhost:3000/webhooks \ -H "Content-Type: application/json" \ -H "X-Webhook-Event: ping" \ -H "X-PX-Timestamp: $ts" \ -H "X-PX-Signature: v1=$sig" \ --data-raw "$body" ``` ```javascript import { createHmac, timingSafeEqual } from 'node:crypto'; import { createServer } from 'node:http'; const SECRET = process.env.PACKETEXCHANGE_WEBHOOK_SECRET; // Check X-PX-Signature over the raw bytes, and refuse anything older than 5 minutes. function verify(timestamp, signature, rawBody) { if (!/^\d+$/.test(timestamp ?? '') || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const mac = createHmac('sha256', SECRET).update(`${timestamp}.`).update(rawBody).digest('hex'); const expected = Buffer.from(`v1=${mac}`); const given = Buffer.from(signature ?? ''); return given.length === expected.length && timingSafeEqual(given, expected); } createServer((req, res) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const raw = Buffer.concat(chunks); if (!verify(req.headers['x-px-timestamp'], req.headers['x-px-signature'], raw)) { return res.writeHead(401).end('bad signature'); } const { event, data } = JSON.parse(raw.toString('utf8')); console.log('received', event, data); res.writeHead(200).end('ok'); // answer fast; do slow work after responding }); }).listen(3000); ``` ```python import hashlib import hmac import json import os import time from http.server import BaseHTTPRequestHandler, HTTPServer SECRET = os.environ["PACKETEXCHANGE_WEBHOOK_SECRET"].encode() def verify(timestamp, signature, raw_body): """Check X-PX-Signature over the raw bytes, and refuse anything older than 5 minutes.""" if not (timestamp or "").isdigit() or abs(time.time() - int(timestamp)) > 300: return False mac = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(signature or "", f"v1={mac}") class Webhooks(BaseHTTPRequestHandler): def do_POST(self): raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) if not verify(self.headers.get("X-PX-Timestamp"), self.headers.get("X-PX-Signature"), raw): self.send_response(401) self.end_headers() return delivery = json.loads(raw) print("received", delivery["event"], delivery["data"]) self.send_response(200) # answer fast; do slow work after responding self.end_headers() HTTPServer(("", 3000), Webhooks).serve_forever() ``` ### Response Each delivery is a POST with a JSON body of the event name, its data and the time it happened. POST to your endpoint event sms.sent (placeholders in angle brackets): ```json { "event": "sms.sent", "data": { "messageId": "", "to": "+447700900123", "from": "Acme", "status": "accepted", "segments": 1, "cost": "", "submittedAt": "" }, "timestamp": "" } ``` - `X-PX-Timestamp`: Unix seconds when this attempt was sent. Reject it when it is more than 5 minutes from now. - `X-PX-Signature`: v1=.">. Compare in constant time. - `X-Webhook-Id`: The delivery id, to de-duplicate retries. - `X-Webhook-Signature`: The legacy body-only signature, still sent during the transition. Prefer v1. - Answer with any 2xx within 10 seconds, then do slow work afterwards. A failed delivery is retried up to 5 times. - Every delivery is in the dashboard's delivery log, where you can resend any your endpoint missed. - Events include sms.sent, call.completed, number.sms.received, number.call.received, topup.confirmed and balance.low. ## Connect an AI agent over MCP URL: https://packetexchange.io/developers/quickstarts/mcp The hosted MCP server exposes the same public API as a set of tools. Point any MCP client at one URL with your key in the Authorization header; there is nothing to install or run. - Endpoints: POST https://packetexchange.io/mcp/http - Scope for a restricted key: the scopes of each tool - Cost: Connecting is free, and the discovery and preview tools are free. Tools that send, call or verify bill exactly like the REST API, and say so in their descriptions. - Key: The key goes in the client configuration as an Authorization header, never in a chat or a tool argument, so it never reaches the model. Use a key scoped to what the agent needs, and a test key while you build. - Funding: Browsing is free. For tools that spend, fund the balance, or let the agent top itself up with wmmn_topup_x402 (USDC on Base, $5 to $50,000). ### Request MCP clients do this for you, but it is plain JSON-RPC over HTTP. This is the tools/call an agent makes to preview the cheapest route for a number. The endpoint is stateless: every POST stands alone, with no session to open first. ```bash # One JSON-RPC call to the hosted MCP server: the same tool an agent would call. # The key rides in the Authorization header, never in the tool arguments. curl -s https://packetexchange.io/mcp/http \ -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "wmmn_resolve_route", "arguments": { "to": "+447700900123", "type": "voice", "strategy": "cheapest" } } }' | jq -r '.result.content[0].text' ``` ```javascript // One JSON-RPC call to the hosted MCP server: the same tool an agent would call. // The key rides in the Authorization header, never in the tool arguments. const res = await fetch('https://packetexchange.io/mcp/http', { method: 'POST', headers: { Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'wmmn_resolve_route', arguments: { to: '+447700900123', type: 'voice', strategy: 'cheapest' }, }, }), }); const { result, error } = await res.json(); if (error) throw new Error(error.message); console.log(result.isError ? 'tool error:' : 'result:', result.content[0].text); ``` ```python import os import requests # One JSON-RPC call to the hosted MCP server: the same tool an agent would call. # The key rides in the Authorization header, never in the tool arguments. res = requests.post( "https://packetexchange.io/mcp/http", headers={ "Authorization": f"Bearer {os.environ['PACKETEXCHANGE_API_KEY']}", "Accept": "application/json, text/event-stream", }, json={ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "wmmn_resolve_route", "arguments": {"to": "+447700900123", "type": "voice", "strategy": "cheapest"}, }, }, timeout=30, ) reply = res.json() if "error" in reply: raise SystemExit(reply["error"]["message"]) print(reply["result"]["content"][0]["text"]) ``` ### Response The tool result is text holding the same JSON the REST API returns. A tool that fails returns isError: true with the reason. 200 OK tools/call wmmn_resolve_route (placeholders in angle brackets): ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{ \"strategy\": \"cheapest\", \"selected\": { ... }, \"alternatives\": [ ... ] }" } ] } } ``` - `result.content[0].text`: The tool output: the REST response data as JSON text. - `result.isError`: true when the tool failed, with the reason in the text. - `Accept`: Send application/json, text/event-stream; the server answers with plain JSON. - An account tool on a connection with no key returns a tool error asking for the Authorization header. - wmmn_list_routes, wmmn_route_details and wmmn_market_summary work with no key at all. - Clients that only speak SSE can use https://packetexchange.io/mcp/sse through mcp-remote --transport sse-only. --- # MCP set-up Endpoint: https://packetexchange.io/mcp/http (Streamable HTTP, stateless). Legacy SSE: https://packetexchange.io/mcp/sse, through mcp-remote --transport sse-only. ## Claude Code (.mcp.json) Claude Code expands ${PACKETEXCHANGE_API_KEY} from your shell, so the key never sits in the file. ```json { "mcpServers": { "packetexchange": { "type": "http", "url": "https://packetexchange.io/mcp/http", "headers": { "Authorization": "Bearer ${PACKETEXCHANGE_API_KEY}" } } } } ``` ## Cursor (~/.cursor/mcp.json) Cursor reads ${env:PACKETEXCHANGE_API_KEY} from the environment it was started in. ```json { "mcpServers": { "packetexchange": { "url": "https://packetexchange.io/mcp/http", "headers": { "Authorization": "Bearer ${env:PACKETEXCHANGE_API_KEY}" } } } } ``` ## VS Code (.vscode/mcp.json) VS Code asks for the key once and stores it in its secret storage, not in the file. ```json { "servers": { "packetexchange": { "type": "http", "url": "https://packetexchange.io/mcp/http", "headers": { "Authorization": "Bearer ${input:packetexchange-api-key}" } } }, "inputs": [ { "type": "promptString", "id": "packetexchange-api-key", "description": "PacketExchange API key", "password": true } ] } ``` ## Claude Desktop (claude_desktop_config.json) Claude Desktop launches local commands, so mcp-remote bridges to the hosted endpoint and adds the header. ```json { "mcpServers": { "packetexchange": { "command": "npx", "args": [ "-y", "mcp-remote", "https://packetexchange.io/mcp/http", "--header", "Authorization:${PX_AUTH_HEADER}" ], "env": { "PX_AUTH_HEADER": "Bearer wmmn_live_sk_YOUR_KEY" } } } } ``` ## Codex (~/.codex/config.toml) Codex sends the value of PACKETEXCHANGE_API_KEY as the bearer token. ```toml [mcp_servers.packetexchange] url = "https://packetexchange.io/mcp/http" bearer_token_env_var = "PACKETEXCHANGE_API_KEY" ``` ## Tools ### Discover - `wmmn_list_routes`: Search marketplace routes by destination, type, price ceiling and seller-stated ASR. No key needed. - `wmmn_route_details`: One route in full: price, billing increment, stated quality and Exchange Score. No key needed. - `wmmn_market_summary`: Route and destination counts right now; averages too when a key is present. No key needed. - `wmmn_resolve_route`: Preview which route Smart Routing would pick for a number, with alternatives. ### Connect - `wmmn_purchase_route`: Connect a route to the account: no upfront charge, traffic then bills per use. Live key only. - `wmmn_list_purchases`: The routes you have connected, with SIP credentials. Full-access key only. ### Send - `wmmn_send_sms`: Send an SMS; returns segments and the exact cost. - `wmmn_make_call`: Place a call that can speak, play audio and collect keypad digits; returns the outcome and cost, or a call id while it runs. - `wmmn_get_call`: Live status of a call: answered, duration, cost, hangup reason and gathered digits. ### Verify - `wmmn_verify_start`: Send a one-time code by SMS or a voice call. Only a hash of the code is kept. - `wmmn_verify_check`: Check a code: approved, denied, expired or max_attempts. - `wmmn_send_voice_otp`: Read your own code aloud on a call in en, es, fr, de, pt or hi. ### Account - `wmmn_get_balance`: Prepaid balance and test credit. - `wmmn_list_transactions`: Recent charges, credits and top-ups. - `wmmn_topup_x402`: Fund the balance with USDC on Base over x402, $5 to $50,000. 18 further `switch_*` tools manage a hosted Switch and need an operator key on an active Switch plan. --- # Runnable examples - Verify by SMS (POST /verify/start, POST /verify/check): Send a one-time code by text, then check what the user typed. Cost: The SMS that carries the code, at the route rate plus the fee. No per-verification fee. - Verify by voice (POST /verify/start, POST /verify/check): The same flow, with the code read aloud on a call. Cost: The call that reads the code, per billing increment plus the fee. - Send an SMS (POST /comms/sms, GET /comms/sms/{id}): A transactional reminder, then its status. Cost: The route rate per segment plus the fee. - Make a call (POST /comms/calls, GET /comms/calls): Place a call and read its outcome, duration and cost. Cost: Billable seconds at the route rate plus the fee. Unanswered calls cost nothing. - Price a number (GET /routes/price-number, GET /routes/resolve): Rank every route for a number by real cost, then preview Smart Routing. Cost: Free. - Webhooks (X-PX-Signature, X-PX-Timestamp): A small receiver that verifies signatures before trusting a delivery. Cost: Free. - Phone numbers (GET /dids/search, POST /dids/buy, PATCH /dids/{id}/routing): Search the number store, buy a number and point it at your SIP server. Cost: Buying charges the setup price plus the first month; it asks for --confirm first. - AI voice agent (POST /ai-agents, POST /ai-agents/{id}/simulate): Create an agent, rehearse a turn and attach it to a campaign. Cost: Creating and simulating are free. Calls bill per AI-connected minute. - Caller ID test (POST /cli-tests, GET /cli-tests/{id}): See which caller ID a route actually delivers to a handset. Cost: $0.50 per test, charged only if the route rang. - x402 top-up (POST /topups/x402): Fund the balance with USDC on Base: read the 402 terms, sign, retry. Cost: Moves the USDC you choose, $5 to $50,000; it asks for --confirm first.