Transforming Payloads
New to Sparrow? This guide shows you how to change the shape of a webhook payload before it’s delivered — no code, no proxy service, just a small template you write once per subscription.
What is a transform, and why would I want one?
Section titled “What is a transform, and why would I want one?”A webhook payload is just a chunk of JSON. The problem is that the system Sparrow delivers to rarely wants the exact JSON your event started with:
- Slack wants a
{"text": "..."}message, not your raw event. - Your billing system wants amounts in cents, but your event has dollars.
- A legacy endpoint wants a flat object, but your event is deeply nested.
- A partner should only see three fields, not your entire internal record.
A transform solves this. You attach a small template to a subscription, and Sparrow runs your event through it to produce the body that actually gets sent.
Where the transform lives
Section titled “Where the transform lives”When you create or update a subscription, set its transform_template field.
That’s it — Sparrow handles the rest at delivery time.
The mental model (read this first)
Section titled “The mental model (read this first)”A template receives your event and prints text. Whatever text it prints becomes the delivered body. Your event is available through these fields:
| Field | What it is |
|---|---|
.payload | Your event’s data (the JSON you pushed) |
.event_name | The event type, e.g. payment.succeeded |
.event_id | Unique ID for this event |
.timestamp | When the event happened |
.attempt | Which delivery attempt this is |
So {{ .payload.customer.email }} reads the customer.email field out of your
event, and {{ .event_name }} prints the event type.
Try it before you ship it
Section titled “Try it before you ship it”You don’t have to guess. Test any template against a sample event:
-
List the available helpers:
Terminal window curl http://localhost:8080/v1/template-functions -
Test a template against an event type’s sample payload (returns the rendered output; it renders strictly, as a subscription does by default):
Terminal window curl -X POST http://localhost:8080/v1/subscriptions:testTemplate \-H 'Content-Type: application/json' \-d '{"event_name": "payment.succeeded","template": "{{ dict \"id\" .event_id \"amount\" .payload.amount | json }}"}' -
Or entirely offline with the CLI (no server round-trip; renders locally against a synthetic sample context instead of a stored event payload):
Terminal window sparrow template test partner.tmpl --payload '{"amount": 4200}'Add
--missing-key zeroto render missing fields as<no value>, like a subscription withtemplate_missing_key: zero.
Real-world examples
Section titled “Real-world examples”Each example shows the incoming event, the template you’d set on the subscription, and the delivered result.
1. Send only the fields a partner needs
Section titled “1. Send only the fields a partner needs”Problem: your event has 30 internal fields, but a partner should only see three.
Incoming .payload:
{ "id": "evt_123", "amount": 4200, "internal_notes": "…", "secret": "…" }Template:
{{ dict "id" .payload.id "amount" .payload.amount "event" .event_name | json }}Delivered:
{"amount":4200,"event":"payment.succeeded","id":"evt_123"}dict builds an object from "key" value pairs; json turns it into JSON.
2. Post a message to Slack
Section titled “2. Post a message to Slack”Problem: Slack’s Incoming Webhooks expect {"text": "..."}.
Incoming .payload:
{ "customer": { "name": "Ada" }, "amount": 4200, "currency": "usd" }Template:
{{ dict "text" (printf "%s paid %s %.2f" (dig "customer" "name" "Someone" .payload) (upper .payload.currency) (div .payload.amount 100)) | json }}Delivered:
{"text":"Ada paid USD 42.00"}Here dig safely reads customer.name (falling back to "Someone" if it’s
missing), div converts cents to dollars, and printf formats the sentence.
3. Convert dollars to cents for a billing system
Section titled “3. Convert dollars to cents for a billing system”Problem: your event has amounts in dollars; the target wants integer cents.
Incoming .payload:
{ "customer": { "id": "cus_9" }, "amount": 42.5, "currency": "usd" }Template:
{{ dict "user" (dig "customer" "id" "" .payload) "amount_cents" (toInt (mul .payload.amount 100)) "currency" .payload.currency | json }}Delivered:
{"amount_cents":4250,"currency":"usd","user":"cus_9"}mul multiplies, toInt drops the decimal so you get a clean integer.
4. Flatten a nested payload for a legacy endpoint
Section titled “4. Flatten a nested payload for a legacy endpoint”Problem: an old system wants a flat object, but your data is nested.
Incoming .payload:
{ "customer": { "id": "c1", "email": "ada@example.com" }, "order": { "total": 99.5 } }Template:
{{ dict "customer_id" (dig "customer" "id" "" .payload) "email" (dig "customer" "email" "" .payload) "total" (dig "order" "total" 0 .payload) | json }}Delivered:
{"customer_id":"c1","email":"ada@example.com","total":99.5}dig walks a path of keys and returns your default if any part is missing — so
one absent field never breaks the whole delivery.
5. Summarize a list of items
Section titled “5. Summarize a list of items”Problem: your event has an array of line items; you only want the SKUs.
Incoming .payload:
{ "items": [ { "sku": "A" }, { "sku": "B" }, { "sku": "C" } ] }Template:
{{ $skus := list }}{{ range .payload.items }}{{ $skus = append $skus .sku }}{{ end }}{{ dict "skus" $skus | json }}Delivered:
{"skus":["A","B","C"]}range loops over the array; append collects each SKU into a growing list.
6. Add a constant or computed field
Section titled “6. Add a constant or computed field”Problem: you want to pass the whole payload through, but tag it with extra metadata.
Template:
{{ merge .payload (dict "source" "sparrow" "delivered_at" (formatTime "2006-01-02T15:04:05Z07:00" now)) | json }}Delivered: your original payload, plus "source" and "delivered_at".
merge combines maps; later keys win, and the originals are left untouched.
7. Provide safe defaults for optional fields
Section titled “7. Provide safe defaults for optional fields”Problem: some events are missing optional fields, and you want sensible fallbacks instead of empty values.
Template:
{{ dict "name" (dig "customer" "name" "Guest" .payload) "plan" (default "free" (index .payload "plan")) | json }}dig covers missing nested keys. index reads a top-level key without failing
when it is absent, and default then covers a field that is missing or
present but empty.
Good to know
Section titled “Good to know”- Transforms run at delivery, on the way out. They don’t change what Sparrow stores or how it fans out — only the body each subscription receives.
- A template that fails is visible. See When a template fails.
- There are limits. Output is capped at 1 MB and execution at 5 seconds per transform, so a runaway template can’t stall a worker.
- Why Go templates and not JavaScript? They’re parsed once and cached, run with almost no overhead, add zero dependencies, and are safe to share across every delivery worker — a deliberate choice for a small memory footprint and high throughput. See Why Sparrow.
When a template fails
Section titled “When a template fails”A template fails when it reads a field the payload does not have (with the
default template_missing_key: error), calls a function with the wrong kind of
value, or exceeds the limits above. Two settings on the subscription decide
what happens.
| Setting | Values |
|---|---|
on_transform_error | fail (default): nothing is sent. The delivery is marked failed with error category template_error, is not retried automatically, and keeps the error in template_error. fallback: the standard event envelope is sent instead, and the error is still recorded on the delivery. |
template_missing_key | error (default): reading a missing field fails the template. zero: it prints <no value> and the delivery goes out. |
curl -X PATCH http://localhost:8080/v1/consumers/acme/subscriptions/{subscription_id} \ -H 'Content-Type: application/json' \ -d '{"on_transform_error": "fallback", "template_missing_key": "zero"}'A template error is a problem on the sending side, not with the receiving
system, so it never counts toward the webhook’s health and raises no health
alert. It is counted in the sparrow_template_errors_total metric.
To recover, fix the template, then retry: one delivery with
POST /v1/consumers/{consumer}/deliveries/{delivery_id}:retry, or all of them
by listing ?status=failed&error_category=template_error&prepare_retry=true
and starting a batch retry. Every attempt renders the subscription’s current
template, so the retry uses the fixed one. In the UI, filter Deliveries by the
Template Error category.
When an event type’s schema changes, Sparrow checks every subscription’s template against the new schema before the change is written; see Event Type Versions.
Where to go next
Section titled “Where to go next”- Template Functions reference — the full list of all 37 helpers with signatures and examples.
- Recipes — ready-made source/sink configs that use transforms.