Webhooks — API & CLI
Everything about the Webhook node that involves a terminal: the ingress endpoint and its status codes, the headers it reads, the REST routes that manage webhooks and events, and the hostwh CLI. What the node is and how to set it up from the dashboard is on the Webhook node page; this one is its reference.
The ingress endpoint
One route, one verb. POST /api/in/:token, where the token is the Webhook ID from the screen. It is public — no session, no bearer token unless the webhook has Require API key on — and it does not answer GET, PUT, PATCH or HEAD.
curl -X POST https://api.hostwebhook.com/api/in/<webhook-id> \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4821-created" \
-d '{ "type": "order.created", "id": "evt_4821" }'curl -X POST https://api.hostwebhook.com/api/in/<webhook-id> \
-H "Authorization: Bearer hwk_..." \
-H "Content-Type: application/json" \
-d '{ "type": "order.created", "id": "evt_4821" }'The key must belong to the same organization as the webhook, be on the webhook's list of allowed keys, and carry ingress:write (or the older, wider events:write). See API keys.
Any content type is accepted and the raw body is kept; JSON is parsed when it parses. The JSON body limit is 10 MB. Multipart uploads are handled separately, capped at 10 files, 25 parts, 20 fields, 1 MB per field and 200 MB in total, with the per-file size coming from your plan — a multipart field named payload or data is parsed as the payload, and uploaded files arrive as _files.
What it answers
| Status | When | Body |
|---|---|---|
| 202 | Accepted, Response Mode None | { eventId, streamToken } |
| 200 | Response Mode Sync, payload validated | { accepted: true, eventId } |
| 422 | Response Mode Sync, payload rejected by the Schema Validator | { error: "Schema Validation Failed", … } |
| 408 | Response Mode Sync, nobody answered in time | { error: "Pipeline timeout", eventId } |
| 500 | Response Mode Sync, the AI Node or the delivery to the Target URL failed | { error: "AI Node failed", nodeName, message, eventId } { error: "Delivery failed", lastError } |
| 200 | Recognised as a repeat | { eventId, deduplicated: true, originalEventId } |
| 404 | Unknown id — or the webhook is paused | Unknown ingress token |
| 403 | Public URL off, IP not in the allowlist, or an API key without the scope / not on the webhook's list | This endpoint is internal-only / IP not allowed / API key missing required scope: ingress:write / This API key is not allowed for this endpoint |
| 401 | Missing or invalid API key, or a signature that does not verify | This endpoint requires an API key / API key authentication failed / Invalid webhook signature |
| 429 | Over the per-minute rate limit, or over your plan's weekly events | Ingress rate limit exceeded / Weekly event limit reached |
| 413 | A multipart upload past one of the ceilings | The message names the ceiling |
successStatus and errorStatus are configurable on each Schema Validator, the body has three detail levels, and every sync response carries X-HostWebhook-Event-Id. What answers is the Schema Validator, as soon as it has judged the payload, or an AI Node, as soon as the model is done — not the end of the workflow; with several in a row, a rejection answers at once and a pass waits for the last one. All of it is on Sync responses.isActive: false is not found by the ingress lookup at all, so a provider posting to a paused webhook gets 404, not a 403 or a 409. Turning the public URL off instead gives a 403, which is at least distinguishable.Where the delivery id is read from
In this order. The first one found wins; if none is found the delivery has no identity and is never treated as a duplicate.
- An explicit header —
Idempotency-KeyorX-Idempotency-Key. - A provider's delivery id —
X-GitHub-Delivery,X-Shopify-Event-Id,webhook-id(the Standard Webhooks header),PAYPAL-TRANSMISSION-IDorI-Twilio-Idempotency-Token. - An id in the body — top-level
id,event.id,MessageSidorCallSid.
The window is enforced in Redis and clamped to your plan's ceiling at delivery time — 300 s on free, 3600 s on pro, 86400 s on enterprise. Saving a value above the ceiling is refused with a 403.
Signature headers, by type
| Type | Header read | Value it expects |
|---|---|---|
| stripe | Stripe-Signature | t=<ts>,v1=<hex>, as Stripe sends it |
| github | X-Hub-Signature-256 | sha256=<hex> over the raw body |
| shopify | X-Shopify-Hmac-Sha256 | Base64 HMAC over the raw body, bare |
| hostwebhook / custom | X-HostWebhook-Signature, X-Webhook-Signature or X-Signature | t=<ts>,v1=<hex> over "<t>.<rawBody>". A bare sha256= is rejected |
| static | Your incomingSignatureHeader, or Authorization / X-Webhook-Secret / X-API-Key | The shared secret itself, no HMAC |
A body that fails verification is not silently dropped: an event is written with status SIGNATURE_INVALID so you can see it in the events list, and the caller gets a 401. The full rules for each type — what to sign, the 300-second replay window, working examples in Node and Postman, and how to reference a Signing Secret instead of pasting one — are in Incoming signature verification. The outgoing side — how HostWebhook signs what it delivers to your server — is on the Signing page.
Managing webhooks over the API
Everything under /api/webhooks is authenticated, by bearer token or by the session cookie, and scoped: webhooks:read to read, webhooks:write to change.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/webhooks | List. Accepts ?tags=a,b and ?workspaceId= |
| POST | /api/webhooks | Create. The only response that includes the outgoing signingSecret in plaintext |
| GET | /api/webhooks/:id | Detail, with a computed successRate |
| PATCH | /api/webhooks/:id | Update. Same field names as create, minus workspaceId |
| DELETE | /api/webhooks/:id | Delete the webhook and its events |
| DELETE | /api/webhooks/bulk | Delete many — body { ids: string[] } |
| PATCH | /api/webhooks/:id/toggle-active | Pause or resume. Paused means the ingress answers 404 |
| PATCH | /api/webhooks/:id/rotate-token | New Webhook ID. The old URL stops working at once |
| GET | /api/webhooks/:id/signing-secret | Reveal the outgoing signing secret — this call is audited |
| PATCH | /api/webhooks/:id/rotate-signing-secret | New outgoing signing secret |
curl -X POST https://api.hostwebhook.com/api/webhooks \
-H "Authorization: Bearer hwk_..." \
-H "Content-Type: application/json" \
-d '{
"workspaceId": "<your-workspace-id>",
"name": "Stripe production",
"targetUrl": "https://api.myapp.com/webhooks/stripe",
"maxRetries": 5,
"retryDelaySeconds": 60,
"retryStrategy": "exponential-capped",
"incomingSignatureType": "stripe",
"incomingSignatureSecret": "whsec_...",
"ingressRateLimitPerMinute": 600,
"responseMode": "none",
"priority": 0,
"fallbackUrls": ["https://api-eu.myapp.com/webhooks/stripe"]
}'The field names of the configuration form, for the API:
| Section on screen | Fields | Notes |
|---|---|---|
| General | description, targetUrl | The target URL is optional. Private and localhost addresses are rejected. |
| Delivery & Retries | maxRetries, retryDelaySeconds, retryStrategy | 0–20 retries, 10–3600 s base delay, and one of linear, exponential, exponential-capped (the default). |
| Health Monitoring | degradedAfter, downAfter | Consecutive failures before the badge turns amber, then red. Defaults 2 and 4. |
| Protection | requireApiKey, allowedApiKeyIds, ingressRateLimitPerMinute, allowedIps | Rate limit 0 means unlimited. The allowlist takes one IP or CIDR per line; empty allows all. |
| Incoming Signature Verification | incomingSignatureType, incomingSignatureSecret, incomingSignatureHeader, incomingSigningSecretId | One of none, static, stripe, github, shopify, hostwebhook. The secret is stored encrypted and never returned. |
| Response Mode | responseMode | none or sync. Sync is refused with a 400 unless the flow carries a Schema Validator or an AI Node. |
| Auto-Recovery | autoRecovery, recoveryCheckIntervalSeconds, autoReplayOnRecovery | Probe the target while it is down, restore it when healthy, optionally replay the unacknowledged DLQ events. |
| Circuit Breaker | circuitRecoverySeconds, halfOpenSuccessThreshold | How long the circuit stays open, and how many successes close it again. |
| Advanced Delivery | priority, fallbackUrls | Priority 0–10, lower is higher. Fallbacks are tried in order when the primary fails. |
| Internal calls / Public URL | acceptsInternalCalls, internalOnly | internalOnly: true is the Public URL switch off. |
incomingSignatureSecret is write-only — it is stored encrypted and never returned. Sending it empty on a PATCH keeps the current one; to remove it, set incomingSignatureType to none. The read side tells you only whether one exists, in hasIncomingSignatureSecret./api/templates and /api/webhooks/connectors/… were removed and now answer 410 Gone. Create the webhook with POST /api/webhooks and set the fields above yourself, or add the integration from the Marketplace in the dashboard.Events, retries and the DLQ
Deliveries run through a BullMQ queue. The backoff is the one you chose: a fixed retryDelaySeconds for linear, or that value doubled on each attempt for the two exponential strategies, capped at 30 minutes by default.
The dead-letter queue is not a separate queue — it is the events that reached FAILED, carrying dlqEnteredAt, dlqExpiresAt and dlqAcknowledged. That is why Auto-Replay DLQ on Recovery in the config form can simply replay the unacknowledged ones once the target comes back.
These routes take events:read to look and events:write to act. Note that ingress:write — the scope for a key you hand to a sender — opens none of them.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/events/send-test | The Send Test Event button — body { webhookId, payload, includeSignature? } |
| POST | /api/events/:eventId/replay | Replay one event |
| POST | /api/events/replay-all?webhookId= | Replay the failed events of one webhook, up to 100 |
| POST | /api/events/:eventId/cancel | Cancel a queued event |
| GET | /api/events/:eventId/deliveries | Every delivery attempt for an event (deliveries:read) |
| GET | /api/events/:eventId/timeline | The event's timeline, step by step |
| GET | /api/events/dlq?webhookId=&acknowledged= | List dead-lettered events |
| GET | /api/events/dlq/stats | DLQ counters |
| POST | /api/events/dlq/acknowledge | Acknowledge — body { eventIds: string[] } |
| POST | /api/events/dlq/purge | Delete those events — body { eventIds: string[] } |
| DELETE | /api/events/dlq/purge-all?webhookId= | Empty the DLQ, optionally for one webhook |
| GET | /api/queue/counts | How much is waiting in the delivery queue |
/api/events/:eventId/replay. And every replay path refuses a paused webhook: “Cannot replay events on a paused webhook”. The DLQ has a floor as well as a ceiling — when it overflows your plan's limit it drops the oldest events, not the newest.Flow links
The flow-link rows in the node's Internal calls list are documents of their own, at /api/flow-links — POST, GET, GET /:id, PATCH /:id and DELETE /:id, with canvas:read to read and canvas:write to change. A link carries its source (sourceWorkspaceId, sourceNodeType, sourceNodeId), its target, and three switches: sync — on by default, the caller waits for the target pipeline — timeoutMs (30 000 by default — lowered to your plan's ceiling when that is lower; an explicit value above the ceiling is a 403) and enabled. The list can be filtered by sourceNodeType + sourceNodeId or targetNodeType + targetNodeId, and its rows carry the name of each end.
curl -X POST https://api.hostwebhook.com/api/flow-links \
-H "Authorization: Bearer hwk_..." \
-H "Content-Type: application/json" \
-d '{
"workspaceId": "<your-workspace-id>",
"sourceWorkspaceId": "<workspace A>",
"sourceNodeType": "webhook",
"sourceNodeId": "<W_A>",
"targetWorkspaceId": "<workspace B>",
"targetNodeType": "webhook",
"targetNodeId": "<W_B>",
"sync": true
}'sourceNodeType takes "webhook" (the webhook that ends the other flow) or "ai"; anything else — a Transform, an HTTP Action — is a 403 telling you to end the flow with a Webhook node, turn on its Internal calls and use that as the source. A webhook source must have acceptsInternalCalls: true, checked when the link is created and again every time it fires; a webhook cannot be its own source; and a second link between the same two nodes is a 403.An internal call does not go through the ingress: the link (or the AI Node's tool call) writes the event straight into the target webhook — for a link with eventType: "flow-link" and the headers x-hw-flow-link (the link's id) and x-hw-call-stack; for a tool call with eventType: "ai-tool" and x-hw-ai-tool — and a _meta envelope on the payload:
{
"_meta": {
"iterable": false,
"count": 1,
"source": "flow-link", // or "ai-tool"
"sourceNodeType": "webhook", // or "ai"
"sourceNodeId": "66f1…"
},
"…the payload the source received or produced…": "…"
}That envelope is on the stored event, not in front of your nodes: the flow receives the payload without _meta, so {{payload._meta.source}} resolves to nothing in a template. Nor does $() cross: the target flow sees its own workspace's nodes by name, and only the payload carries anything over from the source. Both flows do share one run — the synthetic event keeps the source's correlationId.
acceptsInternalCalls: true — the consent gate described on the node page. The sync wait has a ceiling from your plan: 10 s on free, 60 s on pro, 300 s on enterprise; a saved timeoutMs above it is clamped at dispatch.dispatchCount and lastDispatchedAt move only when the target webhook actually ran — a link skipped because the target is paused or one side has Internal calls off does not count, and a sync link whose wait ran out does, because the target ran anyway. Deleting a webhook or an AI Node deletes the links it is the source or the target of. The full behaviour — the relay to the target's own links, its Target URL, loops, depth and where a skipped link is reported — is on the Webhook node page.
CLI
The CLI is hostwh. Its reason to exist, for this node, is local development: a webhook's target URL cannot point at localhost — the API rejects private addresses — so the CLI holds the connection open and pushes deliveries down it instead. The walkthrough is on Local development.
npm install -g hostwh
hostwh loginlogin opens a browser by default; pass -p, --password for the email-and-password prompt instead. Credentials land in ~/.hostwh/config.json.
Forwarding to a local port
Two shapes, depending on whether you want the webhook to outlive the session.
hostwh forward http://localhost:3001/webhooksCreates a temporary webhook, prints its ingress URL, forwards everything that arrives to the local URL, and deletes the webhook on Ctrl+C. Add --show-payload or --show-headers to print what came through.
hostwh create --name "stripe-dev" --url https://myapp.com/webhooks
hostwh endpoints
hostwh listen <webhook-id> --forward http://localhost:3001/webhooks --show-payloadhostwh listen <id> without --forward only streams events to your terminal, which is the cheapest way to see whether a provider is reaching you at all. With --forward it becomes a tunnel and POSTs each delivery to the local URL, allowing 29 seconds for your handler to answer.
The commands that concern this node
| Command | Options | What it does |
|---|---|---|
| hostwh endpoints | — | Lists your webhooks: id, name, status, target URL. Bare, with no subcommand. |
| hostwh endpoints show <id> | — | Detail plus the ingress URL. |
| hostwh create | -n --name, -u --url, -d --description, -r --retries | Creates a webhook, prompting for anything you leave out. Retries default to 3. |
| hostwh delete <id> | -y --yes | Deletes a webhook and all its events. |
| hostwh listen <id> | -f --forward, --show-payload, --show-headers | Streams events live; forwards them to a local URL with --forward. |
| hostwh forward <local-url> | --show-payload, --show-headers | Throwaway webhook plus forwarding in one command. |
| hostwh tokens create | -n --name, -s --scope, -d --expires-in | Mints an hwk_ credential for CI or Docker. Shown once. Scope defaults to tunnel:write, expiry to 90 days. |
| hostwh config show | — | Prints the API URL, tunnels URL, whether you are logged in, and whether an API key is stored. |
hostwh test and no hostwh run. Nothing in the CLI sends, triggers or replays an event. To fire a test from a terminal, POST to the ingress URL yourself; to fire one with the example payloads and the signature, use Send Test Event on the node page.Ids are shown as their last 8 characters, and every command that takes an id accepts either those 8 or the full value. The API host comes from HOSTWEBHOOK_API_URL (default https://api.hostwebhook.com), and --api-url on login persists it.