webhookcaller-service is the platform's outbound HTTP client — the daemon that lets a playbook call any REST or SOAP API on the internet. When a workflow needs to reach out — post a message to Slack, push a record into a CRM, pull the latest rates from a currency API, trigger a partner's webhook, fire a legacy SOAP endpoint — this is the daemon that makes the request, signs it if required, applies sensible timeouts and retries, and reports back what happened — including the reply itself, parsed and ready to use as data when you ask for it. You describe what to call and what to send in short, readable YAML; the daemon handles the connection, the signing, and the failure handling for you.
Safe outbound by design. A daemon that can call “any URL” is a security risk if left unguarded. webhookcaller-service is built the other way around: it can only reach hosts you have explicitly allowed, it blocks requests aimed at your own internal network by default, it signs requests with secrets that never appear in a playbook, and it trips a circuit breaker on a host that keeps failing. More on each of these below.
Binions is event-driven: daemons talk to each other over a fast internal event bus, and a playbook addresses a daemon by naming a lowercase verb. webhookcaller-service is the daemon that talks to the outside world over HTTP. When a playbook step asks it to send a request, the daemon resolves the target, runs the HTTP call through a hardened client, and emits the outcome as an event the rest of your workflow can react to.
The mental model is two complementary actions — one sets things up, the other does the work:
That is the whole platform you need to learn here: register an endpoint and send. Everything — REST calls, JSON webhooks, SOAP requests, signed Stripe-style callbacks, raw-byte and multipart file uploads, API pulls that feed your database, fan-outs to a fleet of receivers — is built from those two verbs plus the headers and body you choose. There is no separate verb per protocol or per API.
| What it is | The platform's outbound HTTP client — call any REST or SOAP API from a playbook, and read the reply as data |
| Playbook prefix | webhook. |
| Operations | 2 — register_endpoint (set up) and send (make the call — one target or a whole fan-out) |
| Protocols | HTTP and HTTPS — REST, JSON webhooks, and SOAP (XML over HTTP); gRPC and SSH via protocol: |
| Body formats | JSON (body), raw bytes (body_b64), or multipart form-data (form + form_files) — one per send |
| Replies | Status, trimmed body and lower-cased headers on every delivery; the parsed JSON reply via expect_json: true |
| Fan-out | endpoints: — one send to up to 32 registered endpoints, parallel or sequential, closed by a group summary |
| Request signing | Optional HMAC (SHA-256 or SHA-512), with the secret held in an environment variable |
| Outbound safety | Host allow-list, private-network block, per-host circuit breaker, timeouts & retries |
| Service | binions-webhookcaller.service with a dedicated redis-binions-webhookcaller.service |
| Health endpoint | 127.0.0.1:9109 — /health/live, /health/ready, /metrics |
| Package | binions-webhookcaller — one of the 13 binions in a set |
In a playbook you address the daemon with the lowercase verb form run: webhook.<operation>, which the platform turns into the daemon's internal Action.Webhook.<Verb>. There are two operations — one provisions an endpoint, the other sends a request.
| Operation | What it does | Arguments |
|---|---|---|
webhook.register_endpoint | Define a named endpoint — a reusable alias for a remote API with a base URL, a default method, and default headers (such as an authorization header). Provisioning; idempotent — re-registering updates the definition in place. | alias, url; optional method (default POST), optional headers |
webhook.send | Make an HTTP request — to a registered endpoint by alias, to an inline URL, or to a whole list of registered endpoints at once. Any value you supply explicitly overrides the endpoint's default. | one of endpoint (an alias), url (inline), or endpoints (1–32 aliases — fan-out, with optional mode); optional method (default POST), headers, one body source — body (JSON) / body_b64 (raw bytes) / form + form_files (multipart) — plus expect_json, timeout_ms, sign (HMAC instruction) |
Two verbs, no more. Some early notes mention extra verbs such as
send_signed,batch_send, ortest. Those do not exist. Signing is not a separate verb — it is the optionalsignfield onwebhook.send. Fanning one payload out to many receivers is not a separate verb either — it is theendpoints:list on the samesend(see Fan-out below). And there is no separate SOAP verb: a SOAP call is an ordinarywebhook.sendwith the right headers and an XML body, shown below.
When you send to a registered endpoint, its url, method, and headers are the baseline; anything you pass on the send step takes precedence. So you can register an endpoint once with its authorization header and then, per call, point at a specific path, switch the method, or add a one-off header. headers is a list of [name, value] pairs rather than a map, so you can attach the same header more than once where an API requires it. The same resolution applies to every member of a fan-out: each alias in an endpoints: list resolves independently, exactly as a single send would.
Timing is tunable per call, too: a timeout_ms on the step (from 1 ms up to 600 000 ms) overrides the daemon-wide request timeout for that one send — handy for a known-slow partner, or for putting a tight deadline on a quick health probe.
A provisioning playbook that runs once at first boot and gives a remote REST API a short alias, with its base URL and an authorization header. It triggers on the platform booting and filters so it sets up only as the workflow engine starts:
name: provision-crm-endpoint
trigger:
event: Fact.System.Boot
filter: { component.eq: playbook-service }
steps:
- run: webhook.register_endpoint
with:
alias: crm
url: https://api.example-crm.com/v2/contacts
method: POST
headers:
- ["Authorization", "Bearer ${secret.CRM_API_TOKEN}"] # provisioning step — secrets allowed here
- ["Content-Type", "application/json"]
From now on, any business playbook can reach that API as crm without repeating the URL or the token. Because registration is idempotent, leaving this playbook in place is harmless — a later boot simply confirms the same definition rather than creating a duplicate. Keeping endpoint setup in its own provisioning playbook, separate from the workflows that use it, is the recommended pattern — see Provisioning vs. business playbooks.
A business playbook calls the registered endpoint by alias and supplies a JSON body. Here, when an AI step has extracted a new contact, we push it to the CRM:
name: push-contact-to-crm
trigger:
event: Fact.AI.Extracted
filter: { document_type.eq: business_card }
steps:
- run: webhook.send
id: push
with:
endpoint: crm
body:
name: "Acme Components Ltd"
email: "hello@acme-components.example"
source: "scanned business card"
The daemon sends a POST to the CRM's URL with the endpoint's authorization header, encodes the body as JSON, and adds Content-Type: application/json if you did not set one yourself. On success it emits Fact.Webhook.Delivered carrying the HTTP status, a trimmed copy of the response body, and the response headers — lower-cased — as response_headers{}; a later step reads it with ${steps.push.status}. If the call fails, it emits Fact.Webhook.Failed with a short reason instead.
A send carries exactly one body source — the three are mutually exclusive:
body — a JSON value. The everyday choice for REST and webhook APIs: the daemon encodes it as JSON and defaults the content type to application/json. A plain string body with your own Content-Type header covers text formats too — that is how the SOAP envelope below travels.body_b64 — raw bytes, base64-encoded in the playbook. The daemon decodes it and sends the bytes untouched — a PDF, an image, a binary EDI message. The default content type is application/octet-stream; set your own via headers. If you also sign the request, the HMAC is computed over the decoded bytes, so the signature matches exactly what the receiver reads.form + form_files — one multipart upload. form holds scalar text fields and form_files is a list of base64-encoded file parts; together they go out as a single multipart/form-data request — the shape most file-accepting HTTP APIs expect. One deliberate limit: a multipart body cannot be HMAC-signed. The multipart boundary is generated at random, so there is no stable byte stream to sign — combining sign with a form body is refused with hmac_misconfig rather than sent with a signature that could never verify.Raw bytes make the daemon a natural bridge for files that arrive as events — for example, forwarding an inline mail attachment to a partner exactly as it was received:
- run: webhook.send
with:
endpoint: edi-partner
headers:
- ["Content-Type", "application/pdf"]
body_b64: ${trigger.first_attachment.content_b64}
A SOAP request is just a webhook.send to an endpoint, with two headers that SOAP services expect — a text/xml content type and a SOAPAction — and the SOAP envelope as the body. There is no dedicated SOAP verb and no soap_action argument; you express both with ordinary headers:
name: soap-get-quote
trigger:
event: Fact.Schedule.Fired
filter: { name.eq: refresh-fx-rates }
steps:
- run: webhook.send
id: quote
with:
endpoint: legacy-quotes # registered with the SOAP service URL
headers:
- ["Content-Type", "text/xml; charset=utf-8"]
- ["SOAPAction", "\"http://example.com/GetQuote\""]
body: |
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetQuote xmlns="http://example.com/">
<symbol>GBPUSD</symbol>
</GetQuote>
</soap:Body>
</soap:Envelope>
The Content-Type header you set takes precedence over the daemon's JSON default, so the XML envelope is sent as SOAP expects. The response — an XML document — comes back on Fact.Webhook.Delivered as the response body, ready for a later step to parse.
webhook.send is not only for pushing — it pulls just as well. Add expect_json: true to a send and the daemon parses the reply into response_json{} on the delivered fact, where a later step addresses it field by field — ${prev.response_json.rates.EUR} — exactly like any other fact payload. No temporary file, no parsing step, no glue code: the API's answer is the event. (The lower-cased response_headers{} ride on every delivered fact whether or not you ask for JSON, so rate-limit counters and pagination hints are always in reach.)
expect_json is strict by design. A reply that is not valid JSON fails the send with reason: bad_json; a reply body larger than the daemon's max_response_json_bytes cap (256 KiB by default, configurable) fails with response_too_large. Both are non-retryable — retrying would fetch the same unusable payload — so the failure surfaces immediately instead of burning retry attempts. In every case the familiar trimmed response body stays on the fact, so you never lose visibility into what the API actually returned.
The classic shape is a scheduled pull that lands straight in a table:
name: api-pull-to-database
description: Cron-driven GET - the parsed reply becomes a SQL row.
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: pull-fx-rates
steps:
- id: fetch
run: webhook.send
with:
url: "https://api.fx.example/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}"
A schedule fires, the daemon GETs the API — the host must be on the allow-list like any other target — and the parsed reply flows directly into a database row, with the HTTP status kept alongside for auditing. The same pattern turns any JSON API into a data source for AI steps, analytics, or dashboards.
One webhook.send can address a whole list of registered endpoints. Give the step endpoints: — a list of 1 to 32 registered aliases — instead of endpoint or url (the three are mutually exclusive, and the list accepts registered aliases only, never inline URLs). By default the members run in parallel; set mode: sequential to walk them one at a time in declaration order. Sequential mode does not stop on a failure — every member gets its attempt, and the summary tells you how many made it.
Each member of the group is a complete, first-class send: it resolves its alias in the registry, refreshes its OAuth token if the endpoint uses one, passes the SSRF guard, honours the per-host circuit breaker, signs with HMAC if asked, and parses the reply when expect_json is set. Each emits its own Fact.Webhook.Delivered or Fact.Webhook.Failed, stamped with the shared group_id and its own endpoint alias, so member outcomes are always tellable apart. When the last member finishes, one summary fact — Fact.Webhook.GroupCompleted with total, ok, failed, and a per-member results[] — closes the group.
That summary is the fact your workflow should wait for. Because a fan-out emits one delivered-or-failed fact per member plus the summary, the step must tell the engine which fact completes it: set the step key expect: Webhook.GroupCompleted, as in the example below — otherwise the step would finish on the first member's result. And fan-out is HTTP-only: a grpc or ssh send with an endpoints: list is rejected.
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}"
The second step reads the summary directly — how many succeeded, how many failed, and the group_id that also stamps every member fact — enough to alert when failed is non-zero, or to reconcile per-member outcomes from results[] later.
The same webhook.send verb reaches two non-HTTP targets through its protocol: field, so neither needs a new daemon or a new word in the vocabulary:
protocol: grpc — call a unary method on any reflection-enabled gRPC service. The daemon learns the message schema from the server, transcodes your JSON to protobuf and back, and the decoded reply lands in the same response_json{} field an HTTP reply fills — one shape for “call something and use the answer”, whatever the protocol. See gRPC APIs.protocol: ssh — run an operator-approved command on a remote host. A playbook supplies only a command_ref (the name of an allow-listed command), never a shell string — deliberately not a remote shell. See Remote commands over SSH.Both reuse this daemon’s safeguards: the SSRF host allow-list guards the gRPC target, and SSH credentials come from permission-restricted files, never from a playbook. Outcomes arrive on the same Fact.Webhook.Delivered / Fact.Webhook.Failed events as an HTTP call. One thing they do not share is fan-out — an endpoints: list works over HTTP only.
A registered endpoint can carry a full OAuth2 authorization-code + PKCE setup via auth: oauth2. When this is configured, the daemon walks the consent flow, attaches fresh Bearer tokens to outbound requests, and refreshes them automatically as they expire. This means your playbooks never handle token lifecycle manually — you register the endpoint once with its OAuth2 settings and every subsequent webhook.send is authenticated transparently. That holds for fan-out too: each member of an endpoints: list refreshes its own token as part of its full per-member pipeline. See Webhooks & APIs for the full setup reference.
Outbound HTTP is one of the easier places to introduce a security problem — a leaked secret, a request tricked into hitting your own internal network, an unresponsive partner that wedges your workflows. webhookcaller-service is designed so the safe behaviour is the default, not something you have to remember to switch on.
Many APIs (Stripe, GitHub, Slack and others) require each request to carry a signature proving it came from you. The daemon does this for you when you add a sign instruction to a send step: it computes an HMAC over the request body and attaches it as a header. The signature is hex-encoded, exactly the format those services expect.
- run: webhook.send
with:
endpoint: partner-callback
body:
order_id: "ORD-2026-0481"
status: "shipped"
sign:
algorithm: HmacSha256 # or HmacSha512 for stronger digests
key_env: PARTNER_SIGNING_KEY # env var holding the shared secret
header: X-Signature # header to carry the signature
key_env names an environment variable that holds the shared secret; the daemon reads the secret from there. Inline keys are deliberately not accepted, so a secret never lands in playbook YAML or in an event payload. You drop the key into the daemon's service environment — see Secrets & credentials.algorithm is HmacSha256 (the common choice) or HmacSha512 for receivers that demand a stronger digest. header is whatever the receiver looks for — for example X-Signature, X-Hub-Signature-256, or Stripe-Signature.body the HMAC covers the encoded request body; for body_b64 it covers the decoded bytes — what the receiver actually gets, not the base64 wrapper. A multipart body cannot be signed at all: its boundary is random, so a sign combined with form / form_files is refused with hmac_misconfig instead of producing a signature that could never verify.By default the daemon can only call hosts you have explicitly permitted. A request to any other host is refused before it leaves the machine, with the reason ssrf_blocked. This is what stops a malformed or hostile input — perhaps a URL that arrived in an incoming event — from being turned into a request against an unexpected target. The allow-list lives in its own file and has two parts:
# Hosts webhookcaller-service is permitted to reach.
# Exact host names — matched first, case-insensitively.
exact = ["api.stripe.com", "api.example-crm.com"]
# Suffix matches — a leading "." is required, so ".slack.com"
# matches hooks.slack.com but NOT slack.com itself.
suffix = [".slack.com", ".github.com"]
Two safe defaults worth knowing. First, an empty allow-list blocks everything — a freshly provisioned host sends nothing until you list the hosts it may reach. Second, even for an allowed host, requests that resolve to a private, loopback, link-local, or carrier-grade-NAT address are blocked, so a permissive entry cannot be used to reach services inside your own network. If you genuinely run an internal webhook target (an intranet chat server, for instance), you opt in explicitly with
allow_private_ips = truein the same file.
When a remote host starts failing, the daemon stops hammering it. After a number of failures within a short window, the breaker for that host opens: further calls fail fast with circuit_open rather than waiting on a dead service, and the daemon emits Fact.Webhook.CircuitOpen. After a brief cool-off it allows a single probe (half-open); if that succeeds the breaker closes again and emits Fact.Webhook.CircuitClosed. The breaker is per-host, so one struggling partner never drags down calls to a healthy one. Around that sit a per-attempt request timeout and a small number of automatic retries with backoff for transient network and server errors — all configurable below, with the timeout also overridable per send via timeout_ms.
Configuration lives in the daemon's application.toml. The [redis] block points at the dedicated Redis instance that holds the daemon's registered endpoints, transactional outbox, and idempotency claims; [healthcheck] exposes the local health and metrics server; and [webhook] holds the safety controls — the allow-list file plus the retry, breaker, and reply-parsing policy. An optional [otel] block enables distributed tracing.
# /opt/binions/webhookcaller-service/config/application.toml
[redis]
host = "127.0.0.1"
port = 6399
password_file = "/opt/binions/webhookcaller-service/secrets/redis.password"
[healthcheck]
listen_addr = "127.0.0.1:9109"
[webhook]
# The SSRF allow-list (see Security above) lives in its own file.
allowed_hosts_file = "/opt/binions/webhookcaller-service/config/allowed_hosts.toml"
# HTTP client retry policy — applies to network / server / timeout failures.
max_attempts = 3
backoff_base_ms = 250
request_timeout_ms = 30000
# Largest reply body expect_json will parse (bytes). Default 256 KiB;
# a bigger reply fails the send with response_too_large.
max_response_json_bytes = 262144
# Per-host circuit breaker: open after this many failures inside the window,
# stay open for the cool-off, then allow a single half-open probe.
breaker_failure_threshold = 5
breaker_failure_window_secs = 30
breaker_open_duration_secs = 30
# Optional: export traces to a collector for end-to-end visibility.
# Remove this block to keep the daemon JSON-logging only (zero overhead).
# [otel]
# endpoint = "http://127.0.0.1:4317"
password_file, and HMAC signing keys come from environment variables — in line with how every daemon handles credentials. See Secrets & credentials.timeout_ms.Every call reports its outcome as an event on the internal bus, so other playbooks and your monitoring can react to what happened. The ones your workflows read most are the delivered and failed facts — plus the group summary when a send fans out; the breaker facts and the registration fact are mainly for observability.
| Event | Meaning |
|---|---|
Fact.Webhook.Delivered | A request succeeded. Carries the target URL, the HTTP status, a trimmed copy of the response body, the lower-cased response headers as response_headers{}, the duration, and the number of attempts — plus the parsed reply as response_json{} when the send set expect_json: true. Fan-out members are additionally stamped with group_id and endpoint. |
Fact.Webhook.Failed | A request could not be delivered. Carries the URL, a short reason tag (such as ssrf_blocked, circuit_open, timeout, bad_json, response_too_large, or an HTTP error class), the error text, and whether the platform considered it retryable. |
Fact.Webhook.GroupCompleted | A fan-out finished — one summary per endpoints: send, carrying total, ok, failed, and the per-member results[]. This is the fact a fan-out step must name with expect: Webhook.GroupCompleted (see Fan-out above). |
Fact.Webhook.CircuitOpen | The breaker for a host opened after repeated failures; calls to that host fail fast until it cools off. |
Fact.Webhook.CircuitClosed | A host recovered — a half-open probe succeeded and normal calls resume. |
Fact.Webhook.EndpointRegistered | A named endpoint was created or updated. |
Because the result is itself an event, the next step in the same playbook can consume it directly — branch on the status, read a field out of the parsed reply, or react to a failure — with no temporary files and no second round-trip. For the full anatomy of the envelope every event shares, including the correlation id that ties a request to the workflow that made it, see The event envelope.
Like every binion, webhookcaller-service runs as its own hardened systemd service under a dedicated, unprivileged webhookcallersvc user, alongside its own Redis instance that holds its registered endpoints and idempotency state. Install the package and bring both units up together:
# Install the package and start both units
sudo apt install binions-webhookcaller
sudo systemctl enable --now redis-binions-webhookcaller binions-webhookcaller
# Check status
systemctl status binions-webhookcaller
The service is a notify-type unit with a 30-second watchdog: it must report liveness within its watchdog window or systemd restarts it, so a wedged daemon heals itself. Its memory is capped well under the platform's lightweight budget. Check health directly over the local endpoint:
# Liveness and readiness
curl -s http://127.0.0.1:9109/health/live
curl -s http://127.0.0.1:9109/health/ready
# Prometheus-style metrics
curl -s http://127.0.0.1:9109/metrics
If a send is blocked or never arrives. The most common cause is the allow-list: a
Fact.Webhook.Failedwithreason: ssrf_blockedmeans the target host is not listed (remember an empty list blocks everything, and a private-network target needsallow_private_ips). Areason: circuit_openmeans the host has been failing and the breaker is protecting you — the call will retry once it cools off. Atimeoutpoints at a slow upstream, too tight arequest_timeout_ms, or a too-aggressive per-sendtimeout_ms. Abad_jsonorresponse_too_largecomes fromexpect_json: the reply was not valid JSON, or exceededmax_response_json_bytes— neither is retried, because the same payload would come back again.