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.

bashSend an event
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" }'
bashSend an event to a webhook that requires an API key
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

StatusWhenBody
202Accepted, Response Mode None{ eventId, streamToken }
200Response Mode Sync, payload validated{ accepted: true, eventId }
422Response Mode Sync, payload rejected by the Schema Validator{ error: "Schema Validation Failed", … }
408Response Mode Sync, nobody answered in time{ error: "Pipeline timeout", eventId }
500Response Mode Sync, the AI Node or the delivery to the Target URL failed{ error: "AI Node failed", nodeName, message, eventId }
{ error: "Delivery failed", lastError }
200Recognised as a repeat{ eventId, deduplicated: true, originalEventId }
404Unknown id — or the webhook is pausedUnknown ingress token
403Public URL off, IP not in the allowlist, or an API key without the scope / not on the webhook's listThis endpoint is internal-only / IP not allowed / API key missing required scope: ingress:write / This API key is not allowed for this endpoint
401Missing or invalid API key, or a signature that does not verifyThis endpoint requires an API key / API key authentication failed / Invalid webhook signature
429Over the per-minute rate limit, or over your plan's weekly eventsIngress rate limit exceeded / Weekly event limit reached
413A multipart upload past one of the ceilingsThe message names the ceiling
ℹIn Sync mode the status code is the real one, and the 200 and 422 above are only the defaults — 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.
⚠Paused and unknown look identical from outside. A webhook with 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.

  1. An explicit header — Idempotency-Key or X-Idempotency-Key.
  2. A provider's delivery id — X-GitHub-Delivery, X-Shopify-Event-Id, webhook-id (the Standard Webhooks header), PAYPAL-TRANSMISSION-ID or I-Twilio-Idempotency-Token.
  3. An id in the body — top-level id, event.id, MessageSid or CallSid.
ℹThere is deliberately no hash-of-the-body fallback. Two byte-identical bodies with no id are two events, because that is what they usually are — the same form filled twice, the same button pressed twice. Header names are matched as given, lowercased and uppercased.

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

TypeHeader readValue it expects
stripeStripe-Signaturet=<ts>,v1=<hex>, as Stripe sends it
githubX-Hub-Signature-256sha256=<hex> over the raw body
shopifyX-Shopify-Hmac-Sha256Base64 HMAC over the raw body, bare
hostwebhook / customX-HostWebhook-Signature, X-Webhook-Signature or X-Signaturet=<ts>,v1=<hex> over "<t>.<rawBody>". A bare sha256= is rejected
staticYour incomingSignatureHeader, or Authorization / X-Webhook-Secret / X-API-KeyThe 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.

MethodPathPurpose
GET/api/webhooksList. Accepts ?tags=a,b and ?workspaceId=
POST/api/webhooksCreate. The only response that includes the outgoing signingSecret in plaintext
GET/api/webhooks/:idDetail, with a computed successRate
PATCH/api/webhooks/:idUpdate. Same field names as create, minus workspaceId
DELETE/api/webhooks/:idDelete the webhook and its events
DELETE/api/webhooks/bulkDelete many — body { ids: string[] }
PATCH/api/webhooks/:id/toggle-activePause or resume. Paused means the ingress answers 404
PATCH/api/webhooks/:id/rotate-tokenNew Webhook ID. The old URL stops working at once
GET/api/webhooks/:id/signing-secretReveal the outgoing signing secret — this call is audited
PATCH/api/webhooks/:id/rotate-signing-secretNew outgoing signing secret
bashCreate a webhook
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 screenFieldsNotes
Generaldescription, targetUrlThe target URL is optional. Private and localhost addresses are rejected.
Delivery & RetriesmaxRetries, retryDelaySeconds, retryStrategy0–20 retries, 10–3600 s base delay, and one of linear, exponential, exponential-capped (the default).
Health MonitoringdegradedAfter, downAfterConsecutive failures before the badge turns amber, then red. Defaults 2 and 4.
ProtectionrequireApiKey, allowedApiKeyIds, ingressRateLimitPerMinute, allowedIpsRate limit 0 means unlimited. The allowlist takes one IP or CIDR per line; empty allows all.
Incoming Signature VerificationincomingSignatureType, incomingSignatureSecret, incomingSignatureHeader, incomingSigningSecretIdOne of none, static, stripe, github, shopify, hostwebhook. The secret is stored encrypted and never returned.
Response ModeresponseModenone or sync. Sync is refused with a 400 unless the flow carries a Schema Validator or an AI Node.
Auto-RecoveryautoRecovery, recoveryCheckIntervalSeconds, autoReplayOnRecoveryProbe the target while it is down, restore it when healthy, optionally replay the unacknowledged DLQ events.
Circuit BreakercircuitRecoverySeconds, halfOpenSuccessThresholdHow long the circuit stays open, and how many successes close it again.
Advanced Deliverypriority, fallbackUrlsPriority 0–10, lower is higher. Fallbacks are tried in order when the primary fails.
Internal calls / Public URLacceptsInternalCalls, internalOnlyinternalOnly: 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.
ℹThe provider presets that used to live at /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.

MethodPathPurpose
POST/api/events/send-testThe Send Test Event button — body { webhookId, payload, includeSignature? }
POST/api/events/:eventId/replayReplay one event
POST/api/events/replay-all?webhookId=Replay the failed events of one webhook, up to 100
POST/api/events/:eventId/cancelCancel a queued event
GET/api/events/:eventId/deliveriesEvery delivery attempt for an event (deliveries:read)
GET/api/events/:eventId/timelineThe event's timeline, step by step
GET/api/events/dlq?webhookId=&acknowledged=List dead-lettered events
GET/api/events/dlq/statsDLQ counters
POST/api/events/dlq/acknowledgeAcknowledge — body { eventIds: string[] }
POST/api/events/dlq/purgeDelete those events — body { eventIds: string[] }
DELETE/api/events/dlq/purge-all?webhookId=Empty the DLQ, optionally for one webhook
GET/api/queue/countsHow much is waiting in the delivery queue
ℹThere is no DLQ-specific replay route — a dead-lettered event is replayed with the ordinary /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.

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.

bashLink the webhook that ends flow A to the one that starts flow B
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
  }'
ℹThe source is a webhook — or an AI Node. 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:

jsonWhat the target webhook receives from an internal call
{
  "_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.

ℹA webhook is the only thing a flow link can point at. The list of allowed target types has exactly one entry. And the target must have 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.

bashInstall and sign in
npm install -g hostwh
hostwh login

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

bashThrowaway — endpoint created and deleted for you
hostwh forward http://localhost:3001/webhooks

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

bashPersistent — an endpoint that stays
hostwh create --name "stripe-dev" --url https://myapp.com/webhooks
hostwh endpoints
hostwh listen <webhook-id> --forward http://localhost:3001/webhooks --show-payload

hostwh 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

CommandOptionsWhat 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 --retriesCreates a webhook, prompting for anything you leave out. Retries default to 3.
hostwh delete <id>-y --yesDeletes a webhook and all its events.
hostwh listen <id>-f --forward, --show-payload, --show-headersStreams events live; forwards them to a local URL with --forward.
hostwh forward <local-url>--show-payload, --show-headersThrowaway webhook plus forwarding in one command.
hostwh tokens create-n --name, -s --scope, -d --expires-inMints 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.
ℹThere is no 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.