Overview
API_CALL is the bridge between a conversation flow and your own backend. When the engine enters a state that contains an API_CALL action, it pauses the conversation, fires an outbound HTTP request, saves the response, then routes to the next state based on whether the request succeeded or failed.
{
"fetch_order": {
"message": "Looking that up for you...",
"actions": [
{
"type": "API_CALL",
"integration": "my_backend",
"path": "/orders/{{variables.order_id}}",
"method": "GET",
"save_to": "order",
"success_transition": "show_status",
"failure_transition": "order_error"
}
]
}
}Configuration
API_CALL fields
typestringrequiredMust be "API_CALL".
integrationstringoptionalName of a saved integration whose base URL and auth credentials are used. Recommended — keeps secrets out of your flow JSON. Mutually exclusive with url.
pathstringoptionalPath appended to the integration's base URL. Supports {{variables.*}} substitution. Required when integration is set.
urlstringoptionalFull URL (legacy mode — no saved integration). Supports {{variables.*}} substitution.
methodstringoptionalHTTP method: GET, POST, PUT, PATCH, or DELETE. Defaults to "GET".
headersobjectoptionalAdditional request headers merged on top of the integration's headers. Values support {{variables.*}} substitution.
bodyobjectoptionalExtra fields merged into the auto-injected request body. Only applies to POST, PUT, and PATCH. Values support {{variables.*}} substitution.
save_tostringoptionalVariable name to store the full response under. For example "order" makes the response accessible as {{variables.order.*}}. When omitted the response is stored at {{variables.response}}.
fire_and_forgetbooleanoptionalWhen true, the request fires but the flow does not wait for a response. No data is saved and the conversation transitions immediately. Defaults to false.
success_transitionstringoptionalState to route to when the server returns HTTP 2xx.
failure_transitionstringoptionalState to route to when the server returns HTTP 4xx/5xx or the request times out.
Variable substitution
Any path, url, headers, or body value can embed flow variables using {{variables.name}}. The engine resolves them at call time.
{
"type": "API_CALL",
"integration": "my_backend",
"path": "/users/{{variables.user_id}}/orders/{{variables.order_id}}",
"method": "PATCH",
"headers": { "X-Tenant": "{{variables.tenant_id}}" },
"body": { "status": "{{variables.new_status}}" }
}HTTP method examples
GET — fetch data
Use GET to read data the flow needs before it can continue. The response is saved to a variable whose fields are available in every subsequent message and condition.
Scenario: User asks for their order status. order_id was collected in an earlier state.
{
"type": "API_CALL",
"integration": "my_backend",
"path": "/orders/{{variables.order_id}}",
"method": "GET",
"save_to": "order",
"success_transition": "show_status",
"failure_transition": "order_not_found"
}If the server returns { "status": "in_transit", "eta": "2026-06-01", "carrier": "DHL" }:
{{variables.order.status}}—"in_transit"{{variables.order.eta}}—"2026-06-01"{{variables.order.carrier}}—"DHL"
POST — send data
Use POST to create a resource or trigger an action. The auto-injected body already contains the user's last message and all current variables — add body fields for anything extra.
Scenario: Submit a support ticket after collecting issue_type and description.
{
"type": "API_CALL",
"integration": "my_backend",
"path": "/tickets",
"method": "POST",
"body": {
"issue_type": "{{variables.issue_type}}",
"description": "{{variables.description}}",
"priority": "normal"
},
"save_to": "ticket",
"success_transition": "ticket_created",
"failure_transition": "ticket_error"
}The confirmation state can then reference {{variables.ticket.id}} to show the user their reference number.
PATCH — update a record
Use PATCH for partial updates to an existing resource.
{
"type": "API_CALL",
"integration": "my_backend",
"path": "/users/{{variables.user_id}}/preferences",
"method": "PATCH",
"body": { "notifications": "{{variables.notification_choice}}" },
"success_transition": "preference_saved",
"failure_transition": "preference_error"
}DELETE — remove a resource
DELETE sends no body. Any context your server needs should come from the URL path or the auto-injected X-Sarufi-* headers.
{
"type": "API_CALL",
"integration": "my_backend",
"path": "/subscriptions/{{variables.subscription_id}}",
"method": "DELETE",
"success_transition": "cancellation_confirmed",
"failure_transition": "cancellation_error"
}Fire and forget
Set fire_and_forget: true when you want to notify your backend without blocking the conversation — analytics events, webhook pings, non-critical logging.
{
"type": "API_CALL",
"integration": "analytics",
"path": "/events",
"method": "POST",
"body": { "event": "flow_completed", "flow": "onboarding" },
"fire_and_forget": true
}No failure handling with fire_and_forget
The response is ignored entirely — including errors. Do not use it for operations where failure would affect the user experience.
Response access
The full response body is always available at {{variables.response}}. Use save_to to give it a meaningful name.
{ "save_to": "profile" }Access nested fields with dot notation:
| Expression | Resolves to |
|---|---|
{{variables.profile.name}} | Top-level name field |
{{variables.profile.address.city}} | Nested city inside address |
{{variables.profile.orders[0].id}} | First item of an array |
Always name your responses
{{variables.order.status}} is much clearer than {{variables.response.status}} in multi-step flows that make several API calls.
Success and failure routing
success_transition fires on any 2xx response. failure_transition fires on 4xx, 5xx, or a network timeout. Both are optional — omitting them falls back to the state's default transition.
Design failure states to be actionable: explain what went wrong and offer a path forward (retry, contact support, or exit the flow gracefully).
Auto-injected request headers
Every API_CALL automatically includes these identification headers so your server knows who made the call without parsing the body.
| Header | Always present | Value |
|---|---|---|
X-Sarufi-User-Id | yes | User identifier (phone number on WhatsApp, device-scoped ID on web) |
X-Sarufi-Chatbot-Id | yes | Chatbot ULID |
X-Sarufi-Channel | when known | whatsapp, web, ussd, sms, api |
X-Sarufi-State | when in a state | Current state name |
X-Sarufi-Flow-Name | when available | Flow name |
X-Sarufi-User-Name | when set | Value of {{variables.contact_name}} |
Auto-injected request body
For POST, PUT, and PATCH the engine always sends a base JSON body. Any body fields you declare are merged on top and take precedence.
{
"message": {
"type": "text",
"text": "<the user's latest message>"
},
"variables": {
"<all current conversation variables>"
}
}GET and DELETE send no body.
Backend examples
from fastapi import Request, FastAPI
app = FastAPI()
@app.get("/orders/{order_id}")
async def get_order(order_id: str, request: Request):
user_id = request.headers.get("X-Sarufi-User-Id")
chatbot_id = request.headers.get("X-Sarufi-Chatbot-Id")
channel = request.headers.get("X-Sarufi-Channel")
order = db.get_order(order_id, user_id=user_id)
return {"status": order.status, "eta": order.eta, "carrier": order.carrier}
@app.post("/tickets")
async def create_ticket(request: Request):
body = await request.json()
user_id = request.headers.get("X-Sarufi-User-Id")
ticket = db.create_ticket(
user_id=user_id,
issue_type=body["variables"].get("issue_type"),
description=body["variables"].get("description"),
)
return {"id": ticket.id, "reference": ticket.reference}Integration mode vs legacy mode
| Integration mode | Legacy mode | |
|---|---|---|
| Config | integration + path | url only |
| Auth credentials | Stored encrypted in the platform | Hardcoded in the flow JSON |
| Credential rotation | Update the integration once — all flows update automatically | Requires editing every flow |
| Recommended | Yes | Only when a saved integration is not possible |
Set up integrations in the Sarufi dashboard under Settings → Integrations, or via the Integrations API.