Webhook Triggers
Webhooks allow external systems (n8n, Zapier, Twilio, custom apps) to activate agents via HTTP POST.
Authentication
- Read endpoints (GET): JWT +
x-tenant-idor API Key - Write endpoints (POST/PATCH/DELETE): JWT required
- Execution endpoints: Own validation (HMAC, API Key, or none)
CRUD Endpoints
These endpoints apply to all trigger types (webhook, email, db_event, schedule).
POST /api/triggers
Create a new trigger.
Request Body:
{
"agent_id": "uuid",
"name": "ERP Chat Integration",
"trigger_type": "webhook",
"trigger_config": {
"auth": { "type": "api_key" },
"query_extraction": { "mode": "field", "field": "chatInput" },
"context_mapping": {
"userId": "$.userId",
"userName": "$.userName"
},
"response_adapter": { "format": "raw", "split_messages": true, "max_messages": 10 },
"session_strategy": { "mode": "derive", "fields": ["userId", "companyId"] }
}
}
Response (201):
{
"success": true,
"data": {
"id": "uuid",
"agent_id": "uuid",
"name": "ERP Chat Integration",
"trigger_type": "webhook",
"webhook_secret": "whs_example_abc123...",
"enabled": true
},
"webhook_url": "https://llm.zihin.ai/api/triggers/webhook/uuid"
}
The webhook_secret is only shown at creation time. Store it securely for HMAC validation.
Other CRUD
| Endpoint | Method | Auth | Description |
|---|---|---|---|
GET /api/triggers | GET | Hybrid | List triggers |
GET /api/triggers/:id | GET | Hybrid | Get trigger details |
PATCH /api/triggers/:id | PATCH | JWT | Update trigger |
DELETE /api/triggers/:id | DELETE | JWT | Delete trigger |
POST /api/triggers/:id/enable | POST | JWT | Enable trigger |
POST /api/triggers/:id/disable | POST | JWT | Disable trigger |
GET /api/triggers/:id/executions | GET | Hybrid | Execution history |
GET /api/triggers/:id/executions/:executionId | GET | Hybrid | Enriched execution details |
POST /api/triggers/:id/test | POST | JWT | Dry-run test |
GET /api/triggers/stats | GET | Hybrid | Aggregated statistics |
GET /api/triggers/types | GET | Hybrid | Available trigger types |
Webhook Execution
POST /api/triggers/webhook/:id
Execute a webhook trigger. Uses validation configured in trigger_config.auth.
Auth by type:
auth.type | Header Required | Description |
|---|---|---|
signature | X-Webhook-Signature: sha256=<hmac> | HMAC-SHA256/SHA512 with webhook_secret |
api_key | X-Api-Key: YOUR_API_KEY | Tenant API Key |
none | — | No validation |
Sync Response (default):
{
"success": true,
"trigger_id": "uuid",
"agent_id": "uuid",
"agent_response": "Found 5 active contracts...",
"execution_id": "uuid",
"usage": { "total_tokens": 1250 },
"latency_ms": 15234
}
ERP Format Response (when response_adapter.format = "ebarn"):
[
{ "message": "Found 5 active contracts:", "type": "text" },
{ "message": "1. Contract ABC - $50,000\n2. Contract DEF - $30,000", "type": "text" }
]
Async Execution with Callback
Triggers can execute in async mode: the webhook responds immediately (202 Accepted) and delivers the agent response via HTTP callback.
Configuration
Add execution inside trigger_config:
{
"execution": {
"mode": "async",
"timeout_ms": 120000,
"ack_response": {
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response></Response>",
"content_type": "text/xml"
},
"callback": {
"url": "https://api.twilio.com/2010-04-01/Accounts/{{accountSid}}/Messages.json",
"method": "POST",
"content_type": "application/x-www-form-urlencoded",
"auth": { "type": "basic", "secret_ref": "TWILIO_AUTH" },
"body_template": {
"To": "{{phoneNumber}}",
"From": "whatsapp:+14155238886",
"Body": "{{agent_response}}"
},
"response_transform": {
"strip_markdown": true,
"max_length": 1550
}
}
}
}
Execution Fields
| Field | Type | Default | Description |
|---|---|---|---|
mode | string | sync | sync or async |
timeout_ms | number | 30000 | Agent execution timeout (1s-10min) |
ack_response.body | any | — | Immediate response body |
callback.url | string | — | Callback URL (supports {{placeholders}}) |
callback.method | string | POST | POST, PUT, or PATCH |
callback.auth.type | string | none | basic, bearer, api_key, or none |
callback.auth.secret_ref | string | — | Vault secret name |
callback.body_template | object | — | Template with {{agent_response}}, etc. |
callback.response_transform | object | — | Transform rules (strip_markdown, max_length) |
Auto-derived Transform
When response_transform is omitted, it is derived from response_adapter.format:
| Format | strip_markdown | max_length |
|---|---|---|
ebarn | false | — |
twiml | true | 4096 |
raw | false | — |
Response Splitting
Long agent responses can be automatically split into multiple chunks for channels with message size limits (WhatsApp ~1600 chars, SMS 160 chars, etc.).
Split is domain-agnostic — it works with any trigger type and any callback endpoint. The agent generates its best response; the infrastructure adapts to channel constraints.
Configuration
Add split_config inside execution:
{
"execution": {
"mode": "async",
"callback": { "..." : "..." },
"split_config": {
"enabled": true,
"max_chunk_size": 1500,
"max_chunks": 10,
"chunk_strategy": "paragraph",
"numbering": true,
"inter_chunk_delay_ms": 500
}
}
}
Split Config Fields
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable response splitting |
max_chunk_size | number | 1500 | Maximum characters per chunk (100-50000) |
max_chunks | number | 10 | Maximum number of chunks (1-50) |
chunk_strategy | string | paragraph | Split strategy: paragraph, sentence, or length |
numbering | boolean | true | Add (1/N) header to each chunk |
inter_chunk_delay_ms | number | 500 | Delay between chunk deliveries in ms (0-10000) |
Chunk Strategies
| Strategy | Description | Best For |
|---|---|---|
paragraph | Splits on \n\n boundaries, regroups small paragraphs | General text, reports |
sentence | Splits on sentence endings (.!?), regroups short sentences | Conversational text |
length | Hard split at exact character count | Raw data, code blocks |
Behavior
- If response fits in a single chunk, no splitting occurs
- Each chunk is delivered via the configured
callbacksequentially - If a chunk delivery fails, remaining chunks are not sent (fail-fast)
- Numbering format:
(1/3)\nFirst chunk text...
Split works best with mode: "async". In sync mode, the response is returned directly — split only affects adapters that support arrays (e.g., ERP format). For most channels, use async mode with callback.
Recommended Settings by Channel
| Channel | max_chunk_size | inter_chunk_delay_ms | Notes |
|---|---|---|---|
| WhatsApp (Twilio) | 1500 | 500 | Sandbox truncates at ~1600 |
| SMS | 140 | 300 | 160 char limit with encoding overhead |
| Slack | 4000 | 200 | Block Kit has 3000 char limit per block |
| ERP | 3000 | 0 | Array format, no delay needed |
Message Buffering (Popcorn)
When users send multiple short messages in rapid succession ("popcorn messages"), each message would normally trigger a separate agent execution. Message buffering consolidates these into a single execution.
How It Works
User sends: "Olá" → buffered (timer starts: 5s)
User sends: "preciso de" → buffered (timer resets: 5s)
User sends: "ajuda" → buffered (timer resets: 5s)
... 5 seconds pass ...
Agent receives: "Olá\npreciso de\najuda" → single execution
The buffer uses a sliding window: each new message resets the timer. After the window expires without new messages, all accumulated messages are concatenated and sent as a single agent execution.
Configuration
Add message_buffer inside execution:
{
"execution": {
"mode": "async",
"callback": { "..." : "..." },
"message_buffer": {
"enabled": true,
"window_ms": 5000,
"max_messages": 10,
"concat_separator": "\n"
}
}
}
Message Buffer Fields
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable message buffering |
window_ms | number | 5000 | Debounce window in milliseconds (100-60000) |
max_messages | number | 10 | Maximum messages in buffer before forced execution (1-100) |
concat_separator | string | \n | Separator used when joining buffered messages |
Behavior
- Requires Redis — if Redis is unavailable, messages execute immediately (fail-safe)
- Buffer is per-session (uses the same session key from
session_strategy) - During the buffer window, the webhook responds with 202 Accepted (no agent execution)
- After the window expires, the concatenated message is processed and the response is delivered via callback
- Compatible with
split_config— buffered input can produce split output - The
_buffered_messagescount is added towebhookContextfor telemetry
Split and buffer solve complementary problems. Use both together for channels like WhatsApp:
{
"execution": {
"mode": "async",
"message_buffer": { "enabled": true, "window_ms": 5000 },
"split_config": { "enabled": true, "max_chunk_size": 1500 },
"callback": { "..." : "..." }
}
}
The flow becomes: multiple user messages → buffer → single agent execution → split response → multiple callback deliveries.
Webhook Config Reference
| Field | Options | Description |
|---|---|---|
auth.type | signature, api_key, none | Validation method |
query_extraction.mode | field, jmespath, template, full_body | How to extract the query |
context_mapping | object | JSONPath mapping ($.field) from payload to agent context |
response_adapter.format | ebarn, slack, teams, twiml, raw | Response format |
session_strategy.mode | derive, ephemeral | derive = deterministic session, ephemeral = new each call |
timeout_ms | number | Timeout in ms (1000-300000, default: 30000) |
Context Mapping
Maps fields from the webhook payload into the agent's execution context via JSONPath expressions. These fields are available to the LLM in the system prompt and to tools at runtime.
Naming Conventions
| Pattern | Visibility | Use for |
|---|---|---|
fieldName | Visible in LLM prompt + tools | Data the LLM needs (IDs, names, locations) |
_fieldName | Tools only (hidden from prompt) | Internal metadata (message SIDs, internal IDs) |
Auto-excluded Fields
These fields are always excluded from the LLM prompt regardless of name — they remain accessible to tools via ${context.field}:
| Field | Reason |
|---|---|
token | API credential — used by tools for authentication |
host | API base URL — used by tools for requests |
userName | Already rendered in the session context section |
Examples
ERP Integration:
{
"context_mapping": {
"userId": "$.user.id",
"companyId": "$.company.id",
"companyName": "$.company.name",
"city": "$.company.address.city",
"state": "$.company.address.state",
"userName": "$.userName",
"token": "$.token",
"host": "$.host"
}
}
The LLM sees: userId, companyId, companyName, city, state. Excluded: userName (session section), token and host (credentials).
WhatsApp via Twilio:
{
"context_mapping": {
"_waId": "$.WaId",
"_messageSid": "$.MessageSid",
"phoneNumber": "$.From",
"profileName": "$.ProfileName"
}
}
The LLM sees: phoneNumber, profileName. Excluded: _waId and _messageSid (prefixed with _).
Safety Net: defaults_from_context
MCP Servers support defaults_from_context in their config to inject context values authoritatively into tool parameters — even if the LLM hallucinates a different value:
{
"config": {
"defaults_from_context": {
"body[].requesterId": "requesterId"
}
}
}
This maps webhookContext.requesterId → body[].requesterId in every tool call, overriding whatever the LLM sends.
Sender Access Control
Control who can trigger a webhook before agent execution — zero LLM cost for unauthorized senders.
{
"sender_access": {
"mode": "members",
"identity_field": "From",
"match_column": "phone",
"resolve_user": true
}
}
| Mode | Description |
|---|---|
any | Any sender (default) |
members | Only active tenant members (lookup in tenant_users) |
whitelist | Only listed identifiers |
Phone numbers are normalized before comparison: whatsapp:+5511999990000 becomes +5511999990000.
WhatsApp via Twilio
Key points for WhatsApp integration:
- Twilio sends
application/x-www-form-urlencodedpayloads (accepted automatically) - Message field:
Body(notchatInput) - Use
execution.mode: "async"to avoid Twilio's ~15s timeout - Format
twiml: auto strips markdown + 4096 char limit - WhatsApp Sandbox truncates messages > ~1600 chars — use
max_length: 1550 - Markdown tables are converted to bullet-lists automatically
Execution Inspection
GET /api/triggers/:id/executions/:executionId
Returns enriched execution details with correlated session, LLM calls, and aggregated metrics.
Response:
{
"success": true,
"execution": {
"id": "uuid",
"trigger_id": "uuid",
"status": "success",
"started_at": "2026-02-18T14:30:00Z",
"finished_at": "2026-02-18T14:30:12Z",
"execution_time_ms": 12340,
"normalized_query": "List active contracts",
"agent_response": "Found 5 active contracts...",
"response_channel": "webhook_sync",
"tokens_used": 1250,
"session_id": "a1b2c3d4-...",
"webhook_context": {
"userId": "123",
"companyId": "456",
"company": "Acme Corp"
}
},
"session": {
"session_id": "a1b2c3d4-...",
"agent_id": "uuid",
"agent_name": "Sales Assistant",
"status": "active",
"created_at": "2026-02-18T14:00:00Z",
"updated_at": "2026-02-18T14:30:12Z",
"message_count": 8
},
"llm_calls": [
{
"model": "openai.gpt-4.1",
"provider": "openai",
"input_tokens": 850,
"output_tokens": 400,
"cached_input_tokens": 200,
"cost_usd": 0.0125,
"response_time_ms": 3200,
"tools_executed": ["search_contracts"],
"finish_reason": "stop",
"timestamp": "2026-02-18T14:30:02Z"
}
],
"tool_calls": [
{
"id": "uuid",
"tool_name": "search_contracts",
"tool_call_id": "call_abc123",
"tool_input": { "status": "active", "limit": 10 },
"tool_output": "{\"rows\": [...], \"count\": 5}",
"output_preview": "5 rows returned",
"success": true,
"duration_ms": 450,
"error_message": null,
"iteration": 1,
"call_index": 0,
"execution_id": "uuid",
"created_at": "2026-02-18T14:30:01Z"
}
],
"metrics": {
"total_calls": 2,
"total_tokens": 1250,
"total_cost_usd": 0.018,
"avg_latency_ms": 2800,
"models_used": ["openai.gpt-4.1"],
"providers_used": ["openai"]
}
}
The tool_calls array contains granular details for each tool invocation. Older executions without tool call logs return an empty array.
Session → Trigger Navigation
GET /api/v1/sessions/:sessionId/trigger-context
Reverse navigation: from a session, discover which trigger and execution originated it. Returns has_trigger: false if the session was not created by a trigger.
Response (session from trigger):
{
"success": true,
"has_trigger": true,
"trigger": {
"id": "uuid",
"name": "ERP Chat Integration",
"trigger_type": "webhook",
"agent_id": "uuid",
"agent_name": "Sales Assistant",
"enabled": true
},
"execution": {
"id": "uuid",
"status": "success",
"started_at": "2026-02-18T14:30:00Z",
"finished_at": "2026-02-18T14:30:12Z",
"execution_time_ms": 12340
},
"navigation": {
"execution_detail": "/api/triggers/uuid/executions/uuid"
}
}
Error Codes
| Code | HTTP | Description |
|---|---|---|
invalid_input | 400 | Missing or invalid field |
trigger_not_found | 404 | Trigger not found |
trigger_disabled | 400 | Trigger is disabled |
invalid_signature | 401 | HMAC/Svix validation failed |
webhook_sender_denied | 403 | Webhook sender not authorized (sender_access) |
execution_failed | 500 | Agent execution error |