Skip to content

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.

  1. You attach a body template (a JSON string) and optional header templates to a webhook endpoint.
  2. When an event is delivered, DataRecs fills the placeholders from that event and sends the rendered result.
  3. 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:

PartWhat it is
BodyA JSON document with {{ ... }} placeholders. Must be valid JSON.
HeadersA map of header name → value template (each value may contain placeholders).
Content-TypeThe Content-Type header sent with the rendered body (defaults to application/json).

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:

    VariableExample value
    event.ida3f1c9e0-7b2d-4e5a-9c8b-1f2e3d4a5b6c
    event.typereconciliation.run.completed
    event.source/datarecs/core-api
    event.subjecttenants/e6d82e9a36e2724f/jobs/job_a1b2c3/runs/run_7f3a9c2e
    event.time2026-06-09T14:32:10.000Z
    event.tenantide6d82e9a36e2724f
    event.specversion1.0
    event.datacontenttypeapplication/json
  • data.* — the event payload. The available fields depend on the event type. For example, a reconciliation.run.completed event exposes:

    VariableExample value
    data.run_idrun_7f3a9c2e
    data.job_idjob_a1b2c3
    data.tenant_ide6d82e9a36e2724f
    data.workflow_namerecon-run-7f3a9c2e
    data.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:

VariableExample value
data.run_idrun_7f3a9c2e
data.job_idjob_a1b2c3
data.resultUNMATCHED
data.rows_compared100005
data.rows_matched99980
data.rows_unmatched25
data.source_row_counts{ "source_0": 100000, "source_1": 100005 }
data.source_row_counts.source_0100000
data.join_stats{ "matched_groups": 99980, … }
data.join_stats.matched_groups99980
data.tolerancesthe tolerances array (referenceable as a whole)

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 wholedata.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 full data.* set.
  • If it subscribes to both reconciliation.run.completed and reconciliation.run.errored, you can use the fields they share (data.run_id, data.job_id, data.tenant_id, data.workflow_name) plus everything under event.* — but not data.result (only completed has it) or data.error (only errored has 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.

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.

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 }}" }
]
}
]
}

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 }}"
}
]
}
}
]
}

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).

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 }}"
}
  • 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 of Authorization: Bearer {{ secrets.api_token }} shows Authorization: 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 named typo is configured, saving fails with a clear error — exactly like an unknown data.* 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 delete api_token until you remove the reference — so a live endpoint can never start rendering a blank credential.
  1. Open the endpoint under Automation → Webhooks and go to the Secrets panel.
  2. 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.
  3. Save. The secret now appears by name only. Reference it from a body or header template as {{ secrets.<name> }}.
  4. Rotate by setting the same name to a new value. Delete with the row’s delete action (blocked if a template still references it).

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:

Terminal window
datarecs webhooks endpoints preview ep_abc123 \
--event-type reconciliation.run.completed \
--body-template @slack-block-kit.json

This prints the rendered body for the supplied template against a sample of that event type — a scriptable dry-run you can add to CI.

  1. Open the endpoint under Automation → Webhooks.
  2. Set the Payload FormatStandard (the default envelope), Slack, Teams, or Custom. Choosing Slack or Teams seeds the editor with a starter template you can edit.
  3. Edit the Body and Headers in the template editor. The preview pane updates as you pick a sample event type.
  4. Click Save. An invalid template is rejected with an inline error.

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.

To keep deliveries fast and safe, templates have bounds. Saving is rejected if a template exceeds them:

LimitMaximum
Body template size64 KB
Header value size (each)4 KB
Number of headers50
{{ }} placeholders (total, across body + headers)500
Body JSON nesting depth32
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.