Voice agent simulation call ID mapping sounds like a one-column join. It rarely stays that simple.
A simulator creates a test-run ID. The voice platform creates a call ID. The telephony provider may create separate call-leg and conversation IDs. Your tracing stack creates a trace ID, while your application already has an internal call record. If those identifiers are joined after the call ends, the first transfer or retried webhook can attach evidence to the wrong record.
Voice agent simulation call ID mapping is the process of assigning one internal interaction ID before a test starts, then storing simulator, provider, call-leg, conversation, trace, recording, and evaluation IDs as aliases beneath it.
This template is unnecessary if you run a handful of manual tests and inspect each result immediately. It is for teams running automated simulations, ingesting provider callbacks, replaying failed calls, or joining evaluations to internal QA and CI records.
TL;DR: Create the internal ID first. Pass it to the simulator or provider when possible. Store every external ID as an append-only alias, deduplicate callbacks by provider plus event ID, and model transfers as separate legs under one root interaction. Never use phone number plus timestamp as an automatic primary join.
Methodology: This template proposes a correlation and recovery contract using the public Vonage, Telnyx, Twilio, and W3C documentation cited below, checked on September 25, 2026. The record, pseudocode, and CI scenarios are illustrative engineering recommendations.
Last Updated: September 2026
Related Guides:
- IVR and Voice Agent Log Correlation - join production call chains across IVR, telephony, transcripts, and outcomes
- Voice Agent Call Evidence Export - package transcripts, audio, traces, and QA results for review
- OpenTelemetry for Voice Agents - propagate trace context across ASR, LLM, tools, and TTS
- Voice Agent Tests as Code - keep simulation fixtures and assertions in Git
- Voice Agent Workflow Testing - validate tool calls, state transitions, and side effects
- Voice Agent Sandbox Testing - prove mutating tools without touching production data
- WebSocket Voice Agent Testing - test realtime transports before the phone path
- Persistent Caller ID Testing - test inbound identity and repeat-caller behavior
What Is Voice Agent Simulation Call ID Mapping?
The mapping is a durable identity record for one simulated user interaction. It connects the test definition, the simulator run, every provider call leg, lifecycle callbacks, traces, recordings, transcripts, tool evidence, and final evaluation.
The common mistake is what we call the provider-key trap: choosing the first external call ID you see and treating it as the primary key for the entire interaction. That works on a happy-path call. It breaks when a transfer creates another leg, a provider retries a callback, or the simulator and monitoring system ingest the same call at different times.
Include multi-leg calls and duplicate callbacks in the first mapping tests. Those cases can expose transcripts, recordings, and evaluations that disagree about which call they describe.
Official provider contracts show why one key is not enough. Vonage Voice webhooks expose a call uuid, a conversation_uuid, custom data in some initiation paths, and lifecycle events. Telnyx Voice webhooks distinguish call_leg_id, call_session_id, webhook event id, and delivery attempt. Twilio's Call resource gives each call a CallSid and reports lifecycle states such as queued, ringing, in progress, completed, busy, no-answer, and failed.
Keep those IDs. Just do not promote any one of them to the root key for your internal record.
Which ID Should Be Canonical?
Create the root interactionId before the first simulation request leaves your system. Give each provider leg its own callLegId. Everything else is an alias or evidence pointer.
| Identifier | Owner | Scope | Use It For | Do Not Use It For |
|---|---|---|---|---|
interactionId | Your application | Whole simulated interaction | Canonical join across tests, legs, evidence, and evaluation | Provider API calls |
callLegId | Your application | One inbound, outbound, or transfer leg | Leg ordering and per-leg state | Whole transfer chain |
simulationRunId | Simulator or test runner | One scenario execution | Link to fixture, prompt version, and expected result | Provider webhook deduplication |
providerCallId | Voice or telephony provider | Usually one provider call or leg | Fetch provider events, recordings, and status | Cross-provider identity |
providerConversationId | Provider | Related legs or conversation session | Group provider-native legs when documented | Universal interaction key |
traceId | Tracing system | One distributed trace | Join service spans and latency evidence | Durable business identity |
webhookEventId | Provider | One callback event | Idempotency and audit | Call identity |
evaluationId | QA system | One scoring result | Versioned evaluation evidence | Runtime event correlation |
Canonical-ID rule: one internal interaction ID owns the simulated call story. Provider IDs, trace IDs, and evaluation IDs remain typed aliases with narrower scopes.
This separation matters for privacy too. The W3C Trace Context specification standardizes trace propagation and warns against putting personally identifiable or sensitive information in trace context. Use opaque identifiers. Do not encode a phone number, customer email, account number, or patient identifier into interactionId or traceparent.
What Should the Correlation Record Contain?
Start with one compact record. Keep large evidence objects elsewhere and store pointers here.
{ "schemaVersion": "voice-call-correlation.v1", "interactionId": "int_01J4Q9Y8Q3H6N5X2T7M1P0R4SC", "environment": "staging", "createdAt": "2026-08-06T15:04:12.431Z", "simulation": { "runId": "sim_28417", "scenarioId": "refund-transfer-07", "fixtureVersion": "git:7e3a91c", "agentVersion": "billing-agent-v42" }, "legs": [ { "callLegId": "leg_01", "sequence": 1, "direction": "outbound", "provider": "provider-a", "providerCallId": "call_a91f", "providerConversationId": "conversation_77", "startedAt": "2026-08-06T15:04:14.102Z", "endedAt": "2026-08-06T15:04:41.887Z", "terminalState": "transferred", "evidence": { "traceIds": ["4bf92f3577b34da6a3ce929d0e0e4736"], "recordingIds": ["rec_51aa"], "transcriptIds": ["tr_440"], "evaluationIds": ["eval_9031"] } }, { "callLegId": "leg_02", "sequence": 2, "parentCallLegId": "leg_01", "direction": "outbound", "provider": "provider-a", "providerCallId": "call_b05c", "providerConversationId": "conversation_77", "startedAt": "2026-08-06T15:04:39.621Z", "endedAt": "2026-08-06T15:05:01.204Z", "terminalState": "completed", "evidence": { "traceIds": ["4bf92f3577b34da6a3ce929d0e0e4736"], "recordingIds": ["rec_51ab"], "transcriptIds": ["tr_441"], "evaluationIds": ["eval_9032"] } } ], "correlation": { "method": "metadata_echo", "confidence": "deterministic", "verifiedAt": "2026-08-06T15:05:03.118Z" }}
The distinction between interactionId and callLegId is the part that matters most. A transfer should add a leg, not overwrite the first provider call ID. The root interaction remains stable while the leg sequence tells you what happened. Evidence is scoped to each leg; a shared trace can appear under both. If a recording or evaluation spans several legs, store the contributing leg IDs explicitly rather than guessing from timestamps. The overlapping leg times here illustrate a warm transfer.
Required invariants
| Invariant | Pass Condition | Failure Signal | Action |
|---|---|---|---|
| Root created first | interactionId exists before the provider request | First callback creates an orphan record | Reject or quarantine the callback |
| Alias uniqueness | (provider, providerCallId) maps to one leg | Same alias points to two interactions | Block automatic merge |
| Append-only aliases | New leg adds an alias without replacing history | Transfer erases the original call ID | Restore leg history from events |
| Event idempotency | One durable decision and pending-work record per scoped event key | Retry creates duplicate state changes or jobs | Atomically commit the receipt, decision, and outbox; recover pending work |
| Terminal monotonicity | A leg does not move from terminal back to active | Late callback reopens a completed leg | Store as late evidence, do not mutate state |
| Evidence provenance | Transcript, recording, trace, and score identify their source leg | Evidence has only a timestamp | Require review before attaching |
| Opaque identifiers | IDs contain no customer data | Phone, email, or account number appears in an ID | Regenerate and redact |
How Should Webhooks Attach to the Right Simulated Call?
Use the strongest available correlation method in a fixed order.
| Priority | Method | Confidence | Use When |
|---|---|---|---|
| 1 | Echoed interactionId in supported metadata | Deterministic | Provider or simulator returns your opaque metadata in callbacks |
| 2 | Pre-persisted provider call ID | Deterministic | The create-call response returns a provider ID before webhooks arrive |
| 3 | Signed token containing an opaque interaction reference | Deterministic after verification | Integration supports a state token but not arbitrary metadata |
| 4 | Provider conversation/session ID plus known leg map | Deterministic within documented provider scope | Transfers share a documented session identifier |
| 5 | Phone endpoints plus a narrow time window | Probabilistic | Legacy integration exposes no stable metadata or early call ID |
Time-window matching belongs last. Two automated tests can dial the same number within the same second. If your fallback query returns two candidates, do not pick the nearest one and move on. Mark the callback needs_review and keep it out of automated scoring.
Use a provider adapter so provider-specific fields stop leaking into the core model. Preserve provider call, conversation, event, delivery-attempt, and occurrence-time fields separately. webhookEventId below means a stable event identity, not a new ID generated for every delivery. If a provider has no native event ID, derive an event key from its documented identity fields and test that retries preserve it. Do not use call ID alone or include the delivery attempt in that key.
The following is illustrative transactional pseudocode, not a runnable SDK example. Authenticate and validate the callback first. Derive tenant and provider-account scope from trusted receiver configuration, then apply that scope to every uniqueness constraint and alias lookup.
eventKey = (trustedScope, provider, webhookEventId)BEGIN TRANSACTION receipt = INSERT immutable_event(eventKey, verifiedEvent) ON UNIQUE CONFLICT DO NOTHING if receipt was not inserted: decision = READ committed_decision(eventKey) COMMIT return DUPLICATE with decision match = resolve_one_known_leg(trustedScope, verifiedEvent) # Check echoed root ID and provider aliases against existing records. # A conversation ID alone is insufficient when it identifies several legs. # Missing or conflicting candidates must not select an arbitrary winner. if match is missing or ambiguous: decision = QUARANTINED with reason SAVE decision for receipt else: decision = ATTACHED to match.interactionId and match.callLegId SAVE decision for receipt INSERT outbox(eventKey, "process_correlated_event", decision, PENDING) ON UNIQUE CONFLICT DO NOTHINGCOMMITreturn decision
The insert is the atomic claim: concurrent deliveries contend on the same unique key instead of checking and claiming separately. A conflicting insert must wait for the winning transaction or retry on a serialization conflict before reading its committed decision. A crash before commit rolls back the receipt, decision, and outbox together, so a provider retry can claim the event again. Return success only after commit; a transaction failure must not produce a success acknowledgement.
After commit, a worker scans pending outbox records and recovers expired work leases. A crash before publication cannot lose the job, even when the next webhook is returned as DUPLICATE. That result means the event is durably accepted, not that its business work has finished. An outbox can deliver more than once after a worker crash, so evaluation, CRM, and cleanup consumers also need stable operation keys and idempotent writes or destination-supported idempotency. The receipt alone does not guarantee exactly-once external effects.
Quarantine is a durable decision too. Retries reuse the saved receipt rather than adding another quarantine entry. Reconciliation scans quarantined receipts; once a verified mapping exists, it atomically changes QUARANTINED to ATTACHED and inserts the same uniquely keyed outbox work. Keep that transition guarded and preserve the original receipt. It must not depend on the provider sending the event again.
Webhook correlation rule: authenticate the callback, then atomically commit its unique receipt, correlation decision, and any pending work before acknowledging it. Reject unauthenticated requests; retain authenticated but ambiguous events for reconciliation without mutating a call record.
For mutating tool workflows, apply the same rule in your voice agent sandbox tests: the call correlation key and the tool idempotency key should be related in the audit record, but they should not be the same field.
How Do Transfers, Retries, and Duplicate Callbacks Change the Model?
One simulated interaction can contain more than one call leg and more than one delivery of the same event.
Transfer walkthrough
| Sequence | Event | Root Interaction | Call Leg | Provider Alias | Decision |
|---|---|---|---|---|---|
| 1 | Simulation requested | int_01J4... | none | none | Persist root before dialing |
| 2 | Provider accepts call | int_01J4... | leg_01 | call_a91f | Add first alias |
| 3 | Agent starts transfer | int_01J4... | leg_01 | conversation_77 | Keep first leg open until terminal evidence |
| 4 | Transfer leg starts | int_01J4... | leg_02 | call_b05c | Add child leg under same root |
| 5 | Completion callback arrives | int_01J4... | leg_02 | event evt_900 | Mark second leg complete |
| 6 | Same callback retries | int_01J4... | leg_02 | event evt_900, attempt 2 | Record delivery attempt, do not replay work |
Telnyx's webhook contract is a useful concrete reference: it exposes a unique webhook event ID, a call-leg ID, a shared call-session ID for related legs, and a delivery attempt number. Other providers use different names. The model stays the same.
Late and out-of-order events
Webhooks do not always arrive in lifecycle order. Store occurredAt from the provider separately from receivedAt in your system. Apply state transitions using the event's meaning and monotonic rules, not arrival order alone.
If a call.completed event arrives before a delayed call.answered event, keep both as evidence. Do not reopen the leg. This is where OpenTelemetry traces help: traces explain service timing, while the correlation record preserves business identity and provider event order.
What Should CI Prove?
Put the mapping contract next to your voice agent tests as code. The test is not complete when the agent says the right words. It is complete when every artifact resolves to the intended interaction and no duplicate delivery replays a side effect.
| Test Case | Setup | Assertion | Blocking Failure |
|---|---|---|---|
| Metadata echo | Start one simulation with a known interactionId | Callback resolves without timestamp matching | Missing or changed ID |
| Concurrent duplicate webhook | Deliver the same event ID 3 times concurrently | One receipt, one decision, and one outbox record | Duplicate score, CRM write, or cleanup |
| Crash before commit | Stop the receiver after inserting the receipt but before commit, then retry | Transaction rolls back and retry creates the complete receipt, decision, and outbox | Orphan claim suppresses all future work |
| Crash after commit | Stop before publishing the outbox, then resend the webhook | Duplicate response and eventual processing of the existing pending outbox | Accepted event is never processed |
| Worker retry | Stop after a destination write but before marking outbox work complete | Same operation key is retried without repeating the business effect | Duplicate external write |
| Quarantine recovery | Retry an unmatched event, then add a verified mapping | One quarantine receipt transitions once and schedules recoverable work | Repeated quarantine entries or lost event |
| Out-of-order lifecycle | Send completed before answered | Leg remains terminal and both events remain auditable | Terminal state reopens |
| Transfer chain | Create 2 provider legs under one session | Two legs share one root and retain separate aliases | First alias overwritten |
| Concurrent same-number tests | Start 7 calls to the same endpoint | Every callback maps by stable ID | Time window selects the wrong run |
| Missing metadata | Remove echoed internal ID | Event is quarantined or matched by pre-persisted provider ID | Silent probabilistic merge |
| Trace sampling | Omit a trace from one leg | Business correlation remains intact | Missing trace creates a new interaction |
| Replay | Re-ingest a completed call packet | Existing interaction is reused idempotently | Duplicate interaction created |
The 7 concurrent-call case is intentionally small. It is enough to expose a join that depends on phone number and timestamp without turning a correctness test into a load test.
When a failed production or simulation call becomes a regression test, use the call evidence export runbook to carry the same interaction, leg, transcript, recording, trace, and evaluation references into the fixture. That makes replay evidence comparable instead of merely similar.
Where Does This Template Stop?
This template does not define your storage engine, retention policy, or full event taxonomy. The call logging taxonomy guide owns those decisions. It also does not prove caller identity; use the caller identity testing checklist before trusting account or customer fields.
Three limitations matter:
Some providers cannot echo your metadata. Persist the provider call ID as early as the API allows. If no stable identifier exists until a callback, quarantine ambiguous matches.
Transfers do not share one universal identifier. A provider session or conversation ID is only as broad as that provider documents. Keep your internal root interaction above it.
Correlation does not prove correctness. A perfectly mapped call can still have a wrong transcript, tool result, or evaluation. Pair identity checks with workflow assertions and content-specific QA.
An internal call ID and provider call ID are a useful starting pair. The durable version adds leg scope, event identity, evidence provenance, and an explicit confidence level so ambiguity cannot masquerade as certainty.
Implementation Checklist
- Generate an opaque
interactionIdbefore the simulation starts. - Persist the simulation run, scenario, fixture, agent version, and environment.
- Pass the internal ID through supported metadata or a signed opaque token.
- Store provider call, leg, conversation, trace, recording, transcript, and evaluation IDs as typed aliases.
- Enforce uniqueness on provider plus provider call ID.
- Atomically store the scoped event receipt, correlation decision, and pending outbox work.
- Recover pending outbox work and quarantined receipts without requiring a provider resend.
- Give downstream operations stable idempotency keys so worker retries do not repeat business effects.
- Store provider event time separately from local receive time.
- Model transfers as child legs under one root interaction.
- Keep terminal state monotonic when late callbacks arrive.
- Quarantine ambiguous timestamp matches instead of guessing.
- Keep PII and sensitive data out of identifiers and trace context.
- Test concurrent duplicates, crashes before and after commit, worker retries, quarantine recovery, out-of-order events, transfers, concurrent calls, missing metadata, and replay in CI.
The useful outcome is not a prettier ID table. It is a failed simulation that opens to one complete story: fixture, prompt version, provider legs, transcript, recording, trace, tool evidence, and evaluation.

