Templates
By default a delivery carries our own shape — the event's namespaces at the top level. If your intake expects a different one, an endpoint can carry a body template and custom headers instead of you standing up a translation service.
Reshaping is all a template is for. Branching, filtering and enrichment belong in your own handler.
The default body
With no template of your own, the body is the event's namespaces at the top level, exactly as listed in Events:
json
{
"event": {
"id": "9a2f4c61-8b3d-4e52-a1c7-0f5b6d8e2a34",
"type": "call.completed",
"timestamp": "2026-08-17T09:15:00.000Z"
},
"call": {
"id": "5c9d3a70-1e84-4b2f-9c6a-8d0f1e3b7a25",
"status": "ended",
"direction": "outbound",
"from_e164": "+27821234567",
"to_e164": "+27829876543",
"duration_sec": 42,
"started_at": "2026-08-17T09:14:00.000Z",
"answered_at": "2026-08-17T09:14:10.000Z",
"ended_at": "2026-08-17T09:15:00.000Z"
},
"org": { "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" },
"campaign": { "id": null },
"contact": {
"id": null,
"first_name": null,
"last_name": null,
"email": null,
"phone_e164": null,
"status": null,
"dnc_at": null,
"source": null,
"created_at": null
}
}The body and the template's field names are the same shape, which is the point of the grouping: {{ call.id }} in a template is call.id in the body. The dashboard's editor starts you from a generated template that reproduces the above exactly, so you can delete what you do not want rather than assemble what you do.
An endpoint you already have keeps sending exactly what it sends today. That generated template is the starting value of the create form, not something applied at delivery time — so when we add a field, existing endpoints are unaffected until somebody edits their template. New fields show up in the editor's field list; they do not appear in your payload on their own. Once you add one, an event that happened before the field existed — redelivered, or released from a QA hold — renders it null rather than failing.
Syntax
Templates are Liquid: fields, filters and tags.
liquid
{{ call.id }}
{{ call.duration_sec | json }}
{{ campaign.id | default: "none" }}
{% if outcome.disposition == "interested" %}"hot"{% else %}"cold"{% endif %}Every Liquid tag works — if, unless, case, for, assign, capture and the rest — except include, render, layout and block, which load another template, and a template has none to load.
A field is namespace.leaf. A whole namespace works too: {{ call | json }} sends every field call carries, and {% for field in custom %} walks your contact fields. A namespace sends whatever it holds when the event is delivered, including fields we add later — name the leaves you want if your intake should not see new ones.
An unknown filter, a field the event does not carry and a template that will not parse are refused when you save. Saving also renders the template twice — with every field that can be blank set to null, and with awkward strings in the rest — and refuses a body that is not valid JSON. A branch neither render takes is only checked when an event is delivered, so test a template that branches on a value with the events it branches on.
Always pipe through json
Every value in a JSON body should go through the json filter:
liquid
{ "id": {{ call.id | json }} }json is what makes a quote, a backslash or a non-ASCII character in a contact's name produce valid JSON, and it renders a null field as null rather than leaving the empty string a bare reference would produce.
Note that json emits the surrounding quotes for a string, so write {{ call.id | json }} and not "{{ call.id | json }}".
Nullable fields are marked as such in Events. campaign.id, call.duration_sec, call.answered_at and their neighbours really are null in ordinary operation — a template that assumes otherwise produces a malformed body on the first non-campaign call.
Available fields
Per event type, a template may reference:
- Ours, on every event:
event.id,event.type,event.timestamp,endpoint.id,endpoint.description,delivery.id - That event's own namespaces —
call.*,org.*,campaign.*,outcome.*,contact.*,script.*,agent.*— see Events custom.*, your own imported contact columns — see belowanswers.*, what your agents captured on the call script — see below
endpoint.*, delivery.id, custom.* and answers.* are what the default body leaves out. The first two describe the delivery rather than the event, and are there for a client routing several of our endpoints into one intake URL, or wanting its idempotency key in the body as well as in the webhook-id header.
Referencing a field the event does not carry is an error at save time, not a blank. The editor lists every available field beside the template, grouped by namespace.
Custom fields
Your organisation's contact fields — the list under Contacts → Fields, which imports fill — are reachable under custom, by each field's key. A field keyed policy_no becomes:
liquid
{"policy": {{ custom.policy_no | json }}}The key is shown beside each field on that page and never changes, so a template that names it keeps working when the field is relabelled. Values arrive as strings in the field's stored form: a number as 1250.5, a date as 2026-11-01, yes/no as true or false, a phone number in E.164. A field marked sensitive is still sent — it is hidden from agents, not from your own systems. The editor's Insert field menu lists your fields, and saving refuses a custom.* key that is not one of them — it could only ever send null. Deleting a field later does not edit your template; the next save asks you to remove the reference.
Three rules:
- Every custom field can be blank. No import fills every column on every row, and a contact imported from a different file may not have the key at all. A missing key renders as
null, so keep| jsonbehind every one. - They are opt-in. A new endpoint's generated template does not mention them.
{{ custom }}on its own sends every field the contact has, including a column you add to a later import. Name the keys you want if that column should not start reaching a third party unannounced.
Custom fields are available on both events. On call.completed they are null whenever the contact namespace is — an inbound call has no contact and therefore no fields.
Script answers
What your agents captured on the call script is reachable under answers, keyed on the question's own field key:
liquid
{"renews": {{ answers.renews_on | json }}}Available on call_outcome.submitted only — referencing one on call.completed is refused when you save. Insert field lists the questions this endpoint's calls can answer: those on the published scripts of the campaigns it is filtered to (every script, if it sends every campaign), plus every AI agent's script, since any AI agent can work any campaign. Saving refuses a key outside that list, which could only ever arrive blank — the usual cause is a template copied from an endpoint for a different campaign. A question on a script that has never been published is not offered until it is, because its key can still change.
The three rules for custom fields apply — every one can be blank, they are opt-in, and {{ answers }} on its own sends every answer the call captured. Two more are specific to answers. A key with a dash or a leading digit needs bracket form ({{ answers["policy-no"] }}). And, worth checking your intake against:
- Every answer is a JSON string, including a number question. A question typed as a number arrives as
"250", not250. Answers are stored as text because the question type is an input affordance for the agent rather than a storage type; if your CRM needs a number, coerce it on your side.
A date and time question is the one exception worth building against. It is the only question type whose answer has a guaranteed shape: the agent gets a date-and-time picker, and the value is always YYYY-MM-DDTHH:MM — so Liquid's date filter can reformat it into whatever your system takes.
liquid
{
"call_back_at": {{ answers.callback_at | date: "%Y-%m-%d %H:%M" | json }},
"call_back_day": {{ answers.callback_at | date: "%A %-d %B" | json }}
}Three things to know before you rely on it:
- The value carries no timezone, on purpose. It is a wall clock — "half past two on the 26th" — so any
dateformat built from the calendar and clock parts (%Y %m %d %H %M,%A,%B) gives back exactly what the agent picked. Add the offset your side, where you know which zone the customer meant. - Do not use
| date: "%s". An epoch second needs a timezone and this value has none, so the number would be read as UTC — not as the customer's zone. - Keep the
jsonfilter on it. The question may never have been reached, and a blank answer withoutjsonrenders"call_back_at":— a dead delivery. An unanswered date-and-time question rendersnullwith it.
Pair them with script.id, script.name and script.version from Events — those are listed fields, so they are in the default body, and script.version tells you which published revision of the questionnaire produced a given answer set.
Delivery time
An endpoint may also carry a delivery time expression: a short Liquid template that names the instant this event's delivery should go out. It is rendered against the same fields as the body, when the event fans out. Blank sends immediately.
liquid
{{ outcome.callback_at }}The output must be a full ISO 8601 instant with a zone (2026-10-24T15:00:00Z or 2026-10-24T17:00:00+02:00) or 10-digit epoch seconds. A wall clock without a zone is refused when you save, because it names no instant. A blank output or a time already past sends at once. An output that cannot be read also sends at once, and the log records the reason. Nothing is held longer than 30 days after fanout.
The expression is rendered once, at fanout, and never re-read: editing the endpoint does not move a delivery already held. See Delivery for Cancel, Send now and the log states.
A date-and-time answer (answers.callback_at) is a wall clock with no zone, so append one before it can be a delivery time:
liquid
{{ answers.callback_at | append: ":00+02:00" }}That turns 2026-10-24T15:00 into 2026-10-24T15:00:00+02:00; use the offset the customer meant. outcome.callback_at, on call_outcome.submitted, is already an ISO instant with Z.
Content type
Defaults to application/json. You may set another media type — a template that emits form-encoded or XML is legitimate. It must be a well-formed media type of at most 128 characters.
When the content type is JSON (application/json, or anything ending +json), the rendered body is checked for validity before sending.
Custom headers
Up to 20 headers per endpoint, for the API key or routing token your intake requires.
| Limit | Value |
|---|---|
| Headers per endpoint | 20 |
| Header name | ≤ 128 characters |
| Header value | ≤ 2 048 characters |
| Body template | ≤ 8 000 characters |
| Delivery time | ≤ 512 characters |
| Content type | ≤ 128 characters |
Names must be valid HTTP tokens: letters, digits, and -, ., _. No spaces, colons or line breaks. Values may not contain line breaks or control characters — one would split the request — nor anything beyond Latin-1, such as a smart quote (“ ”) or €, which a header cannot carry.
Header values are stored encrypted.
Reserved headers
These cannot be set or overridden:
- Anything starting
webhook-— not merely the three the specification defines today. Your verification library reads those, so letting them be overwritten would mean shipping a request that fails verification while our own log records a successful send. content-type— comes from the endpoint's own media type.- Anything starting
x-event-— ours, and any we add later. Overwritingx-event-idorx-event-timecould fake an event's identity or make an out-of-order delivery look ordered; overwritingx-event-testcould make a test look like a real event in your CRM. host,content-length,transfer-encoding,connection,expect— request framing. A tenant-set value here is a request-smuggling-shaped bug, not a feature.user-agent— how you allow-list us in your logs.
Attempting to set one is rejected when you save the endpoint, with the reason.
Size
The rendered body still has to fit the 64 KB delivery cap. Rendering is bounded at one second, and at 262 144 characters allocated by loops, captures, filters and output together. For a body of listed fields none of these is reachable, and the 8 000 character template limit is the practical one; a loop is what can reach them.
Script answers change that arithmetic. A single answer can hold 2 000 characters, so a template naming a few dozen long free-text answers can breach the 64 KB cap on a real call while passing every check when you saved it — the save-time probe renders short placeholder values, so it cannot predict your customers' answers. A body that breaches the cap or a limit, or is not valid JSON, is a dead delivery: it is recorded in the log with the reason, and nothing is sent. If you are forwarding long free-text answers, name the keys you need rather than every key you have.
Testing a template
Use the dashboard's test delivery. It renders your template against a real-shaped event and posts it to your endpoint, so you see exactly what your intake will receive.
Remember that a test body is indistinguishable from a real one except for the x-event-test header — including event.type, which renders the endpoint's real event type. If acting on a test would be harmful, check for that header.
Reference: WEBHOOKS.TEMPLATE in src/lib/const/webhook.const.ts and src/lib/utils/webhook/template.util.ts.