Skip to main content

Testing your integration

A sandbox secret key (sk_test_...) is a full API key for a sandbox account: everything you can do in live — create users, order numbers, assign devices, build dial plans — you can do there, without touching your live account.

Calling is where sandbox differs, and the distinction that matters is between a real call and a simulated one:

  • Real calls. A sandbox account is a real account: its users, devices, and softphones register and ring for real, and calls between them run the live code path. Placing one works however you place one in live — a deskphone or softphone dialing an extension, click-to-call, a ring group, a queue. The single thing sandbox withholds is the public telephone network.
  • Simulated calls. POST /v1/test_helpers/calls fabricates a call and drives its whole lifecycle on timers, with no devices involved. This is how you exercise your webhook handling — including outcomes that are hard to produce on demand, like a busy far end or a voicemail deposit — and it's the only path that needs a sandbox-specific endpoint.

Simulated events are fabricated, but everything downstream of them is the production path. Webhooks arrive over the same signed, retried, ordered delivery your live traffic uses. The /v1/events SSE feed mirrors most of these, but a couple of events — call.ringing and call.emergency — are delivered only by webhook.

Every webhook event DialStack emits in production can be triggered in sandbox: most fire automatically as a call plays out, a few are triggered by dialing a magic number, and the rest are sent on demand.

What you need

A sandbox account, and a sandbox key (sk_test_...) for it. Point your requests at the same base URL as production, pass the key, and name the account:

Text
Authorization: Bearer sk_test_YOUR_SECRET_KEY
DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT

That is the whole setup for real calls. Two users with a registered device or softphone each can dial one another's extensions with nothing else provisioned — no phone number involved, since an internal call never needs one.

Simulating calls needs one more thing: a phone number on the sandbox account. A simulated call fabricates a call record, and the record needs a number to report as the account side, so the simulator uses the account's first phone number — POST /v1/test_helpers/calls returns 400 when there is none. Order one on the sandbox account itself; sandbox numbers order instantly, and they cost nothing.

That number is a real, listable resource, and the simulator treats it as one: outbound simulations set the call record's from_number from it, inbound simulations target it, and it appears in GET /v1/phone-numbers like any other number. A simulated call never references a number that doesn't exist.

Every simulated event carries the sandbox account_id, so you can always tell sandbox traffic from live.

Receiving webhooks locally

Simulated events go to your platform's sandbox webhook endpoint — the one you configure separately from your live endpoint, so sandbox traffic never reaches your production webhook. Point that endpoint wherever your code runs. To catch events on your own machine during development, expose your local server with an HTTP tunnel and set the sandbox webhook URL to the address it returns:

Bash
cloudflared tunnel --url http://localhost:3000
# → https://random-name.trycloudflare.com (set this as your sandbox webhook URL)

ngrok and other tunnels work the same way.

If you only need the core call.* lifecycle, you can read events from the /v1/events SSE feed instead of standing up a tunnel. Note that call.ringing and call.emergency are webhook-only, so a harness that waits for them on SSE will hang — use webhooks if your flow depends on either.

Outbound calls

Real calls

A sandbox account places real calls. Its phones really register, really ring, and really connect to each other — a user picking up a deskphone and dialing an extension, or your integration calling POST /v1/calls, gets a genuine call, not a mock of one. The destination is the only thing sandbox constrains: internal destinations connect, the public network is refused.

POST /v1/calls (click-to-call) is the one a test can drive, so it's the one worth showing:

Bash
curl -X POST https://api.dialstack.ai/v1/calls \
-H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \
-H "DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT" \
-H "Content-Type: application/json" \
-d '{"user": "user_01...", "dial_string": "1002"}'

The response is 202 Accepted with no body, exactly as in production. What happens next depends on where you dialed:

  • An internal destination — another user's extension, a ring group, a queue, a voice app. The call connects for real, end to end, and produces the full event lifecycle and call record that a live call would. Use this to validate your integration against real device behavior.
  • A phone number on the public network. Sandbox accounts have no access to it. The caller hears an announcement explaining that the destination was not dialed, and the call ends there — for a click-to-call, that means their devices still ring and the announcement plays when they answer. Because the far end was never reached, no call was ever placed — so no call.* webhooks are emitted for it. To exercise your webhook handling for an outbound call, simulate one instead.

If the user has no registered device, nothing rings and nothing happens — the same as in live. Standing up devices is not a prerequisite for testing your webhooks; use the simulated path below.

Simulated outbound calls

POST /v1/test_helpers/calls with direction: "outbound" fabricates an outbound call and runs its whole lifecycle on timers, with no devices involved. This is the path to use when what you are testing is your webhook handling. It returns 200 with the started call's id and scenario, so a test harness can assert on the response directly.

Bash
curl -X POST https://api.dialstack.ai/v1/test_helpers/calls \
-H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \
-H "DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT" \
-H "Content-Type: application/json" \
-d '{"user": "user_01...", "direction": "outbound", "to_number": "+15005550100"}'

user is the sandbox user the call is attributed to — a user_... value from GET /v1/users. to_number is the destination; its magic-number value picks the outcome, or pass a scenario object to script it explicitly. For the answered scenario above, the events arrive in order:

  • call.initiatedcall.ringingcall.answeredcall.end
  • then, a few seconds later: recording.availablerecording.transcription.completerecording.summary.complete

During the call: while a call is in flight, GET /v1/calls/{id} returns 200 with a sparse live projection. status is null, and fields known only after the call ends (such as duration and the recording) are absent until the final record lands after call.end. This is the same behavior as a live call.

Scenario magic numbers

The destination you pass as to_number scripts the outcome of a simulated outbound call, the same way Stripe uses test cards and Twilio uses test numbers:

Number dialedWhat it simulates
+15005550100Answered after 5s, ~30s of talk time, recording produced
+15005550101Rings for 20s, no answer
+15005550102Busy
+15005550103The far end's answering machine picks up — recorded as a normal answered call (why)
+15005550104Answered, then transferred mid-call (call.transfer)
933Answered, and additionally emits call.emergency
any other numberDefault: answered, short call, recording produced

A few things to know:

  • A simulated call dials nothing, so these numbers never ring anyone. They're also in the reserved 555-01xx range that carriers never assign to a subscriber, so a magic number that slips into your production config dials a dead number instead of ringing someone.
  • Matching is on the digits, so +15005550100, 15005550100, and 500-555-0100 all resolve to the same scenario.
  • 933 (the emergency self-test number) also emits call.emergency so you can exercise your emergency-call handling — no real emergency call is placed.
  • 911 is intentionally not a sandbox trigger.
  • Magic numbers apply to the simulated paths only. Passing one to POST /v1/calls dials nothing special — it is a public-network destination like any other, so sandbox denies it.

A note on voicemail

On an outbound call there is no reliable signal for whether a person or an answering machine answered; the two are indistinguishable. So +15005550103 — and the voicemail outcome in an inbound custom scenario when it's applied to an outbound call — is recorded as a normal answered call. Your integration won't see a distinct "went to voicemail" status on an outbound call, because production can't produce one.

A true voicemail deposit is an inbound event; see Custom scenarios under Inbound calls.

Inbound calls

An inbound call can't be triggered by dialing a number, so inbound simulations use a dedicated sandbox-only endpoint, POST /v1/test_helpers/calls. It drives call.incoming and call.ringing so you can test screen pop or other inbound workflows. Set from_number to a known contact's number to exercise your caller-ID matching. The endpoint returns 200 with the started call's id and scenario, so a test harness can assert on the response.

Bash
curl -X POST https://api.dialstack.ai/v1/test_helpers/calls \
-H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \
-H "DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT" \
-H "Content-Type: application/json" \
-d '{"user": "user_01...", "from_number": "+15551234567"}'

user is the target user for the call — a user_... id from your sandbox account — and it's currently required: the simulator delivers the call directly to that user rather than running your account's inbound routing (routing simulation isn't supported yet). This endpoint is sandbox-only; a live key receives a 400.

Custom scenarios

Inbound calls have no magic numbers, so the scenario object is how you script an inbound call's outcome (it can also override the outcome of a simulated outbound call). Pass it to POST /v1/test_helpers/calls:

Bash
curl -X POST https://api.dialstack.ai/v1/test_helpers/calls \
-H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \
-H "DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT" \
-H "Content-Type: application/json" \
-d '{
"user": "user_01...",
"from_number": "+15551234567",
"from_name": "Sarah Johnson",
"scenario": { "outcome": "voicemail", "ring_seconds": 15, "voicemail_seconds": 12 }
}'

from_name is the simulated caller-ID name: it rides the call's webhook events as from_name and, once the call completes, is what from_label reports on the call log.

Scenario fields — any field you omit keeps its default:

FieldNotes
outcomeanswered, no-answer, busy, or voicemail (anything else → 400)
ring_secondsRing duration, clamped to 120s
talk_secondsTalk duration, clamped to 120s
voicemail_secondsVoicemail length, clamped to 120s
recordingWhether a recording is produced

from_number must be in E.164 format. The voicemail outcome deposits a real voicemail only on an inbound call landing in one of your users' mailboxes — it drops a voicemail there and fires voicemail.new followed by voicemail.transcription.complete. (On an outbound call, voicemail collapses to a normal answered call — see A note on voicemail.)

Standalone events (fax, queue, and more)

The call lifecycle, recordings, and voicemail play out as part of a simulated call. The remaining webhook events — faxes, call queues, and a few one-off notifications — aren't tied to a call, so sandbox sends them on demand. POST /v1/test_helpers/events sends a single event of the type you name, with a representative sample payload, over the same webhook delivery path, and returns 200 echoing the event it sent.

Bash
curl -X POST https://api.dialstack.ai/v1/test_helpers/events \
-H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \
-H "DialStack-Account: acct_YOUR_SANDBOX_ACCOUNT" \
-H "Content-Type: application/json" \
-d '{"event": "queue.call.answered"}'

You can send any of these:

  • Fax: fax.delivered, fax.failed, fax.received
  • Queue: queue.call.queued, queue.call.dispatched, queue.call.answered, queue.call.abandoned, queue.call.timed_out, queue.call.completed, queue.call.callback_requested, queue.call.callback_attempted, queue.call.callback_failed
  • Device provisioning: device.provisioned, device.config_fetched, device.provisioning_failed — nothing in a simulated call fetches a device configuration, so these are sent on demand. (A real phone provisioned against a sandbox account emits them for real, on the same delivery path.)
  • Billable count changes: user.created, user.deleted, phone_number.activated, phone_number.disconnected — sandbox users and numbers are never billed, so these are sent on demand with illustrative counts rather than being driven by sandbox provisioning.
  • Other notifications (not part of any call, fax, or queue flow): recording.failed, call.mobile_push_wakeup

An unrecognized type returns 400 with the current list of supported types. Like the other test endpoints, this is sandbox-only; a live key receives a 400.

Recordings and transcripts

Answered simulated calls produce a sample recording, and the full recording → transcript → summary → sentiment path runs end to end on sample content:

  • recording.available fires, and the signed download URL from GET /v1/calls/{id}/recording serves real audio bytes.
  • recording.transcription.complete and recording.summary.complete follow. GET /v1/calls/{id}/transcript returns a completed transcript with verbatim text, and the call record's summary field (also on GET /v1/calls/{id}) carries the summary.
  • The transcript and the call record also carry a sentiment object, with a per-speaker local and remote breakdown. The sample pool is mostly neutral — matching how real business calls tend to score — with a positive, a negative, and a polarized case (near-zero score, high magnitude) so you can exercise every rendering path.