Customising Webhook Payloads
By default, every webhook delivery is the standard signed CloudEvent envelope. That’s perfect for your own services, but tools like Slack and Microsoft Teams expect a specific JSON shape, and some endpoints need a particular header (an Authorization token, a routing key, a content type).
Payload templating lets you customise the JSON body and the headers DataRecs sends — without writing any code. You author a template: a JSON document (and a set of header values) with {{ ... }} placeholders that get filled in from each event. There is no logic, no loops, and no conditionals — only variable substitution. This keeps templates safe and predictable, and means a template can never reshape itself based on event content.
How it works
Section titled “How it works”- You attach a body template (a JSON string) and optional header templates to a webhook endpoint.
- When an event is delivered, DataRecs fills the placeholders from that event and sends the rendered result.
- The delivery is signed over the rendered body (the exact bytes you receive) using the same Standard Webhooks scheme — see Signature verification.
A template has three parts:
| Part | What it is |
|---|---|
| Body | A JSON document with {{ ... }} placeholders. Must be valid JSON. |
| Headers | A map of header name → value template (each value may contain placeholders). |
| Content-Type | The Content-Type header sent with the rendered body (defaults to application/json). |
Variables
Section titled “Variables”Placeholders reference values from the event being delivered, or a secret you configured on the endpoint. There are three namespaces:
-
event.*— the CloudEvent envelope. Always available, the same for every event type:Variable Example value event.ida3f1c9e0-7b2d-4e5a-9c8b-1f2e3d4a5b6cevent.typereconciliation.run.completedevent.source/datarecs/core-apievent.subjecttenants/e6d82e9a36e2724f/jobs/job_a1b2c3/runs/run_7f3a9c2eevent.time2026-06-09T14:32:10.000Zevent.tenantide6d82e9a36e2724fevent.specversion1.0event.datacontenttypeapplication/json -
data.*— the event payload. The available fields depend on the event type. For example, areconciliation.run.completedevent exposes:Variable Example value data.run_idrun_7f3a9c2edata.job_idjob_a1b2c3data.tenant_ide6d82e9a36e2724fdata.workflow_namerecon-run-7f3a9c2edata.resultUNMATCHED -
secrets.*— a write-only secret you configured on the endpoint, referenced by name as{{ secrets.<name> }}(e.g.{{ secrets.api_token }}). Use this — never a literal credential — whenever a template needs a token, API key, or other credential. The value is injected at delivery time and never appears in the Console, the CLI, the preview, or any log. See Secret variables below.
Here are the data.* fields for some other common reconciliation event types:
reconciliation.run.comparison.completed carries the full comparison summary, including nested objects:
| Variable | Example value |
|---|---|
data.run_id | run_7f3a9c2e |
data.job_id | job_a1b2c3 |
data.result | UNMATCHED |
data.rows_compared | 100005 |
data.rows_matched | 99980 |
data.rows_unmatched | 25 |
data.source_row_counts | { "source_0": 100000, "source_1": 100005 } |
data.source_row_counts.source_0 | 100000 |
data.join_stats | { "matched_groups": 99980, … } |
data.join_stats.matched_groups | 99980 |
data.tolerances | the tolerances array (referenceable as a whole) |
reconciliation.run.errored:
| Variable | Example value |
|---|---|
data.run_id | run_7f3a9c2e |
data.job_id | job_a1b2c3 |
data.tenant_id | e6d82e9a36e2724f |
data.workflow_name | recon-run-7f3a9c2e |
data.error | { "code": "QUERY_FAILED", "message": "…" } |
data.error.code | QUERY_FAILED |
data.error.message | Extractor SQL error on source_1 |
reconciliation.run.rows_processed:
| Variable | Example value |
|---|---|
data.run_id | run_7f3a9c2e |
data.job_id | job_a1b2c3 |
data.stage_name | compare-ledgers |
data.row_count | 100000 |
data.mismatch_count | 12 |
Nested fields and arrays
Section titled “Nested fields and arrays”You can address a nested object by its dotted path: data.error.code, data.source_row_counts.source_0. You can also reference an object or array as a whole — data.join_stats resolves to the entire object.
Array elements are not individually addressable (you can use data.tolerances, the whole array, but not data.tolerances.0) — the number of elements depends on the event, and indexing would be logic.
Available variables: the intersection rule
Section titled “Available variables: the intersection rule”An endpoint can have several subscriptions, so a single endpoint may receive different event types. A template is applied to whatever event arrives, so you can only safely use a variable that exists in every event type your endpoint subscribes to. The available set is the intersection of each type’s fields.
- If your endpoint subscribes only to
reconciliation.run.completed, you can use that type’s fulldata.*set. - If it subscribes to both
reconciliation.run.completedandreconciliation.run.errored, you can use the fields they share (data.run_id,data.job_id,data.tenant_id,data.workflow_name) plus everything underevent.*— but notdata.result(onlycompletedhas it) ordata.error(onlyerroredhas it). - The
event.*envelope is always available because it’s the same for every type. - A subscription that selects all events (
*), or an endpoint with no subscriptions yet, collapses to envelope-only (event.*).
If a template references a variable that isn’t available to the endpoint, saving is rejected with a clear error naming the offending path. Adding a subscription later that removes a field from the intersection will also fail validation, so a stored template is always renderable.
Exact vs interpolated placeholders
Section titled “Exact vs interpolated placeholders”How a placeholder is written controls the type of the result:
-
Exact placeholder — a string whose entire value is a single placeholder, like
"{{ data.row_count }}". The placeholder is replaced by the value with its JSON type preserved: a number stays a number, an object stays an object, an array stays an array.{ "rows": "{{ data.rows_compared }}" }renders to (note: no quotes — it’s a real number):
{ "rows": 100005 }This also works for objects and arrays:
{ "counts": "{{ data.source_row_counts }}" }renders to:
{ "counts": { "source_0": 100000, "source_1": 100005 } } -
Interpolated placeholder — a placeholder embedded in surrounding text, like
"Run {{ data.run_id }} finished". The value is converted to text and the result is always a string.{ "summary": "Run {{ data.run_id }}: {{ data.result }}" }renders to:
{ "summary": "Run run_7f3a9c2e: UNMATCHED" }
Whatever a value contains, it can only ever appear as a JSON string or its native JSON type — event content can never break out of the structure you defined or change the shape of your payload.
Examples
Section titled “Examples”Slack (Block Kit)
Section titled “Slack (Block Kit)”This body template turns a reconciliation.run.completed event into a Slack Block Kit message. Point the endpoint at a Slack incoming webhook URL.
{ "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Reconciliation run {{ data.result }}" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Run:*\n{{ data.run_id }}" }, { "type": "mrkdwn", "text": "*Job:*\n{{ data.job_id }}" }, { "type": "mrkdwn", "text": "*Workflow:*\n{{ data.workflow_name }}" }, { "type": "mrkdwn", "text": "*Result:*\n{{ data.result }}" } ] }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": "Event `{{ event.id }}` at {{ event.time }}" } ] } ]}Microsoft Teams (Adaptive Card)
Section titled “Microsoft Teams (Adaptive Card)”This body template renders the same event as a Teams Adaptive Card for an incoming webhook connector.
{ "type": "message", "attachments": [ { "contentType": "application/vnd.microsoft.card.adaptive", "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": [ { "type": "TextBlock", "size": "Large", "weight": "Bolder", "text": "Reconciliation run {{ data.result }}" }, { "type": "FactSet", "facts": [ { "title": "Run", "value": "{{ data.run_id }}" }, { "title": "Job", "value": "{{ data.job_id }}" }, { "title": "Workflow", "value": "{{ data.workflow_name }}" }, { "title": "Result", "value": "{{ data.result }}" } ] }, { "type": "TextBlock", "isSubtle": true, "wrap": true, "text": "Event {{ event.id }} • {{ event.time }}" } ] } } ]}Custom headers
Section titled “Custom headers”Header templates are a map of header name → value, and each value can contain placeholders too. A common case is adding an Authorization header for an endpoint that expects a bearer token, plus a couple of routing/trace headers derived from the event:
{ "Authorization": "Bearer {{ secrets.api_token }}", "X-DataRecs-Event-Type": "{{ event.type }}", "X-DataRecs-Run-Id": "{{ data.run_id }}"}You can also override the Content-Type the rendered body is sent with (it defaults to application/json).
Secret variables
Section titled “Secret variables”Many receivers sit behind authentication — a bearer token, an API-key header, a Basic-auth credential, or an HMAC secret a gateway expects. A template needs to send that credential, but a template is not a safe place to keep one: it is stored in plaintext, rendered into every preview, and written to the endpoint’s change history.
Secret variables solve this. A secret variable is a named, write-only value you store on the endpoint and then reference from a body or header template as {{ secrets.<name> }}. The value is encrypted at rest, injected into the template only at delivery time, and is never returned, logged, or shown anywhere.
{ "Authorization": "Bearer {{ secrets.api_token }}"}How they behave
Section titled “How they behave”- Write-only. You can set, rotate, and delete a secret by name, and list the configured names — but there is no way to read a value back. The API, CLI, and Console only ever return the name and when it was last set.
- Masked in preview. Anywhere a value would otherwise be shown — the live preview, the variable picker, the CLI — a secret renders as
••••••. So a preview ofAuthorization: Bearer {{ secrets.api_token }}showsAuthorization: Bearer ••••••, confirming the shape without ever revealing the value. - Injected at delivery, then signed. The real value is substituted into the rendered body/headers at the moment of delivery, inside DataRecs’ isolated per-tenant delivery service. The signature is then computed over the fully-rendered bytes — so the receiver verifies exactly what it got, secret included.
- Referencing an unconfigured secret is rejected. If a template references
{{ secrets.typo }}and no secret namedtypois configured, saving fails with a clear error — exactly like an unknowndata.*path. A stored template therefore always has every secret it needs. - Deleting a secret a template still uses is blocked. If a stored template references
{{ secrets.api_token }}, you can’t deleteapi_tokenuntil you remove the reference — so a live endpoint can never start rendering a blank credential.
Managing secret variables
Section titled “Managing secret variables”- Open the endpoint under Automation → Webhooks and go to the Secrets panel.
- Click Add Secret, enter a name (letters, digits, and underscores — the identifier you’ll use in
{{ secrets.<name> }}) and the value. The value field is write-only — it is never pre-filled or echoed back. - Save. The secret now appears by name only. Reference it from a body or header template as
{{ secrets.<name> }}. - Rotate by setting the same name to a new value. Delete with the row’s delete action (blocked if a template still references it).
Set or rotate a secret. Prefer the hidden prompt or --value-file so the value never lands in your shell history:
# Hidden interactive prompt (recommended)datarecs webhooks endpoints secrets set ep_abc123 api_token
# From a file (use @- to read from stdin)datarecs webhooks endpoints secrets set ep_abc123 api_token --value-file @token.txtList configured secrets (names and when they were set — never values):
datarecs webhooks endpoints secrets list ep_abc123Delete a secret (fails if a template still references it):
datarecs webhooks endpoints secrets delete ep_abc123 api_tokenEach secret is its own datarecs_webhook_secret resource. The value is sensitive and write-only in effect — it is sent to the API but never stored in Terraform state (only a SHA-256 digest is, which drives rotation). Rotate by changing value.
resource "datarecs_webhook_secret" "api_token" { endpoint_id = datarecs_webhook_endpoint.slack.id name = "api_token" value = var.receiver_api_token # sensitive; never written to state}Preview and validation
Section titled “Preview and validation”Templates are checked when you save them. A template is rejected (with a clear, specific error) if:
- the body isn’t valid JSON,
- a placeholder is malformed (
{{ a.,{{}},{{ a..b }}, a stray}}), or - it references a variable that isn’t available to the endpoint.
An invalid template is never stored — so a saved template always renders.
Live preview. In the Console’s endpoint editor, pick a sample event type and the preview pane renders your template against a representative sample of that event, showing the exact body and headers that would be delivered (and any validation errors inline). Use it to confirm exact-vs-interpolated typing and your Slack/Teams shape before saving.
You can also preview from the CLI by rendering a template against an event type’s sample event:
datarecs webhooks endpoints preview ep_abc123 \ --event-type reconciliation.run.completed \ --body-template @slack-block-kit.jsonThis prints the rendered body for the supplied template against a sample of that event type — a scriptable dry-run you can add to CI.
Configuring a template
Section titled “Configuring a template”- Open the endpoint under Automation → Webhooks.
- Set the Payload Format — Standard (the default envelope), Slack, Teams, or Custom. Choosing Slack or Teams seeds the editor with a starter template you can edit.
- Edit the Body and Headers in the template editor. The preview pane updates as you pick a sample event type.
- Click Save. An invalid template is rejected with an inline error.
Apply a template by passing the endpoint fields in a JSON file:
datarecs webhooks endpoints update ep_abc123 --file ./endpoint-template.jsonendpoint-template.json:
{ "payloadFormat": "custom", "contentType": "application/json", "payloadTemplate": "{ \"text\": \"Run {{ data.run_id }} {{ data.result }}\" }", "headerTemplates": { "X-DataRecs-Event-Type": "{{ event.type }}" }}Preview a template against a sample event first:
datarecs webhooks endpoints preview ep_abc123 \ --event-type reconciliation.run.completed \ --body-template @slack-block-kit.jsonresource "datarecs_webhook_endpoint" "slack" { url = "https://hooks.slack.com/services/T000/B000/XXXX" description = "Slack run alerts" payload_format = "custom" content_type = "application/json"
payload_template = file("${path.module}/slack-block-kit.json")
header_template = { "X-DataRecs-Event-Type" = "{{ event.type }}" }}The provider surfaces the same validation as the API: an invalid payload_template fails on plan/apply.
Signature verification
Section titled “Signature verification”This is the one thing to get right when adopting templating.
The signature is computed over the rendered body you actually receive — not over the original CloudEvent envelope.
Every delivery is still signed with Standard Webhooks: the webhook-id and webhook-timestamp headers are unchanged (stable delivery id and send time), and webhook-signature covers the post-render bytes. So your receiver’s verification doesn’t change at all — keep verifying against the raw bytes exactly as delivered, before any JSON re-parsing or reformatting. The verification samples in Using Webhooks work as-is for templated payloads.
Limits
Section titled “Limits”To keep deliveries fast and safe, templates have bounds. Saving is rejected if a template exceeds them:
| Limit | Maximum |
|---|---|
| Body template size | 64 KB |
| Header value size (each) | 4 KB |
| Number of headers | 50 |
{{ }} placeholders (total, across body + headers) | 500 |
| Body JSON nesting depth | 32 |
| Rendered body size (after substitution) | 512 KB |
If a delivery would render to more than the rendered-body limit, that delivery is failed (surfaced in delivery logs) rather than truncated — DataRecs never sends a partial body.
See also
Section titled “See also”- Using Webhooks — creating endpoints and subscriptions, the standard envelope, retries, and signature verification.
- Reconciliation run lifecycle — the events you can format, and when they fire.