Skip to content

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.

When you create or update a subscription, set its transform_template field. That’s it — Sparrow handles the rest at delivery time.

A template receives your event and prints text. Whatever text it prints becomes the delivered body. Your event is available through these fields:

FieldWhat it is
.payloadYour event’s data (the JSON you pushed)
.event_nameThe event type, e.g. payment.succeeded
.event_idUnique ID for this event
.timestampWhen the event happened
.attemptWhich delivery attempt this is

So {{ .payload.customer.email }} reads the customer.email field out of your event, and {{ .event_name }} prints the event type.

You don’t have to guess. Test any template against a sample event:

  1. List the available helpers:

    Terminal window
    curl http://localhost:8080/v1/template-functions
  2. 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 }}"
    }'
  3. 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 zero to render missing fields as <no value>, like a subscription with template_missing_key: zero.

Each example shows the incoming event, the template you’d set on the subscription, and the delivered result.

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.

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.

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.

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.

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

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.

SettingValues
on_transform_errorfail (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_keyerror (default): reading a missing field fails the template. zero: it prints <no value> and the delivery goes out.
Terminal window
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.