This is how Binions calls out to any web API on the internet. Whenever a playbook needs to post to Slack, pull fresh rates from a REST API, push data to a CRM, or call a SOAP service, it hands the job to the webhookcaller daemon — the platform’s single outbound HTTP client. You describe the call in plain YAML; the daemon signs it, screens the destination, retries on transient failures, and reports the outcome — parsed reply included, if you ask for it — back onto the event bus, where the next step can use it. This page is the integration-author’s view; for the full daemon reference see webhookcaller-service.
Good to know. webhookcaller only makes outbound calls — Binions reaching out to someone else. To receive an inbound webhook (a SaaS tool calling you), that traffic arrives at the HTTP edge instead; see HTTP edge & realtime and the section on receiving webhooks below.
| What it does | Calls outbound HTTP(S) APIs — REST, SOAP, or any plain web request — and hands parsed replies to the next step |
| Playbook prefix | webhook. (event domain Webhook) |
| Operations | Exactly two: webhook.register_endpoint and webhook.send |
| Protocols | REST and JSON out of the box; SOAP via generic send + the right headers; raw-byte and multipart bodies for everything else |
| Fan-out | One send to up to 32 registered endpoints, parallel or sequential, closed by a single summary fact |
| Security | HMAC request signing, an SSRF host allow-list, and a per-host circuit breaker |
| Reports back | Fact.Webhook.Delivered / Failed / GroupCompleted / CircuitOpen / CircuitClosed / EndpointRegistered |
The whole surface area is deliberately small. There is no send_signed, no batch_send, and no test verb — signing, fan-out to many endpoints, reply parsing and raw bodies are all shapes of the same webhook.send, and every outbound call goes through one of these two:
| Operation | Kind | What it does |
|---|---|---|
webhook.register_endpoint | Provisioning | Stores a named endpoint — a base URL, default method, default headers, and any auth — under a stable alias, so business playbooks reference the alias instead of repeating URLs and credentials. |
webhook.send | Business | Makes the call. Target it at a registered endpoint alias, an inline url, or a whole endpoints list for fan-out. Takes method, headers, one of three body shapes (body / body_b64 / form + form_files), and optionally sign, expect_json and timeout_ms. |
This mirrors the platform’s wider split: provisioning playbooks set up named resources once; business playbooks reference them by alias and stay free of secrets. See Provisioning vs business playbooks for the pattern, and The integration model for how every connector follows it.
Why register an endpoint? Define
slack-alertsonce, with its URL and token, and a dozen playbooks can post to it with a one-lineendpoint: slack-alerts. Rotate the URL or token in one place and every playbook follows — no secrets scattered through your YAML. Registration also unlocks fan-out: a multicast list may only name registered endpoints, never raw URLs.
The first example provisions an endpoint; the second uses it. In provisioning, pull credentials from your secret store with ${secret.…} rather than pasting them inline:
# provisioning/register-slack.yaml — run once
steps:
- run: webhook.register_endpoint
with:
alias: slack-alerts
url: https://hooks.slack.com/services/T000/B000/XXXX
method: POST
headers:
- ["authorization", "Bearer ${secret.slack_token}"]
From then on, any business playbook posts a JSON body to that alias. The body is a JSON value, sent verbatim; if you do not set a content-type header yourself, the daemon adds content-type: application/json for you:
# business/notify-team.yaml
steps:
- run: webhook.send
with:
endpoint: slack-alerts
body:
text: "Invoice #4471 processed and filed."
Anything you spell out on the send step takes precedence over the registered defaults — so the same slack-alerts endpoint can be reused with a different method or extra header on one specific call. You can also skip registration entirely and pass an inline url, which is handy for a one-off call.
An outbound call is not fire-and-forget: the answer comes back as data your playbook can chain on. Every successful call emits Fact.Webhook.Delivered carrying the status code, a truncated copy of the response body, and the full set of response headers in response_headers{} (header names lower-cased). And when the API speaks JSON — most do — add expect_json: true and the daemon parses the reply for you: the parsed object lands in response_json{}, ready for direct chaining into the next step with paths like ${prev.response_json.rates.EUR}.
Parsing failures are loud, never silent. A reply that is not valid JSON fails the call as Fact.Webhook.Failed with reason bad_json; a reply bigger than the configured cap ([webhook].max_response_json_bytes, 256 KiB by default) fails as response_too_large. Neither is retried — retrying will not turn an HTML error page into JSON.
That turns the classic “poll an API on a schedule and keep the numbers” integration into two steps — no adapter service, no glue code. Pair it with a scheduler.register_schedule provisioning step that creates the pull-fx-rates cadence, and rows appear on every tick:
name: api-pull-to-database
description: Cron-driven GET → response_json → SQL row (API as data source).
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: pull-fx-rates
steps:
- id: fetch
run: webhook.send
with:
url: "http://127.0.0.1:9310/rates/latest"
method: GET
expect_json: true
- id: persist
run: database.write
with:
table: fx_rates
row:
base: "${steps.fetch.response_json.base}"
eur: "${steps.fetch.response_json.rates.EUR}"
fetched_status: "${steps.fetch.status}"
The follow-up step reads fields straight out of the parsed reply — and the status code rides along too. (Endpoints registered with the gRPC transport deliver their replies into the same response_json{}; see the daemon reference.)
There is no dedicated SOAP path and no soap_action argument — and we would rather be honest about that than pretend otherwise. SOAP is just HTTP with an XML envelope, so you make a SOAP call with the same generic webhook.send, plus two things: a content-type: text/xml header and a SOAPAction header naming the operation. The XML envelope goes in the body:
# business/soap-get-quote.yaml
steps:
- run: webhook.send
with:
endpoint: legacy-erp
headers:
- ["content-type", "text/xml; charset=utf-8"]
- ["SOAPAction", "\"http://example.com/GetQuote\""]
body: |
<?xml version="1.0"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetQuote xmlns="http://example.com/"><Symbol>BINS</Symbol></GetQuote>
</soap:Body>
</soap:Envelope>
One honesty note: a body is a JSON value, so an envelope sent this way travels as a JSON string. Most SOAP receivers accept that happily. For the strict legacy services that insist on untouched bytes, base64-encode the envelope and send it as body_b64 instead — the wire body is then exactly your XML, no JSON wrapping. That raw path is covered next.
JSON is the default, not the limit. A send carries exactly one of three body shapes — they are mutually exclusive, so pick one per call:
body — a JSON value, sent JSON-encoded. Everything shown so far.body_b64 — raw bytes, base64-encoded in the YAML and decoded on the way out. The wire body is exactly the decoded bytes — no re-encoding — sent as content-type: application/octet-stream unless your headers say otherwise. This is the path for PDFs, images, raw XML, or any exact-bytes payload. If the send is HMAC-signed, the signature covers these decoded bytes — precisely what the receiver reads.form + form_files — one multipart/form-data request: form{} holds the scalar text fields, form_files[] the base64-encoded file parts. This is how you upload files to APIs that expect a browser-style form. A multipart body cannot be HMAC-signed — the multipart boundary is random, so a stable signature is impossible, and the daemon refuses the combination as hmac_misconfig rather than emitting a signature that could never verify.A raw-bytes send in practice — forward each incoming mail attachment to a partner, byte for byte (for attachments your mailbox policy keeps inline):
# business/forward-attachment.yaml — raw bytes, not JSON
trigger:
event: Fact.Mail.Received
filter:
via.eq: edi-inbox
has_attachments: true
steps:
- run: webhook.send
with:
endpoint: partner-api
body_b64: ${trigger.first_attachment.content_b64}
timeout_ms: 120000
Large uploads and slow receivers get their own budget: timeout_ms (1–600 000 ms, i.e. up to ten minutes) on any send overrides the client-wide request timeout for just that call.
Sometimes one event has several consumers: an alert should reach the primary ops system, the standby, and the audit sink. Instead of three copy-pasted steps, give webhook.send a list. endpoints: names up to 32 registered endpoints and the daemon fans the same request out to all of them — mode: parallel (the default) sends to all at once, mode: sequential walks the list in declaration order, and a failed member never stops the ones after it.
Registration is the rule here, not a convenience: a multicast list accepts only registered aliases — never ad-hoc URLs — and endpoints is mutually exclusive with the single-target url / endpoint arguments. Each member of the group runs the full single-send pipeline described on this page — registry resolve, OAuth token refresh, SSRF screening, circuit breaker, HMAC signing, expect_json parsing — and emits its own Delivered or Failed, stamped with the shared group_id and its endpoint alias. When the last member finishes, one summary Fact.Webhook.GroupCompleted closes the group with total, ok, failed and per-endpoint results[].
One thing to remember in the YAML: the summary is what your step should wait for. Add expect: Webhook.GroupCompleted to the step — otherwise the saga would consider the step done at the first member’s Delivered:
name: webhook-multicast-alerts
description: Fan one alert out to three registered systems; count the result.
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: heartbeat-alert
steps:
- id: fanout
run: webhook.send
expect: Webhook.GroupCompleted
with:
endpoints: [ops-primary, ops-standby, audit-sink]
mode: parallel
body:
source: "binions-heartbeat"
fired_at: "${trigger.fired_at}"
- id: log-outcome
run: database.write
with:
table: alert_fanout_log
row:
group_id: "${steps.fanout.group_id}"
ok: "${steps.fanout.ok}"
failed: "${steps.fanout.failed}"
Multicast is an HTTP feature: endpoints registered with the gRPC or SSH transports are rejected from a fan-out list.
APIs that require an interactive consent flow (Google, Microsoft, and roughly forty more) used to demand hand-built token plumbing. Now the endpoint registration carries the whole lifecycle: declare auth: oauth2 with the provider's URLs and scopes, and the daemon generates the authorization URL (with PKCE and an anti-forgery state), exchanges the callback code for tokens, attaches a fresh Bearer header to every webhook.send, and refreshes tokens automatically before they expire. Tokens never appear in YAML and never leave the daemon.
# provisioning — register a Google-style OAuth2 endpoint
- run: webhook.register_endpoint
with:
endpoint: crm-api
url: "https://api.example.com/v1"
auth: oauth2
oauth:
auth_url: "https://accounts.example.com/o/oauth2/auth"
token_url: "https://oauth2.example.com/token"
scopes: ["crm.read", "crm.write"]
client_id: "1234-abc.apps.example.com"
client_secret_ref: "crm-oauth-secret"
The one-time consent hop rides the /in gateway: the provider redirects the operator's browser to an /in/<route> callback, a three-line playbook hands the code back to the daemon, and the endpoint flips to active. From then on every send is authenticated, and a rotated refresh token is persisted automatically.
Inbound is first-class too: point any external service's webhook at /in/<your-route> and trigger a playbook on Fact.Http.Received. If the sender signs its calls the way Stripe and GitHub do, the gateway verifies the signature for you, per route, straight from daemon configuration: name the header the signature arrives in (an optional prefix such as sha256= is stripped), point at a secret file — root-owned, and read on every request, so rotating the secret needs no restart — and the gateway checks an HMAC-SHA256 of the raw body in constant time. A missing or wrong signature is answered with 401 and audited as Fact.Http.Rejected; it never reaches your playbooks. Configuration details are on showman-service. Edge-level guards — IP allow-lists, rate limits, basic auth — belong one layer further out, as middlewares on the published route: see traefiklinker-service.
Need to answer the caller synchronously (Slack slash-commands, payment confirmations)? Make the playbook's final step a webhook.send to /in/_reply/${trigger.correlation_id}. Details and examples: HTTP edge & realtime.
Because this daemon is the one component allowed to reach the open internet, it is also the most defended. Four mechanisms work together so a misconfigured or malicious playbook cannot turn your outbound client into an attack tool.
Many APIs (Stripe, GitHub, Slack, Twilio) want each request signed so they can prove it came from you and was not tampered with in transit. Add a sign block and the daemon computes an HMAC over the exact request body and attaches it as a header:
- run: webhook.send
with:
endpoint: partner-api
body:
order_id: 4471
sign:
algorithm: hmac-sha256 # or hmac-sha512
key_env: PARTNER_SIGNING_KEY # name of an env var, never the key itself
header: X-Signature # header to carry the hex digest
hmac_misconfig), not a silent unsigned send. See Secrets management.body_b64 send is signed over its decoded bytes — the true wire body — while a multipart send cannot be signed at all and is refused as hmac_misconfig, as explained above.Server-Side Request Forgery is the classic risk of any outbound caller: a crafted URL tricks it into hitting an internal admin panel or cloud-metadata endpoint. webhookcaller blocks this with an explicit allow-list of hostnames — if a destination is not on the list, the call never leaves the host. The list lives in a config file:
# allowed_hosts.toml
# Exact host names — matched in full, case-insensitively.
exact = ["api.stripe.com", "hooks.slack.com"]
# Suffix matches — each entry MUST start with a dot.
# ".slack.com" matches hooks.slack.com and events.api.slack.com,
# but NOT slack.com itself.
suffix = [".githubusercontent.com"]
# Leave false. Set true only for a deliberate intranet target,
# which otherwise hits the private-IP guard below.
allow_private_ips = false
On top of the allow-list sits a belt-and-braces guard that rejects IP literals and hostnames pointing at private space — loopback (127.0.0.0/8, ::1, localhost), RFC 1918 ranges, link-local, the CGNAT shared range (100.64.0.0/10), and the unspecified address — unless you explicitly opt in with allow_private_ips for a known internal service. Two consequences worth knowing:
Security. Add hosts to the allow-list deliberately, one at a time, and prefer
exactentries over broadsuffixwildcards. A blocked call surfaces asFact.Webhook.Failedwith reasonssrf_blocked, so you can see exactly what was refused. More guidance in Hardening.
A flaky or down upstream should not drown in repeated requests, and a transient blip should not fail a workflow. Two layers handle this automatically, keyed per destination host:
| Mechanism | Default behaviour |
|---|---|
| Retries with backoff | Up to 3 attempts on a retryable error (network, timeout, 5xx), with exponential backoff starting at 250 ms (then 2×, 4×). A 4xx is not retried — that is your request, not their outage. Reply-parsing failures (bad_json, response_too_large) are not retried either. |
| Timeouts | A 5-second connect timeout and a 30-second per-attempt request timeout, so a hung server cannot wedge a worker. A single send can claim a bigger (or smaller) budget with timeout_ms, up to ten minutes. |
| Per-host circuit breaker | If a single host racks up 5 failures within a 30-second window, its circuit opens: further sends to that host short-circuit instantly (no wasted attempts) for 30 seconds, then a probe is allowed through. Other hosts are unaffected. |
These thresholds are configurable per host install — the figures above are the shipped defaults. The open and close transitions are visible as events, so you can alert on a degrading integration before it becomes an outage.
Every call ends as a Fact on the event bus, which other playbooks can subscribe to — success, failure, breaker state change, or a completed fan-out group:
| Fact | Meaning |
|---|---|
Fact.Webhook.EndpointRegistered | A named endpoint alias was saved. |
Fact.Webhook.Delivered | The call succeeded — carries the status code, a truncated response body, the response headers (response_headers{}, names lower-cased), duration, and attempt count; plus response_json{} when the send set expect_json. Members of a multicast are additionally stamped with group_id and endpoint. |
Fact.Webhook.Failed | The call failed — carries a machine-readable reason (such as ssrf_blocked, invalid_method, circuit_open, timeout, hmac_misconfig, bad_json, or response_too_large), the error text, and whether it was retryable. |
Fact.Webhook.GroupCompleted | A multicast group finished — one summary with total, ok, failed and per-endpoint results[]. This is the fact a fan-out saga step waits for (expect: Webhook.GroupCompleted). |
Fact.Webhook.CircuitOpen | A host’s circuit just opened — that integration is being shielded. |
Fact.Webhook.CircuitClosed | A host recovered and its circuit closed again. |
Because every event carries the same correlation id through the run, you can trace a single outbound call end to end in the logs — see Example workflows for patterns that chain a webhook.send to a follow-up reaction.