Journeys Simulation API
The Journeys Simulation API allows external systems (such as AI agents) to interact with journeys conversationally without sending real WhatsApp messages. This is useful for testing, previewing, and integrating journey logic into external workflows.
This API is under active development and breaking changes are expected over the coming months. Backwards compatibility is not guaranteed until at least August 2026.
Authentication happens as per all other API calls with a Turn.io token specified
in the HTTP Authorization header prefixed with Bearer and followed by a token issued by the Turn.io user interface.
How it works
The simulation endpoint uses a single POST route that handles both creating new simulations and submitting input to existing ones:
- If no simulation exists for the given
simulation_id: a new simulation session is created and the journey starts executing. - If a simulation already exists for the given
simulation_id: the providedinputis submitted to the existing session.
The simulation_id is always required and provided by the caller. It can be any string identifier between 6 and 64 characters, so a UUID works. The endpoint uses the combination of the number your token belongs to, the journey UUID (from the URL) and the simulation_id (from the body) to identify simulation sessions, so another number using the same simulation_id never reaches your session.
Starting a simulation
To start a new simulation, make the following request:
$ curl -X POST "https://whatsapp.turn.io/v1/journeys/<journey-uuid>/simulation" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d '{
"simulation_id": "my-session-001",
"revision": "production",
"contact": {
"name": "Test User",
"language": "eng"
}
}'
> {
"state": "waiting_for_input",
"message": "Welcome! What is your name?",
"context": {
"contact": {
"name": "Test User",
"language": "eng"
}
}
}
Parameters
| Name | Required | Default | Description |
|---|---|---|---|
| simulation_id | yes | A caller-provided string identifier (6 to 64 characters) used to track the simulation session. | |
| revision | no | "production" | Which revision of the journey to simulate. Must be "production" or "staging". |
| contact | no | {} | An object of contact attributes to populate the simulated contact context. Setting a reserved field to null clears it, which is useful for simulating contacts without a whatsapp_id. |
| parent | no | An object exposed to the journey as @parent.*, for simulating a function-like journey invoked from a parent. Reserved variables (chat, contact, event, global, number, organisation) are ignored. | |
| child | no | An object exposed to the journey as @child.*, for simulating a journey resuming after a child journey has finished. Reserved variables are ignored, as for parent. | |
| input | no | The contact's first message. The journey answers it right after its opening, and message holds both. | |
| http_mocks | no | [] | Fixed answers for the HTTP requests the journey's skills make, see Mocking HTTP calls. |
| fail_unmocked_http_calls | no | false | When true, a request that no mock answers fails inside the skill instead of going out for real. |
Response fields
| Name | Description |
|---|---|
| simulation_id | The simulation session identifier. |
| state | The current state of the simulation: "waiting_for_input" (journey expects a reply) or "end" (journey has finished). |
| message | The text output from the journey. Includes message content, button options, and media URLs. |
| card | The name of the card the journey is on after this turn. |
| options | The labels of the buttons or list items the journey offered this turn, for example ["Yes, I agree", "No, thanks"]. The message text lists them too, with instructions for an AI agent driving the simulation. |
| skill_calls | The skills the journey's AI agents called during this turn, in order, each with its name, arguments and output. |
| actions | The actions the journey's AI agents routed to during this turn, for example ["book_appointment"]. Empty when the agent kept the conversation going. |
| http_calls | The HTTP requests the journey's skills made during this turn, mocked or not, each with its method, url, headers, body, json (the body parsed as JSON, when it is), query, params (the values captured by the mock's :name placeholders), status, mocked and failures. |
| contact | The simulated contact's fields, the same as context.contact, including what the journey wrote to them this turn. |
| context | The current variable context of the simulation, including contact fields and any variables set by the journey. Returned on every turn, so contact writes that happen after a reply are observable. AI connections in it come without their vendor token. |
Simulating a contact without a reserved field
Fields the simulator generates for you — such as whatsapp_id, name, and language — can be cleared by passing null explicitly. This is useful for testing journeys against contacts that genuinely lack a field, for example a username-only contact with no WhatsApp number:
{
"simulation_id": "my-session-001",
"contact": {
"whatsapp_id": null
}
}
An empty string ("") is treated as "not supplied" and falls back to the generated value, since it usually reflects an omitted field rather than an intent to clear it. Use null when you mean to clear.
Simulating a journey that expects parent or child variables
Journeys that are normally invoked by another journey can read @parent.* (values passed in by the journey that called them) and @child.* (values returned by a journey they called). Pass parent and child to exercise these paths directly, without having to run the surrounding journey:
$ curl -X POST "https://whatsapp.turn.io/v1/journeys/<journey-uuid>/simulation" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d '{
"simulation_id": "my-session-001",
"parent": {
"customer_name": "Alice",
"order_id": "A-1"
},
"child": {
"status": "approved"
}
}'
> {
"state": "end",
"message": "Order A-1 for Alice was approved.",
"context": {
"contact": {
"name": "Test User"
}
}
}
This makes it practical to test a function-like journey against many combinations of inputs in CI.
Both objects ignore the reserved variables chat, contact, event, global, number, and organisation. These are managed by the simulator itself, so passing them has no effect — set contact fields via the contact parameter instead. Anything else you pass is exposed unchanged.
Note that parent and child are applied when the session is created. Sending them alongside input on a later request has no effect, since that request submits to the existing session.
Testing a journey from outside Turn
Every response says what the journey did on that turn, so an external test suite can assert on more than the reply text: which card the journey is on, which skills its AI agents called with which arguments, which action they routed to, and the HTTP requests their skills made.
Mocking HTTP calls
Skills that call an API through turn.http.request can be given fixed answers, so a test does not depend on the real API and can try what happens when it fails. Each mock takes an optional method, a url pattern (* matches anything, :name matches one path segment and shows up in the call's params) and the response to send back (status defaults to 200, an object body is sent as JSON):
$ curl -X POST "https://whatsapp.turn.io/v1/journeys/<journey-uuid>/simulation" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d '{
"simulation_id": "booking-test-01",
"revision": "staging",
"fail_unmocked_http_calls": true,
"http_mocks": [
{
"method": "GET",
"url": "https://clinic.example.com/api/slots/:day",
"response": { "body": { "slots": ["09:00", "11:30"] } }
}
],
"input": "Can I come in on Monday?"
}'
> {
"state": "waiting_for_input",
"message": "Hi! How can I help you today?\nWe have Monday openings at 09:00 and 11:30. Which one would you like?",
"card": "Agent",
"skill_calls": [
{
"name": "find_slots",
"arguments": { "day": "monday" },
"output": "free slots on monday: 09:00, 11:30"
}
],
"actions": [],
"http_calls": [
{
"method": "GET",
"url": "https://clinic.example.com/api/slots/monday",
"params": { "day": "monday" },
"status": 200,
"mocked": true,
"failures": [],
...
}
],
"context": { ... }
}
Matching mocks answer in list order, one request each, and the last matching mock keeps answering once the list is used up, so two mocks on the same url script a sequence. A request that no mock matches goes out for real and is still listed in http_calls with mocked: false, unless fail_unmocked_http_calls is true. Then it fails inside the skill and the reason is in the call's failures.
http_mocks sent with a later message are added to the ones the simulation already has, so a test can change what the API answers halfway through a conversation.
Submitting input to a simulation
Once a simulation is in the waiting_for_input state, submit user input by making another request with the same simulation_id:
$ curl -X POST "https://whatsapp.turn.io/v1/journeys/<journey-uuid>/simulation" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d '{
"simulation_id": "my-session-001",
"input": "Santiago"
}'
> {
"state": "end",
"message": "Thanks Santiago! Your registration is complete.",
"context": {
"contact": {
"name": "Santiago"
}
}
}
Parameters
| Name | Required | Description |
|---|---|---|
| simulation_id | yes | The same simulation ID used when starting the session. |
| input | yes | The user's response as a string. Must be non-empty. |
| http_mocks | no | More HTTP mocks, added to the ones the simulation already has. |
Multi-turn example
A typical multi-turn simulation involves starting the journey and then submitting input for each step that requires a response.
Step 1: Start the journey
$ curl -X POST "https://whatsapp.turn.io/v1/journeys/d144cb30-f759-4260-82ba-07353f0e389f/simulation" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d '{
"simulation_id": "demo-session-01",
"revision": "production",
"contact": {
"name": "Maria",
"language": "eng"
}
}'
> {
"state": "waiting_for_input",
"message": "Hi Maria! How can we help you today?\n\nExclusively reply with one of the following options:\nBook appointment\nCheck status\nSpeak to agent",
"context": {
"contact": {
"name": "Maria",
"language": "eng"
}
}
}
Step 2: Submit user input
$ curl -X POST "https://whatsapp.turn.io/v1/journeys/d144cb30-f759-4260-82ba-07353f0e389f/simulation" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d '{
"simulation_id": "demo-session-01",
"input": "Book appointment"
}'
> {
"state": "waiting_for_input",
"message": "Great! What date works for you?",
"context": {
"contact": {
"name": "Maria",
"language": "eng"
}
}
}
Step 3: Continue the conversation
$ curl -X POST "https://whatsapp.turn.io/v1/journeys/d144cb30-f759-4260-82ba-07353f0e389f/simulation" \
-H "Authorization: Bearer token" \
-H "Content-Type: application/json" \
-d '{
"simulation_id": "demo-session-01",
"input": "Next Monday"
}'
> {
"state": "end",
"message": "Your appointment is booked for next Monday. See you then!",
"context": {
"contact": {
"name": "Maria",
"language": "eng",
"appointment_date": "Next Monday"
}
}
}
Because context is returned on this turn too, a test can assert directly that the journey wrote appointment_date to the contact, instead of inferring it from the confirmation message.
Error responses
The API returns appropriate HTTP status codes and error messages:
| Status | Error | Description |
|---|---|---|
| 400 | simulation_id is required | The simulation_id field is missing from the request body. |
| 400 | simulation_id must be between 6 and 64 characters | The simulation_id is too short or too long. |
| 400 | Invalid Journey UUID | The journey UUID in the URL is not a valid UUID. |
| 400 | Journey has no published production revision | The journey does not have a published production revision. |
| 400 | Journey has no staging revision | The journey does not have a staging revision (when revision is "staging"). |
| 400 | Journey is disabled | The journey is currently disabled. |
| 400 | Journey has been deleted | The journey has been deleted. |
| 400 | Invalid revision, must be 'production' or 'staging' | An invalid revision value was provided. |
| 400 | Invalid parent, must be an object | The parent field was provided but is not an object. |
| 400 | Invalid child, must be an object | The child field was provided but is not an object. |
| 400 | Invalid input, must be a non-empty string | The input field is empty or not a string. |
| 400 | Invalid http_mocks: ... | A mock in http_mocks is malformed. The message says which part. |
| 404 | Journey not found | No journey exists with the given UUID. |
| 404 | Simulation not found or expired | The simulation session has expired or does not exist. |
Notes
- Simulation sessions are ephemeral and will expire after a period of inactivity. If a session expires, start a new one with a fresh
simulation_id. - No real WhatsApp messages are sent during a simulation. The journey logic is executed in-memory.
- Media content (images, videos, audio, documents) in journey messages is returned as descriptive text with URLs, for example:
"Image available at: https://...". - When the journey includes button or list options, they are appended to the message text, one option per line.
- Contact fields written by the journey are reflected in
contexton the turn they happen, so a write that occurs after a reply can be asserted on directly rather than inferred from the message text. - AI agent blocks with memory enabled will persist conversation context across simulation turns, allowing the AI to reference earlier messages in the session.
- The AI calls a simulation makes are not shown in AI Logs, so test suites that replay many conversations do not fill them with test sessions.