Webhook body keys named after built-in trigger fields are silently swallowed — {{trigger.text}} always renders empty for webhook triggers
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
For webhook-triggered workflows, a JSON body key named `text` (or any other built-in trigger field name: `author`, `channel_id`, `timestamp`, `emoji`, `message_id`) can never be read from a template. `{{trigger.text}}` resolves to the empty built-in field instead of the posted value, with no error or warning — the value just vanishes from the rendered message.
## Reproduction
1. Create a webhook-triggered workflow:
```yaml
name: repro
trigger:
on: webhook
steps:
- id: post
action: send_message
text: |
{{trigger.text}}
cc @Someone
```
2. Post to the hook:
```bash
curl -X POST "https:///hooks/" \
-H "X-Webhook-Secret: " -H 'Content-Type: application/json' \
-d '{"text":"hello world"}'
```
3. The relay returns 202 and the run succeeds, but the channel message contains only `cc @Someone` — the `hello world` line is empty.
Renaming the body key to anything non-reserved (e.g. `alert`) and using `{{trigger.alert}}` works, which confirms the payload arrives fine and only the lookup is broken.
## Root cause
The webhook handler puts all body keys into `webhook_fields` and leaves the built-in fields at their defaults (empty strings), [`crates/buzz-relay/src/api/bridge.rs#L1856-L1872`](https://github.com/block/buzz/blob/3afa129ee785cc74d921d0ba969254a8255c4cc0/crates/buzz-relay/src/api/bridge.rs#L1856-L1872):
```rust
// Build trigger context from webhook body fields.
let mut trigger_ctx = buzz_workflow::executor::TriggerContext {
channel_id: ...,
..Default::default() // text, author, … all ""
};
if let Some(Value::Object(ref map)) = body_json {
for (k, v) in map {
...
trigger_ctx.webhook_fields.insert(k.clone(), val_str);
}
}
```
But `TriggerContext::get_field` matches the built-in names *before* consulting `webhook_fields`, [`crates/buzz-workflow/src/executor.rs#L49-L59`](https://github.com/block/buzz/blob/3afa129ee785cc74d921d0ba969254a8255c4cc0/crates/buzz-workflow/src/executor.rs#L49-L59):
```rust
pub fn get_field(&self, name: &str) -> Option<&str> {
match name {
"text" => Some(&self.text), // "" for webhook triggers — shadows the body key
...
other => self.webhook_fields.get(other).map(|s| s.as_str()),
}
}
```
So for webhook triggers the six built-in names shadow same-named body keys, and because `get_field` returns `Some("")` rather than `None`, the template engine's "unknown keys render literally" behavior doesn't kick in either — the value silently disappears.
`text` is an especially unfortunate reserved name: it's the most natural key for the message payload (and the first thing an integrator will try), which makes this an easy foot-gun to hit and a hard one to diagnose — everything returns success end to end.
## Suggested fix
Any of these would do, roughly in order of preference:
1. For webhook triggers, check `webhook_fields` first (or only) — the built-ins other than `channel_id` are meaningless there anyway.
2. Have webhook ingestion populate `trigger_ctx.text` from a body key named `text` (and skip inserting it into `webhook_fields`), so the shadowing becomes harmless.
3. At minimum, reject or warn on reserved-name body keys / document the reserved names in the workflow docs.
Happy to send a patch if maintainers have a preference among these.
---
*Investigated and co-authored with Claude Code (Claude Fable 5).*
Contributor guide
Assessment
This issue has not been assessed yet.