Hookway

Configuration

Use YAML to define the server, destinations, and flows. Start with examples/config.yaml or choose a flow from the example catalog.

Set the file with HOOKWAY_CONFIG or --config; the flag wins. String values support ${ENV_NAME} expansion. Check the file before serving it:

hookway validate --config core.yaml

This reference explains fields and defaults. For workflows, see ingress adapters, JSON validation, lookups, and protection stores.

Optional flow files

Keep all flows inline, or store every flow in its own YAML file. An entry file continues to hold all central settings and shared resources:

# core.yaml
server:
  addr: ":8080"
flows:
  orders:
    file: flows/orders.yaml
  billing:
    file: flows/billing.yaml

Each referenced file contains a flow body directly: nodes, edges, and optional responses, delivery_failure_policy, or capture_failures. The flow name comes from the entry file. Flow files cannot contain a flows wrapper, process settings, or further file references. Inline flows and file references cannot be mixed.

References are literal .yaml or .yml paths relative to the entry file's directory. Absolute paths, paths escaping that directory, escaping symlinks, URLs, glob patterns, environment variables in paths, and multiple references to the same file are rejected. YAML anchors are local to each file. All files are assembled before graph compilation, shared-reference checks and cross-flow validation; missing or invalid files prevent startup.

Use the same commands for either layout:

hookway validate --config core.yaml
hookway validate --config core.yaml --json --no-env
hookway serve --config core.yaml

For a complete example, see multi-YAML configuration. Mount or deploy the entire configuration directory, retaining the relative paths; mounting only the entry YAML will leave its flow files unavailable. Unpack builder ZIP downloads before starting Hookway. Replay's separate configuration continues to be passed to hookway-replay independently.

In the builder, Config → Export YAML → YAML organization selects Single YAML or One YAML per flow. Filename controls and per-file previews use the same graphs and forms; changing organization does not remove flows. Downloads preserve the selected layout and become a ZIP when there is more than one flow or more than one document. A configured replay document is included in that ZIP.

Config → Import YAML → Files / ZIP reopens a ZIP, selected YAML files, or a configuration directory. Select the entry YAML when more than one candidate is present. Flat file selection can lose directory paths; use the attachment controls to map a selected file to a missing reference. Complete file imports replace the workspace only after syntax and layout checks succeed. Semantic validation issues remain editable. Comments and formatting are regenerated, while configuration values and assigned filenames are retained. Imports allow up to 1,024 files, 5 MiB per YAML document and 64 MiB total decompressed YAML.

There is no configuration hot reload or SIGHUP handler. Change flows, admin tokens and deployment bindings by restarting with the new configuration; refreshing secret references are a separate mechanism described below.

Startup parses and validates the config before connecting to its destinations or serving traffic. The file defines flows:. Invalid YAML, unknown fields, missing required values, invalid durations, duplicate or overlapping flow paths, and unsupported backend/auth types stop the app with field-level errors and suggestions.

Use hookway validate to check without connecting to dependencies. Supported risk choices produce actionable warnings, including unlimited request admission, universal proxy trust, known sample credentials, and weaker acknowledgement policies. Ordinary validation accepts these choices; --strict fails on warnings as an optional policy gate. --no-env checks templates without reading secret references or expanding environment values. See CLI validation for the complete policy and JSON missing-environment behavior. Local HTTP, private destinations, explicit no_auth, and proxy-terminated TLS remain valid operator choices.

Example validation output:

config validation failed
- server.addr: listen address is required Suggestion: set server.addr to a host:port such as ":8080"
- flows.github.nodes[2].secret: secret is required Suggestion: set secret or secret_ref

Server

Admission rejection closes an HTTP/1 connection with an unread body so a slow sender cannot delay the 503 while the HTTP server tries to drain it for reuse. Healthy keep-alive connections are unchanged. Admission limits executions, not all accepted TCP connections; size proxy/OS connection limits independently. See resource limits for combined time and memory budgets.

HTTP endpoints and readiness

EndpointPurpose
Configured flow pathsWebhook ingress; methods come from the trigger
GET /healthz, GET /healthProcess liveness; not destination readiness
GET /ready, GET /readyzCached per-flow readiness
GET /metricsPrometheus when enabled, on the configured listener/path

An unready protection store makes readiness return 503 globally. Otherwise, readiness is reported per flow: a flow is ready when all destinations referenced by its publish nodes are ready. Each unique destination is probed once per interval.

By default the HTTP status is 200 while at least one flow is ready. Set server.readiness_require_all: true to require every flow to be ready. An async webhook destination becomes unready at 90% queue capacity. Liveness stays healthy even when destinations are unavailable.

Readiness responses use the probe cache rather than contacting destinations on every HTTP request. See Upgrades for readiness during termination.

Successful webhook response:

{
  "status": "accepted",
  "endpoint": "github_events",
  "destinations": [
    {"destination_queue_id": "primary", "queue": "github-webhooks"}
  ],
  "event_id": "018f...",
  "request_id": "req_..."
}

endpoint is the flow name (compatibility field). destination_queue_id is the destinations: map key; queue is the publish target. A respond node can replace this envelope with an explicit HTTP status and body (sync proxy).

Secret references

auth.secret_ref is an alternative to inline auth.secret. Exactly one of secret or secret_ref is required. The resolver is cached and refreshed on a timer — never fetched per request.

- id: auth
  type: auth
  scheme: hmac_sha256_hex
  secret_ref: {provider: env, name: WEBHOOK_SECRET, refresh: 15m, overlap: 5m}
  signature_header: X-Hub-Signature-256

A refresh failure keeps serving the last known-good value, emits hookway_secret_refresh_failures_total, and logs at error. AWS credentials use the default SDK chain; AWS_ENDPOINT_URL targets LocalStack.

Security

security:
  trusted_proxy_headers: true
  trusted_proxies:
    - "10.0.0.0/8"
    - "192.168.0.0/16"
  request_id_header: "X-Request-ID"
  redacted_header_denylist:
    - "Authorization"
    - "Cookie"
    - "Paddle-Signature"
    - "X-Hub-Signature-256"
  # Optional fail-safe: forward only these headers to consumers.
  forwarded_header_allowlist:
    - "Content-Type"
    - "X-Request-ID"
  # Inbound hop rejection is a loop_guard node, not this block.
  # loop_protection:
  #   enabled: true
  #   max_hops: 3

security.api_key

Optional shared API key protecting the metrics endpoint and the config API (hookway editor). When set, callers must send Authorization: Bearer <key>. When empty or omitted, those endpoints are unauthenticated. The value may be an ${ENV} reference (expanded on config load), e.g.:

security:
  api_key: "${HOOKWAY_API_KEY}"

Loop protection

A webhook destination can point back at a Hookway path (directly, or through a chain of Hookways), creating a delivery loop that would amplify traffic without bound. Hookway detects this with a hop counter carried in the X-Hookway-Hops header:

security.loop_protection is accepted YAML (and max_hops is still validated) but is not wired to the serve path. Do not rely on it for inbound enforcement.

Because the hop count increments per Hookway hop (not per loop iteration), a tight self-loop is broken after at most max_hops iterations, and an intentional finite chain of up to max_hops Hookways still works. A malformed or negative header value counts as 0, so it can only reset the counter, never bypass the limit. The header is always stamped on outbound webhook deliveries even when a given flow has no loop_guard, so a downstream Hookway can still detect the loop.

This mechanism relies on the header surviving each hop; a loop that transits an intermediary which strips unknown headers will not be caught. Rejections are counted by hookway_loop_rejected_total (the flow name is the endpoint label) and logged at Warn.

Rate Limit

There is no process-global rate_limit: block. Put a rate_limit node in each flow (typically before read_body). See Flows § Trigger, gates, and body. A flow without one is a lint warning.

Metrics

When disabled, the metrics route is not registered.

Metrics include HTTP request counts, auth failures, rate limits, load-shedding saturation (hookway_saturated_total), filter drops (hookway_filtered_total), loop rejections (hookway_loop_rejected_total), publish latency, RabbitMQ reconnect/confirm/retry/DLQ/unroutable (hookway_rabbitmq_unroutable_total) counters, config load failures, and — when runtime_metrics is enabled — Go runtime and process CPU/memory metrics. Startup config failures still block serving traffic, so config failure metrics are recorded in-process before exit and are most useful for tests or future reload paths.

Some of the less obvious series, which are easy to miss when building a dashboard or an alert:

MetricTypeLabelsDescription
hookway_build_infogaugeversion, commitAlways 1. The build the process came from, as injected by -ldflags (dev/none for a plain go build). Join on it to attribute a regression to a release.
hookway_request_body_byteshistogramendpoint, destination_queue_id, queueInbound webhook body size. Buckets run 128 B → 1 MiB. Use it to size read_body.max_bytes from observed traffic rather than guessing.
hookway_variable_lookup_totalcounterendpoint, variable, provider, resultLookup-source variable resolutions. result is hit, miss, cached, or error.
hookway_variable_lookup_duration_secondshistogramendpoint, variable, providerLatency of those resolutions, cache hits included.
hookway_variable_unresolved_totalcounterendpoint, variable, actionRequests whose routing variables could not be resolved, by what happened next: rejected or fallback. A rising rejected rate means traffic is being dropped on a config gap.
hookway_compensation_failed_totalcounterflowReplay-reservation release failures. Non-zero is worth an alert: a silent failure leaves the reservation in place until its TTL, so a legitimate sender retry gets a 409 and the event is lost.
hookway_rabbitmq_confirms_totalcounter—Publisher confirms received from RabbitMQ.
hookway_rabbitmq_retries_totalcounter—RabbitMQ publish retries. Both are process-wide, with no destination label.

Dashboards and interpretation

Enable metrics explicitly and, when needed, Go/process collectors. Import hookway-overview.json into Grafana and select your Prometheus data source, job, instance and flow. Import the separate hookway-replay.json for replay. Scrape each replica's private metrics listener with the appropriate credential.

The core dashboard includes HTTP/publication latency and counts, bounded/unlimited admission mode, executing capacity/utilization, waiting depth/outcomes/duration, flow outcomes, routing/registration results, credential operations and batch child outcomes. Node execution, lookup, secret refresh, capture/redrive and runtime panels remain available. Flow-level panels do not apply destination/queue filters; those labels are not present on all request or node metrics. Routing/credential/batch panels use their configured node/profile labels and have no flow label.

These metrics do not poll SQS or report broker DLQ activity. Existing optional Prometheus alert templates can be used with your monitoring stack; additional alerting or DLQ instrumentation is not a requirement for ingress deployment. See OpenTelemetry spans for the separate tracing boundary.

OpenTelemetry spans

internal/httpapi/flowserver.go holds a package-level otel.Tracer("hookway") and starts one span per executed graph node, named hookway.node.<node_type> (hookway.node.auth, hookway.node.publish, …). Each span is timestamped to the node's real start and end, records the node's error and sets an error status when one occurred, and carries five attributes:

AttributeValue
hookway.flowflow name
hookway.node_idnode id from the config
hookway.node_typenode type
hookway.request_idrequest id
hookway.event_idenvelope event id

Two facts matter before you plan around this:

To get traces out you must register your own tracer provider and exporter in your own build. Hookway does not claim OpenTelemetry support; it propagates traceparent end to end (see the envelope reference below) and emits spans into whatever global provider happens to be installed.

Replay

The optional replay: block connects core to the replay service (hookway-replay), which stores failed deliveries for operator-initiated retry and view-only webhook inspections. Without the block, failure capture and the control listener are off. The service itself is documented in replay-service.md.

replay:
  url: "https://replay.internal"           # the replay service's API (plain HTTP unless a proxy or mesh terminates TLS)
  capture_token: "${REPLAY_CAPTURE_TOKEN}" # a replay key with the capture scope
  max_record_bytes: 16777216               # per artifact; larger → metadata-only record
  tls: {ca_cert: "", client_cert: "", client_key: "", server_name: ""}
  control:
    addr: ":9092"                          # private; must differ from server.addr and metrics.addr
    token: "${REPLAY_CONTROL_TOKEN}"
    max_concurrent: 8

Core records a failure only when it acknowledged the request (final status 2xx) and no on_error or dead_letter path handled it — in practice a publish (or a success continuation after it) that failed under delivery_failure_policy: skip_failed, or an async webhook delivery that failed for good — retries exhausted or a terminal 4xx — with no dead letter, or whose dead-letter dispatch failed. An async failure waits for its request's final status before it is kept. A flow opts out with capture_failures: false.

Each failure record carries metadata (flow, node, destination, sanitized error, attempts, config hashes), a snapshot of the failed node's input, and the payload the node was sending. A snapshot or payload over max_record_bytes, or one that cannot be reproduced exactly, makes the record metadata-only and not retryable.

Uploads are best effort and never touch a request: four workers, at most 128 records and 64 MiB buffered, at most three attempts within five seconds. A record that cannot be kept is dropped, logged at Error with its event_id, flow, and node_id, and counted. Replay being slow or down never changes a response or readiness. Both tokens must be at least 32 characters and must differ; ${VAR} references are accepted by validate without expansion. tls sets the client TLS for uploads (client_cert and client_key together for mutual TLS).

The control listener (replay.control.addr) serves one route, POST /internal/replay/v1/deliver, for the replay service's workers; it is never part of readiness. Every request needs Authorization: Bearer <control.token>. Core reads the whole request before it starts, and answers 503 with code not_started — the only answer after which replay resends — when it is shutting down or already running max_concurrent redrives. A record the current configuration can no longer run is refused with 422 and a block reason (unsupported_snapshot_version, hash_mismatch, flow_missing, flow_broken, node_missing, node_type_changed, destination_changed, target_not_configured), and nothing is sent. Otherwise core resumes the flow at the failed node (or re-sends a publish_only message) with inline delivery, under the deadline replay sends, and reports each node's outcome; downstream failures come back as child records. A redrive never captures, dead-letters, or runs compensations. At shutdown readiness first withdraws for server.shutdown_delay, then every listener stops accepting. The control listener answers 503 not_started to requests still arriving and waits for in-flight redrives before publishers close. The redrive execution deadline and server.shutdown_total_timeout bound this wait. Budget the total for withdrawal, the longer of request/control drain, sequential publisher drains, and capture cleanup. Set the platform termination allowance longer than the total budget; see Upgrades and rollback.

MetricLabelsDescription
hookway_capture_totalflow, outcomeCaptured failures by outcome: uploaded or dropped.
hookway_capture_dropped_totalreasonRecords that never reached replay: buffer_full, upload_failed (three attempts or five seconds spent), capacity (replay answered 507), rejected (replay refused the record, or core could not build it), shutdown.
hookway_inspection_totalflow, outcomeView-only inspections by upload outcome: uploaded or dropped.
hookway_inspection_dropped_totalreasonInspection uploads dropped by the same bounded-uploader reasons as failure capture. Captured content is never logged.
hookway_capture_buffer_bytes—Shared failure/inspection bytes reserved during requests or waiting for upload, including in-flight uploads (gauge).
hookway_control_runs_totalflow, outcomeDeliver requests answered: succeeded, failed (the resumed node failed), blocked (422), not_started (503), rejected (401/4xx), error (500). flow is a configured flow or unknown.
hookway_control_inflight—Redrives running now (gauge).

Connections

connections is an optional top-level map of named credentials keyed by stable id. A connection holds everything needed to reach and authenticate a backend; a destination that references it keeps only its own sink — the bucket, table, topic, or key prefix it writes to. One AWS account can then serve an s3, an sns, an sqs, and a lambda destination from a single declaration, and rotating the credential is one edit in one place.

connections:
  aws_prod:
    type: aws
    region: us-east-1
    access_key_id: "${AWS_ACCESS_KEY_ID}"
    secret_access_key: "${AWS_SECRET_ACCESS_KEY}"

destinations:
  events_bucket:
    type: s3
    connection: aws_prod
    s3: {bucket: hookway-events, sse: AES256}
  notify:
    type: sns
    connection: aws_prod

A connection is authored flat: type sits alongside its own fields rather than above a per-type block, unlike a destination. Ids are 1–128 characters of letters, digits, underscore, or hyphen — stricter than a destination id, which also permits ., because validation issue paths are resolved segment by segment and a dot makes them ambiguous.

Connections are entirely optional and purely additive. Every destination can still be configured inline, which is what examples/destinations/*.yaml do.

Connection types

typeOwnsServes destination typesDestination keeps
awsregion, endpoint, access_key_id, secret_access_keys3, sqs, sns, lambdas3: bucket, create, sse, sse_kms_key_id · lambda: invocation_type · sqs/sns: nothing
redismode, url, addrs, master_name, username, password, db, tlsredis_stream, redis_list, redis_pubsub, redis_kvkey_prefix
postgresurl, max_conns, tlspostgresschema, table
kafkabrokers, sasl, tlskafkaacks, compression, publish_timeout, retry
mongodburi, max_pool_size, tlsmongodbdatabase
rabbitmqurl, publisher_channels, tlsrabbitmqexchange, exchange_type, durable, publish_timeout, declare_queues, dead_letter, queue_defaults, retry
temporalhost_port, namespace, api_key, connect_timeout, tlstemporalstart_timeout

key_prefix stays on the destination on purpose, so two redis destinations can share one server with different prefixes.

There is no webhook connection type. It would carry an auth header and TLS material but no host, so unlike every other type the credential would not travel with the address of the thing it authenticates to — one profile could be attached to destinations pointing at different vendors. Shared outbound webhook auth is an auth profile rather than a connection, and destinations.<id>.webhook.auth remains per-destination.

tls.enabled exists only on redis, kafka, and temporal. For postgres, mongodb, and rabbitmq, transport security is implied by the connection string (sslmode, mongodb+srv, amqps://) and the tls block carries only custom CA and mutual-TLS material.

The split is strict

A field belongs either to the connection or to the destination, never both. Setting a connection-owned field on a destination that also names a connection is a validation error:

destinations.events.s3.region: region is provided by connection "aws_prod" and
must not be set on the destination

This is deliberate: an override-merge would leave "which of these two places did this value come from" as a question you have to answer by reading both. Validation of these bindings runs against the YAML document rather than the decoded config, so a field written as an explicit zero — redis.db: 0, tls.insecure_skip_verify: false, postgres.max_conns: 0 — is caught rather than mistaken for an omitted one.

An error in a value the connection supplied is reported at the path you wrote, with the destination that pulled it in named for context:

connections.aws_prod.region: region is required for s3 destinations
(via destinations.events.connection)

A connection defined but referenced by nothing is reported at severity warning, so it does not block hookway validate or startup.

Resolution and rotation

Connections are resolved once, at load time, exactly like inline config. The merged result is an ordinary destination, so nothing about publishing, readiness, or retry behaviour changes.

That means rotating a destination credential still requires a restart. connections changes where a secret lives, not when it is read. Inbound auth and managed HTTP credentials support live rotation, via auth.secret_ref and its background refresh and overlap window; there is no equivalent for destinations, because the backend clients (pgxpool, Kafka writers, Redis clients, AMQP connections) capture their credentials at construction.

Secrets in a connection are written the same way as anywhere else: ${ENV_VAR} references, expanded at load. Note that expansion runs over the whole file as text before it is parsed, so a ${VAR} written inside a comment still counts as a reference.

Blast radius

Consolidating credentials narrows the number of places to look, and widens what a single leaked block exposes. Before, a leaked destinations.archive block exposed one bucket; afterwards a leaked connections.aws_prod exposes every bucket, queue, topic, and function that account can reach. That is the trade the feature makes deliberately. It does not change limitation L3 in Invariants — one process still holds one set of destination credentials — but it does make the full set enumerable in one block, which is what you want when deciding whether to split a deployment by trust domain.

Destinations

destinations is a top-level map of backends keyed by stable id. Each entry may either configure its backend inline (every example under examples/destinations/ does) or set connection: <name> to take its credentials from a connection and keep only its sink fields. A flow publish node names one of these ids and a target (queue, topic, routing key, stream, collection, object prefix, Temporal task queue, or Lambda function name/ARN). Per-publish options include create, message_attributes, payload, and SQS/SNS-specific wire formats, grouping, FIFO identities, retries and resource attributes. See Ingress adapters for those contracts. Unknown fields such as sqs_options, sns_options, queue_options or kafka_options still fail validation. Supported types: rabbitmq, sqs, sns, s3, lambda, redis_stream, redis_list, redis_pubsub, redis_kv, postgres, mongodb, kafka, webhook, and temporal. See examples/destinations/ for a complete reference config per type.

RabbitMQ

RabbitMQ fields live under destinations.<id>.rabbitmq:

Every publish is sent mandatory: if the exchange has no queue bound for the routing key, the broker returns the message instead of silently dropping it. Hookway surfaces this as a Publish error (so the inbound webhook request gets a non-2xx response rather than an incorrect 202) and increments hookway_rabbitmq_unroutable_total{destination_queue_id, queue}. This is treated as a terminal configuration problem — a missing queue binding — so it is not retried.

      retry:
        max_attempts: 3
        initial_backoff: "100ms"
        max_backoff: "2s"
destinations:
  billing:
    type: "rabbitmq"
    rabbitmq:
      url: "${BILLING_RABBITMQ_URL}"
      exchange: "hookway.billing"
      exchange_type: "direct"
      durable: true
      publish_timeout: "5s"
      declare_queues: true
      queue_defaults:
        message_ttl: "24h"
        max_length: 100000
        overflow: "reject-publish"
      dead_letter:
        enabled: true
        exchange: "hookway.billing.dlx"
        exchange_type: "direct"
        queue: "hookway.billing.dlq"
        routing_key: "webhooks.dead"
        durable: true
        create_queue: true
  audit:
    type: "rabbitmq"
    rabbitmq:
      url: "${AUDIT_RABBITMQ_URL}"
      exchange: "hookway.audit"
      exchange_type: "direct"
      durable: true
      publish_timeout: "5s"
      declare_queues: true

Dead-letter fields:

AWS SQS

SQS fields live under destinations.<id>.sqs. Credentials are optional: when both access_key_id and secret_access_key are set they are used as static credentials; otherwise the default AWS credential chain (env, IAM role, shared profile) applies.

Standard and FIFO queues are supported. A .fifo target requires publish.fifo identities; standard queues may use publish.group_id for fair grouping and publish.sqs.delay_seconds. Use publish.resource_attributes for supported queue attributes. See broker options and the FIFO example.

destinations:
  jobs:
    type: "sqs"
    on_existing_conflict: "reconcile"
    sqs:
      region: "us-east-1"
      # endpoint: "${AWS_ENDPOINT}"          # http://localhost:4566 for LocalStack
      # access_key_id: "${AWS_ACCESS_KEY_ID}"
      # secret_access_key: "${AWS_SECRET_ACCESS_KEY}"

endpoint, access_key_id, and secret_access_key are optional: to use the default AWS credential chain, omit them entirely rather than referencing an env var and leaving it unset — ${ENV_NAME} references require the variable to be set (empty is fine; unset is a config-load error, see Secrets).

AWS SNS

SNS fields live under destinations.<id>.sns and mirror SQS (region, endpoint override, optional static credentials):

Standard and FIFO topics are supported. A .fifo target requires publish.fifo; publish.sns supplies an existing topic ARN, subject or protocol JSON structure as applicable. See broker options.

AWS Lambda

Lambda fields live under destinations.<id>.lambda. Hookway invokes each publish target with the JSON Message envelope as the event payload. Credentials mirror SQS/SNS.

The publish target is the AWS FunctionName: a function name, name:alias / name:version / the LATEST qualifier, or a function ARN. One destination (region + credentials) can invoke many functions. create: true is rejected — Hookway does not create functions. payload: object is not supported (envelope JSON only). message_attributes are ignored. on_existing_conflict is not valid for lambda.

IAM: lambda:InvokeFunction on each target; lambda:GetFunction for /ready probes of static (non-template) targets. Dynamic {variable} targets skip the startup probe.

Function return values are not wired into later graph nodes — a publish success edge forwards the input envelope, not the Lambda response. See examples/destinations/lambda.yaml. For multipart uploads, chain S3 then Lambda with a publish success edge (examples/multipart-s3-lambda.yaml): store bytes with publish.payload: object on S3, then Event-invoke Lambda with metadata after PutObject returns.

destinations:
  workers:
    type: "lambda"
    lambda:
      region: "us-east-1"
      # endpoint: "${AWS_ENDPOINT}"
      # access_key_id: "${AWS_ACCESS_KEY_ID}"
      # secret_access_key: "${AWS_SECRET_ACCESS_KEY}"
      invocation_type: "Event"   # or RequestResponse

AWS S3

S3 fields live under destinations.<id>.s3. By default Hookway writes each webhook event as a single JSON Message (Content-Type: application/json). Set publish.payload: object to store decoded file bytes instead (see Publish payload modes). Credentials are optional: when both access_key_id and secret_access_key are set they are used as static credentials; otherwise the default AWS credential chain (env, IAM role, shared profile) applies.

Object key layout (envelope, default): the publish target is the base folder (key prefix). Each event is stored at:

<target>/yyyy/mm/dd/<timestamp>-<id>.json

Object key layout (payload: object): same date partition, but the leaf name uses the sanitized vendor filename (when present) and no .json suffix:

<target>/yyyy/mm/dd/<timestamp>-<id>[-<filename>]

S3 user metadata includes hookway-event-id and, when available, hookway-filename. Content-Type is taken from the file object's content_type (or application/octet-stream).

The date partition and timestamp are derived from the event's ReceivedAt in UTC. The timestamp is colon-free so the key is usable in all contexts. Example with target: "orders":

orders/2026/06/29/20260629T143000.000000000Z-evt_1.json

on_existing_conflict: accepted for config parity but is a NO-OP for S3. S3 has no mutable per-target attributes to reconcile; the bucket is verified at startup via HeadBucket but is never mutated by this policy.

destinations:
  events:
    type: "s3"
    on_existing_conflict: "reconcile"     # accepted but no-op for S3
    s3:
      region: "us-east-1"
      # endpoint: "${AWS_ENDPOINT}"       # http://localhost:4566 for LocalStack
      # access_key_id: "${AWS_ACCESS_KEY_ID}"
      # secret_access_key: "${AWS_SECRET_ACCESS_KEY}"
      bucket: "my-webhook-events"
      create: true
      sse: "AES256"                       # or "aws:kms"; omit for default encryption
      # sse_kms_key_id: "${S3_KMS_KEY_ID}"

Postgres

Postgres fields live under destinations.<id>.postgres. Hookway inserts each webhook event as one row into a single shared table using jackc/pgx/v5 (pgxpool). The table is created unconditionally at startup with CREATE TABLE IF NOT EXISTS; no per-publish create flag is used.

on_existing_conflict is not valid for Postgres destinations — the field is rejected at config validation time. Do not set it on postgres entries.

Table schema: at startup Hookway runs CREATE TABLE IF NOT EXISTS with fixed columns for the Message envelope:

ColumnTypeNotes
idtextEvent id; PRIMARY KEY
targettextPer-route target value (not a table name)
idempotency_keytextNullable; ingest leaves this empty. Sender retries require a stable sender event ID for deduplication
source_iptextClient IP (nullable)
methodtextHTTP method
pathtextRequest path
received_attimestamptzUTC receive time
headersjsonbHTTP headers (nullable)
queryjsonbQuery parameters (nullable)
bodyjsonbRequest body (nullable)
authjsonbAuth metadata (nullable)

Two indexes are also created IF NOT EXISTS: one on target and one on received_at.

target as a column: the publish target is stored in the target column rather than selecting a different table. Use it to distinguish event sources when multiple flows share the same destination.

Deduplication: inserts use INSERT ... ON CONFLICT (id) DO NOTHING. Repeated delivery of the same Hookway envelope is ignored. Each new inbound request receives a new Hookway ID, so sender retries can insert another row. Deduplicate sender retries using a stable sender event ID in your application.

Pre-existing table caveat: CREATE TABLE IF NOT EXISTS is a no-op against an existing table. If the table already exists with an incompatible schema, inserts will fail at runtime. Hookway does not migrate columns — ensure the table matches the expected schema or drop and recreate it before starting.

destinations:
  warehouse:
    type: "postgres"
    postgres:
      url: "${POSTGRES_URL}"           # libpq DSN; sslmode honored inline
      schema: "public"                 # optional, default "public"
      table: "webhook_events"          # required
      max_conns: 0                     # optional; 0 = pgx default
      # tls:
      #   ca_cert: "/etc/ssl/certs/pg-ca.pem"
      #   client_cert: "/etc/ssl/certs/pg-client.pem"
      #   client_key: "/etc/ssl/private/pg-client.key"
      #   server_name: "pg.internal"
      #   insecure_skip_verify: false

MongoDB

MongoDB fields live under destinations.<id>.mongodb. Hookway inserts each webhook event as one BSON document into a collection within the configured database using go.mongodb.org/mongo-driver/v2.

on_existing_conflict is not valid for MongoDB destinations — the field is rejected at config validation time. Do not set it on mongodb entries.

Collection-per-target: the publish target names the MongoDB collection within database. Use different targets to send events from different flows into separate collections.

Document shape: each event is stored as one BSON document. The stored document carries the same envelope fields (_id, endpoint, received_at, headers, query, body, auth) that other destinations emit, stored as native BSON — top-level scalars map to BSON string/datetime types and nested objects (headers, query, body, auth) become nested BSON documents.

No DDL: Hookway does not create collections or indexes at startup. Collections auto-create on first insert; _id is automatically indexed by MongoDB, so deduplication works with no setup required.

Deduplication: inserts use InsertOne. A duplicate _id (MongoDB E11000 duplicate key error) is treated as a no-op success. This deduplicates internal retries of the same Hookway envelope, not fresh sender requests, which get new IDs. Use a stable sender event ID for application-level deduplication.

destinations:
  archive:
    type: "mongodb"
    mongodb:
      uri: "${MONGODB_URI}"         # mongodb:// connection string
      database: "webhooks"          # required; collection comes from route target
      max_pool_size: 0              # optional; 0 = driver default
      # tls:
      #   ca_cert: "/etc/ssl/certs/mongo-ca.pem"
      #   client_cert: "/etc/ssl/certs/mongo-client.pem"
      #   client_key: "/etc/ssl/private/mongo-client.key"
      #   server_name: "mongo.internal"
      #   insecure_skip_verify: false

Kafka

Kafka fields live under destinations.<id>.kafka. Hookway publishes each webhook event as one message to a topic using segmentio/kafka-go.

Security note: omitting both tls.enabled and sasl connects to the brokers in plaintext with no authentication. Hookway logs a startup warning in this case; use it only for local development.

target is the topic. The publish target is the Kafka topic name. Setting create: true on that publish node makes Hookway pre-create the topic via an admin CreateTopics call at startup (idempotent — an already-existing topic is left as-is). This replaces relying on broker-side auto-creation, which raced the first publish. Omit create: true when topics are pre-provisioned; create: false (or omitted) targets must already exist on the broker.

Partition key / topic creation options: kafka_options (partition_key, partitions, replication_factor) are not fields on the publish node. Messages are produced without a key (round-robin partitions) and topic create uses the broker defaults. Put a dedicated destination per topic when you need different destination-level Kafka settings.

See examples/destinations/kafka.yaml for a complete reference config.

destinations:
  events:
    type: "kafka"
    kafka:
      brokers:
        - "${KAFKA_BROKER_1}"
        - "${KAFKA_BROKER_2}"
      acks: "all"
      compression: "snappy"
      publish_timeout: "5s"
      sasl:
        mechanism: "scram-sha-256"
        username: "${KAFKA_USERNAME}"
        password: "${KAFKA_PASSWORD}"
      tls:
        enabled: true
      retry:
        max_attempts: 3
        initial_backoff: "100ms"
        max_backoff: "2s"

Temporal

Temporal fields live under destinations.<id>.temporal. Hookway starts one workflow execution per webhook event using the official Temporal Go SDK. It only starts workflows (no signals, no worker of its own); your existing workers on the task queue pick the execution up.

Security note: omitting both tls.enabled and api_key connects to the frontend in plaintext with no authentication. Hookway logs a startup warning in this case. The published image is built FROM scratch, so certificate paths must be mounted into the container.

target is the task queue. The publish target is the Temporal task queue name. create: true is rejected; workers create task queues by polling. Task queue names must not end in .fifo.

Workflow settings: set publish.temporal.workflow_type to your registered worker workflow type. The standalone server and builder support the same fields:

Validation rejects missing workflow types, unsupported inputs/policies, invalid timeouts, use on another destination type, and conflicting settings on a shared destination/task queue.

Duplicates count as delivered. When the server reports that an execution with the derived workflow id already exists, Hookway logs it at debug level and returns success, so sender retries with a deterministic workflow id are idempotent (first-start-wins).

Failure handling. There is no dead-letter path for this destination. Any other start error (frontend unreachable, start_timeout exceeded, unknown namespace, rejected start) fails the publish: under the default stop_on_failure policy the sender receives a 503 and is expected to retry; under skip_failed the route is marked failed and the other routes still deliver. The SDK retries transient gRPC errors internally within start_timeout. A stopped worker is not a failure: the start succeeds and the execution waits on the task queue.

Readiness. The destination's readiness probe calls the frontend gRPC health service; a proxy that blocks it will report the destination unready even though publishes succeed.

Payload encoding. Inputs are encoded with the SDK's default JSON data converter. Clusters that require a custom payload codec (for example, encryption at rest) are not supported yet.

A temporal destination cannot be used as a webhook dead_letter target because a dead-letter route has no workflow_type.

See examples/destinations/temporal.yaml for a complete reference config.

destinations:
  orchestrator:
    type: "temporal"
    temporal:
      host_port: "${TEMPORAL_ADDRESS}"
      namespace: "${TEMPORAL_NAMESPACE}"
      connect_timeout: "10s"
      start_timeout: "5s"
      tls:
        enabled: false

flows:
  order_events:
    nodes:
      - {id: in, type: http_trigger, path: /webhooks/orders, methods: [POST]}
      - {id: body, type: read_body, max_bytes: 1048576}
      - {id: auth, type: auth, scheme: hmac_sha256_hex, secret: "${WEBHOOK_SECRET}", signature_header: X-Hub-Signature-256}
      - {id: env, type: envelope, body_format: json}
      - id: out
        type: publish
        destination: orchestrator
        target: order-events
        temporal:
          workflow_type: OrderWorkflow
          input: body
          workflow_id: {source: body, path: id, prefix: order-}
          id_reuse_policy: reject_duplicate
    edges: [in>body, body>auth, auth>env, env>out]

Redis / Valkey

Hookway supports four Redis destination types. All share a common redis: connection block on the destination. redis_options (max_len, consumer_group, ttl, key_template) are not fields on the publish node.

Available types:

TypePrimitiveNotes
redis_streamXADD to a Stream — durable; consumers may XREADGROUPRecommended for at-least-once delivery. YAML has no max_len / consumer_group, so Hookway does not trim or create groups
redis_listLPUSH to a List — FIFO work queueSimple competing-consumer queue. YAML has no max_len, so lists are unbounded unless consumers trim
redis_pubsubPUBLISH to a channel — live broadcast; lossy, not durableFire-and-forget; subscribers offline at delivery time miss the message
redis_kvSET a key per webhook — storage, not a queueLatest-event overwrite. Default key {prefix}:{endpoint}:{id}. YAML has no ttl, so keys do not expire. publish.payload: object stores decoded file bytes instead of the JSON Message

redis_stream is the recommended type for at-least-once webhook handoff. redis_pubsub is intentionally lossy — if no subscriber is connected at publish time, the message is silently dropped; use it only for live broadcast scenarios where occasional loss is acceptable.

Valkey is fully supported: it speaks the RESP protocol and behaves identically to Redis.

Shared redis: connection block — configure once on the destination, reused by every publish that targets it:

destinations:
  events:
    type: redis_stream
    redis:
      mode: single
      url: "${REDIS_URL}"   # redis:// or rediss://
      # Cluster example:
      # mode: cluster
      # addrs: ["redis-1:6379", "redis-2:6379", "redis-3:6379"]
      # Sentinel example:
      # mode: sentinel
      # addrs: ["sentinel-1:26379", "sentinel-2:26379"]
      # master_name: "mymaster"
      key_prefix: "hookway"   # optional

See examples/destinations/redis_stream.yaml for a full annotated reference with TLS and cluster/sentinel comments.

Best practices for redis_stream:

Webhook

Webhook fields live under destinations.<id>.webhook. Hookway delivers each inbound webhook event to a configured HTTP/HTTPS endpoint as an outbound HTTP POST.

Webhook deliveries do not follow HTTP redirects, including same-origin redirects. Configure the receiver's canonical URL so credentials, signatures and original payload bytes stay at the selected endpoint. A 3xx is a terminal delivery failure, not acceptance by the receiver. This applies to sync, async and proxy payload modes; intentional literal localhost/private HTTP endpoints remain valid.

If url contains a {variable} in its authority, its receiver is partly chosen by request data. Such deliveries reject loopback, private, link-local, metadata, and other blocked addresses by default. DNS is checked at every new connection and the dial is pinned to a vetted IP; changing a DNS answer cannot redirect a new connection to a forbidden address. Guarded transports do not use environment forward proxies and do not share their connection pool with unguarded ones. For an intentional internal templated receiver, set destinations.<id>.webhook.allow_private_targets: true. This exception applies only to the named destination and never relaxes DNS-label substitution, safe path values, outbound auth, or redirect refusal. Literal operator-selected hosts, including environment-expanded literal hosts and URLs with only path variables, retain their existing local/private networking support.

Delivery mode (delivery_mode): async (default) or sync.

Per-destination delivery engine and worker pool (async): each webhook destination has its own delivery engine (goroutine pool + queue), so a slow or failing target cannot head-of-line-block deliveries to other destinations. The number of concurrent delivery workers per engine is controlled by server.fanout_concurrency. Two independent limits apply: max_queue bounds queued jobs (per-destination, default 1000), and max_queue_bytes bounds conservative accounted bytes for queued and active jobs (default 64 MiB). Either limit can reject publication before async acceptance; with default response policies, the sender receives a non-2xx response and can retry. Readiness (/readyz) reports a webhook destination as not-ready at ≥90% of either limit. Sync/inline delivery does not use the async queue limits.

The byte budget includes retained raw/object/encoded payloads, headers and parsed values with JSON/base64 headroom; it is not exact wire size or a hard heap cap. Jobs remain charged through retries, dead-letter publication and terminal callbacks. Aliases are conservatively charged again; cycles and excessive structure fail boundedly. A job too large for the remaining budget uses the existing queue-full error, without truncation or partial acceptance. Raise a finite limit deliberately for legitimate large files, and reserve additional container memory for parser, allocator, transport and capture overhead. See resource limits and sizing.

Graceful drain on shutdown (async): on SIGTERM / shutdown, Hookway attempts to drain in-flight and queued deliveries within drain_timeout (defaults to server.shutdown_timeout). A completed drain avoids abandoning queued work; an expired deadline, forced termination, or hard crash (kill -9, OOM, host failure) can lose acknowledged queued or in-flight deliveries. Neither dead_letter nor replay makes this memory queue durable: both need the live process to observe and forward a failure. Use broker-confirmed publication when accepted work must survive a core crash. See Delivery guarantees and recovery.

NON-DURABLE by default: the delivery queue is in-memory only. There is no write-ahead log or persistent queue. A dead_letter destination can retain observed terminal failures after its own publish succeeds. To survive a core crash after acknowledgement, publish directly to a durable broker configured with the required persistence and replication. Broker acceptance does not guarantee downstream processing or eliminate duplicate delivery.

For a webhook destination, publish.target: "" uses the exact configured URL without appending a path (including its query string). Other destination types require a nonempty resource target. See Sentry to Discord for an async notification example using wire_body: json.

Dead-letter (dead_letter, async only): when an async delivery fails terminally — a terminal 4xx, or 5xx/timeout/error after retries are exhausted — Hookway can redirect the message to another destination instead of dropping it (log-and-drop is the default when no dead_letter is set). The redirected message is the original envelope enriched with a dead_letter block (destination, target, outcome, attempts, failed_at). The referenced destination MUST exist and MUST NOT itself be a webhook destination — this makes delivery loops structurally impossible (no cycle-detection needed). The dead-letter target queue/topic is auto-collected into the backend destination (and declared for RabbitMQ when declare_queues: true) even if no publish node targets it directly.

Not a transparent proxy (async webhook destination): Hookway does not return the downstream HTTP response to the inbound sender. With default response policies, async mode returns Hookway's own 202 Accepted (queue acceptance) or an error. Sync mode also returns Hookway's status, not the downstream body. Use http_request and a passthrough respond node to relay the upstream response.

For a synchronous reverse proxy of the inbound request (upstream status, headers, and body), use http_request plus respond passthrough: true — see Flows and examples/showcase/proxy.yaml — not a webhook destination.

Fields

on_existing_conflict is not valid for webhook destinations — the field is rejected at config validation time. Do not set it on webhook entries.

Retry classification

HTTP status / errorBehavior
2xxSuccess — delivery complete
3xxTerminal — redirect is not followed; use the canonical receiver URL
429Retry with backoff
5xxRetry with backoff
Network error / timeoutRetry with backoff
Other 4xx (400, 401, 403, 404, …)Terminal — no retry; dead-lettered if configured, else dropped

Retries use exponential backoff (doubling, +/-20% jitter, capped at retry.max_backoff) — jitter keeps many destinations that all start failing at once (e.g. a shared downstream outage) from retrying in lockstep. A 429 response with a valid Retry-After header (integer seconds or an HTTP-date, per RFC 9110) overrides the computed backoff for that wait, still capped at retry.max_backoff; an absent or unparseable header falls back to the computed backoff. After retry.max_attempts the delivery is abandoned; the final outcome is observable via hookway_webhook_delivery_total{outcome=...} and an Error log entry.

Payload modes

Envelope mode (default): the outbound POST body is the Hookway JSON Message envelope (Content-Type: application/json) containing the normalized event fields (id, endpoint, received_at, headers, query, body, auth). Used for all auth types except proxy.

Proxy mode (auth.type: proxy): Hookway forwards the original raw inbound body verbatim, preserving the original Content-Type, and copies the original inbound headers (minus hop-by-hop headers: Connection, Transfer-Encoding, Upgrade, etc.) to the outbound request. No hook_way auth headers are added — the vendor's original auth headers (e.g. X-Stripe-Signature) pass through as-is. Use proxy mode when the downstream endpoint expects to verify the vendor's original signature.

Outbound auth types

none: no authentication headers are added. Suitable for internal/trusted networks only.

api_key: sets a static header on every outbound request. Configure auth.header (the header name) and auth.value (the value, usually ${WEBHOOK_API_KEY}).

hmac (default): signs each outbound request with HMAC-SHA256. Configure auth.secret (the signing secret). Three signing schemes are supported via auth.scheme:

SchemeSignature header formatSigning string
hmac_sha256_ts_dot (default)t=<unix_ts>,v1=<hex><unix_ts>.<raw_body>
hmac_sha256_ts_colonts=<unix_ts>;h1=<hex><unix_ts>:<raw_body>
hmac_sha256_hexsha256=<hex><raw_body> (no timestamp)

The signature header name defaults to X-Hookway-Signature (override with auth.header). For hmac_sha256_ts_dot and hmac_sha256_ts_colon, Hookway also sends an event-id header (default X-Hookway-Event-Id, override with auth.event_id_header) to enable replay detection.

proxy: forwards the original inbound request's raw body and headers (minus hop-by-hop) to the downstream endpoint unchanged. No hook_way auth is added. See Payload modes above.

Consumer-side auth verification

Your downstream endpoint (the receiver) should verify incoming requests from Hookway. The verification recipe differs by auth type.

api_key: read the configured header from the incoming request and compare it to the expected value using a constant-time comparison function to prevent timing attacks.

expected = config.auth.value
received = request.headers[config.auth.header]
assert constant_time_equal(received, expected)

hmac (hmac_sha256_ts_dot / hmac_sha256_ts_colon): parse the signature header, recompute the HMAC, compare, and reject stale or replayed requests.

# Step 1: parse the signature header
# hmac_sha256_ts_dot:   "t=<ts>,v1=<hex>"
# hmac_sha256_ts_colon: "ts=<ts>;h1=<hex>"
ts, received_hex = parse_signature_header(request.headers["X-Hookway-Signature"])

# Step 2: reject stale timestamps (replay window, e.g. 5 minutes)
now = current_unix_timestamp()
assert abs(now - ts) <= 300   # seconds; tune to your tolerance

# Step 3: recompute HMAC over the RAW received bytes
# IMPORTANT: use the raw request body bytes, not a re-serialised struct
# signing_string is a BYTE sequence: ASCII-encode the timestamp integer,
# then the separator byte, then the raw body bytes — concatenated as bytes.
if scheme == "hmac_sha256_ts_dot":
    signing_string = ascii_bytes(ts) + b"." + raw_body_bytes
else:  # hmac_sha256_ts_colon
    signing_string = ascii_bytes(ts) + b":" + raw_body_bytes
expected_hex = hex(HMAC_SHA256(secret, signing_string))

# Step 4: constant-time compare — never use == on HMAC hex strings
assert constant_time_equal(received_hex, expected_hex)

# Step 5: deduplicate replayed requests (optional but recommended)
event_id = request.headers["X-Hookway-Event-Id"]
assert not seen_event_ids.contains(event_id)
seen_event_ids.add(event_id, ttl=300)

Key points:

hmac_sha256_hex: no timestamp is included in this scheme. Verify as above but the signing string is the raw body alone and there is no ts to check. For Hookway outbound deliveries, deduplicate X-Hookway-Event-Id in the downstream consumer. For inbound GitHub deliveries, use the provider's X-GitHub-Delivery identity; it is distinct from Hookway's generated event ID. Persist deduplication across replicas and restarts. See provider compatibility.

received_hex = parse "sha256=<hex>" from request.headers["X-Hookway-Signature"]
expected_hex = hex(HMAC_SHA256(secret, raw_body_bytes))
assert constant_time_equal(received_hex, expected_hex)
# No timestamp — rely on X-Hookway-Event-Id dedup for replay protection

proxy: Hookway does not add any hook_way auth headers in proxy mode. The original vendor auth headers are forwarded verbatim. Verify using the vendor's own webhook verification procedure (e.g. validate X-Stripe-Signature using Stripe's SDK).

Metrics

MetricLabelsDescription
hookway_webhook_delivery_totaldestination_queue_id, queue, outcomeDelivery attempts by outcome. outcome values: 2xx, 4xx, 5xx, 429, timeout, error.
hookway_webhook_retries_totaldestination_queue_id, queueIncremented per retry attempt.
hookway_webhook_dropped_totaldestination_queue_id, queue, reasonAsync delivery dropped before it was ever attempted. reason is queue_full (buffer full at enqueue — backpressure) or engine_closing (enqueue during shutdown). NOT incremented on retry exhaustion or terminal 4xx.
hookway_webhook_dead_letter_totaldestination_queue_id, queue, outcomeTerminally-failed deliveries successfully redirected to the dead_letter destination. outcome is the last delivery outcome (4xx, 5xx, 429, timeout, error).
hookway_webhook_dead_letter_failures_totaldestination_queue_id, queueDead-letter redirects that themselves failed (or fired with no resolver). The event is then logged and dropped.
hookway_webhook_queue_depthdestination_queue_idCurrent async delivery queue depth (gauge).
hookway_webhook_queue_capacitydestination_queue_idConfigured async delivery queue capacity (gauge; set once at startup).
hookway_webhook_queue_accounted_bytesdestination_queue_idConservative bytes retained by queued and active async jobs, including retries and terminal callbacks (gauge; not live heap).
hookway_webhook_queue_accounted_capacity_bytesdestination_queue_idConfigured async accounted-byte capacity (gauge).

Metric change: hookway_webhook_dropped_total gained a reason label in this release. Dashboards/alerts that aggregated the metric without a reason selector continue to work; those that pinned an exact label set must add reason.

Retry-exhausted or terminal-4xx deliveries are observable via hookway_webhook_delivery_total{outcome="5xx"|"4xx"|"timeout"|"error"} and an Error log entry — they do NOT increment hookway_webhook_dropped_total. When a dead_letter destination is configured, such failures also increment hookway_webhook_dead_letter_total (on successful redirect) or hookway_webhook_dead_letter_failures_total (on redirect failure).

The generic Publish metric (hookway_publish_total{status}) reflects queue-accept (accepted) or queue-full drop (dropped) at the point where the inbound event is enqueued, before any delivery attempt.

destinations:
  forward:
    type: "webhook"
    webhook:
      url: "${WEBHOOK_URL}"
      delivery_mode: "async"    # sync | async (default async)
      timeout: "10s"
      max_queue: 1000           # async only; per-destination
      max_queue_bytes: 67108864 # async only; queued + active accounted bytes
      drain_timeout: "30s"      # async only
      dead_letter:              # async only; must reference a non-webhook destination
        destination: "dlq-rabbit"
        target: "webhook-dead-letters"
      retry:
        max_attempts: 5
        initial_backoff: "500ms"
        max_backoff: "30s"
      auth:
        type: "hmac"
        secret: "${WEBHOOK_SECRET}"
        # scheme: "hmac_sha256_ts_dot"    # default
        # header: "X-Hookway-Signature"   # default
        # event_id_header: "X-Hookway-Event-Id"  # default
      # tls:
      #   ca_cert: "/etc/ssl/certs/ca-bundle.pem"
      #   client_cert: "/etc/ssl/certs/client.pem"
      #   client_key: "/etc/ssl/private/client.key"
      #   server_name: "internal.example.com"
      #   insecure_skip_verify: false

  # The dead_letter destination referenced above — a durable, non-webhook backend.
  dlq-rabbit:
    type: "rabbitmq"
    rabbitmq:
      url: "${RABBITMQ_URL}"
      exchange: "hookway.dlq"
      exchange_type: "direct"
      declare_queues: true   # declares the "webhook-dead-letters" queue on startup

See examples/destinations/webhook.yaml for the full annotated reference config with all four auth types shown.

Resource provisioning and drift (on_existing_conflict)

For sqs and sns destinations, destinations.<id>.on_existing_conflict controls what happens when a target queue/topic already exists with attributes that differ from the config. Allowed values are error, warn, and reconcile; the default is reconcile.

Immutable conflicts — the FIFO-vs-standard nature of a queue/topic — always fail startup regardless of the policy, since they cannot be edited and would otherwise cause failed publishes. The field is only valid on sqs/sns destinations; setting it on a rabbitmq destination is a config error.

When create: true and the target does not exist, it is created with the configured attributes (no reconcile needed).

Flows

Typed routing, registration, managed outbound credentials, bounded batches, ordered XML and flow response policies are documented in Ingress adapters.

A flow is the request pipeline. Trigger paths must not overlap another flow or the reserved health/readiness/metrics paths. Flow names are internal identifiers; the public HTTP path comes from the http_trigger node, not from the flow name.

server:
  addr: ":8080"
  max_body_bytes: 1048576
destinations:
  billing:
    type: webhook
    webhook: {url: "https://example.test/hooks"}
flows:
  github_events:
    nodes:
      - {id: in, type: http_trigger, description: "GitHub posts deliveries here.", path: /webhooks/github, methods: [POST]}
      - {id: rl, type: rate_limit, store: local, description: "Caps traffic before any body is read.", rpm: 60}
      - {id: body, type: read_body, description: "Reads the raw body up to 1 MiB.", max_bytes: 1048576}
      - {id: auth, type: auth, description: "Verifies the HMAC over the raw body.", scheme: hmac_sha256_hex, secret: "${GITHUB_WEBHOOK_SECRET}", signature_header: X-Hub-Signature-256}
      - {id: env, type: envelope, description: "Builds the JSON event envelope.", body_format: json}
      - {id: out, type: publish, description: "Delivers to the billing queue.", destination: billing, target: github-events, create: true}
    edges: [in>rl, rl>body, body>auth, auth>env, env>out]

Nodes are wired by edges (from>to, or from:port>to for a labelled port). Illegal port wiring fails hookway validate. Typical order: http_trigger → pre-body gates (loop_guard, ip_allowlist, rate_limit) → read_body → auth or no_auth → envelope → filter / sample / transform / lookup / code / http_request → publish and/or respond.

on_error is a reserved output port on nodes that can fail with a recoverable input. It is not available on http_trigger (no input), read_body (the body reader is consumed by a failed read), or no_auth (no semantic failure). If the node returns an error and an on_error edge exists, the flow continues with that node's input (not its output) and the request succeeds unless the error path itself fails. The error-path target must consume the failing node's input port — rate_limit:on_error carries RequestHead by default or Verified for the verified stage, auth:on_error still carries RawRequest, envelope:on_error still carries Verified, http_request:on_error still carries Envelope. Protection infrastructure errors (503) never enter on_error. Quota and duplicate denial recovery paths may return a custom response, but cannot execute outbound effects or child flows. A node that has only on_error successors is rejected; it still needs a success path. Without on_error, the failure becomes the HTTP status from the node (401, 413, 429, 502, …) or 500. Filter on_no_match: drop is a successful stop, not an error, so it does not take on_error.

- {id: ack, type: http_request, url: "https://example.test/hooks", error_on_status: [5xx]}
- {id: out, type: publish, destination: billing, target: github-events}
- {id: dlq, type: publish, destination: billing, target: github-failed}
# …
edges: [env>ack, ack>out, ack:on_error>dlq]

See examples/on_error.yaml for rate_limit, auth, envelope, and http_request. examples/showcase/production.yaml uses ack:on_error>dlq.

http_request retry

http_request makes one attempt by default. Add a retry: block to re-attempt transient failures:

- id: notify
  type: http_request
  url: "${SLACK_WEBHOOK_URL}"
  body: transformed
  timeout: 5s
  retry:
    max_attempts: 4        # total attempts including the first; 1 or absent = no retry, max 10
    initial_backoff: 500ms # doubled with +/-20% jitter each attempt (default 500ms)
    max_backoff: 10s       # ceiling for any single wait (default 30s)

http_request outbound egress policy

An http_request whose destination host can be influenced by request data is an SSRF primitive unless it is bounded. Hookway bounds it in three places, and the whole policy turns on one distinction:

A literal host is the operator's choice; a templated host is not. url: "http://10.0.0.5:8080/ingest" is a deliberate configuration decision — proxying to an internal service is a legitimate thing to do, and Hookway does not second-guess it. url: "https://{tenant}-api.example.com/ingest" has a host that depends on env.Vars, which is seeded from the trigger's path variables and extended by lookup and transform nodes. The egress policy applies only to the second case.

"Templated" means the parsed authority has a {placeholder}. A ${VAR} environment reference does not count: it is expanded to literal text at config load, so url: "${UPSTREAM_BASE}/{rest}" is a literal host with a templated path, and is not subject to the policy.

1. Config time: a templated host needs a literal domain suffix. A {variable} in the URL authority must be followed by a literal dotted domain suffix, so a request can choose at most one label under a fixed domain and never the whole host. hookway validate rejects anything else with:

a {variable} in the url host must be followed by a literal domain suffix:
write the host as "{envName}-api.example.com" so requests cannot choose an
arbitrary host

So https://{envName}-api.example.com/v1 is accepted and https://{host}/v1 is not.

2. Render time: the rendered host is vetted before any connection. Each authority variable must be a single DNS label: 1–63 ASCII letters, digits, or hyphens, with no leading or trailing hyphen. Dots, slashes, URL delimiters, and encoded delimiters are rejected with 400, including when allow_private_targets: true. Path variables may still contain slashes. With the policy on, the target is classified after variable substitution and refused if it addresses the gateway's own host or a private network. Denied by address: loopback (127.0.0.0/8, ::1), unspecified (0.0.0.0/8, ::), link-local (169.254.0.0/16 — which is where 169.254.169.254 instance metadata lives — and fe80::/10), RFC1918 private (10/8, 172.16/12, 192.168/16), CGNAT 100.64.0.0/10, IPv6 ULA fc00::/7, multicast (224.0.0.0/4, ff00::/8), reserved/benchmarking ranges, IETF protocol assignments 192.0.0.0/24, and the Alibaba metadata address 100.100.100.200. IPv4-mapped IPv6 spellings collapse to the IPv4 rules, so ::ffff:169.254.169.254 is caught too. Denied by name: localhost, and the .localhost, .local, .internal (which covers metadata.google.internal), .localdomain, and .home.arpa suffixes.

Ambiguous host spellings are rejected rather than interpreted. Deciding whether 2130706433, 127.1, 0177.0.0.1, or 0x7f000001 is "really" 127.0.0.1 is a classic parser-differential SSRF bug — Hookway's answer and the resolver's answer only have to disagree once. So a host with an all-digit final label, a 0x-prefixed label, an empty label, or any character not valid in a DNS name is refused outright. A real TLD is never all digits, so nothing legitimate is lost.

3. Connect time: DNS rebinding is closed. A name that passes the check above can still resolve to 169.254.169.254, and a second lookup between the check and the connect can return a different answer than the first. When the guard is on, the dialer resolves the host itself, classifies every candidate address, and dials a specific vetted IP — so the address that was checked is the address that is connected to. Guarded and unguarded nodes use separate connection pools, so a guarded request cannot reuse a connection opened without this check. Redirects are not followed by default (http_request returns the response as-is). An explicit redirects.max_hops policy follows only bounded redirects on the original scheme and host/port, rechecks the target and normalizes its path. Cross-origin changes and userinfo are refused; managed credential requests never follow redirects. Capture uploads and replay control calls also refuse redirects and require the canonical receiver URL.

allow_private_targets

- id: ack
  type: http_request
  url: "https://{envName}-api.internal.example.com/ingest"
  allow_private_targets: true
Typeboolean
Defaultfalse — fail closed
EffectOpts this node out of steps 2 and 3 above. Step 1 (the literal-domain-suffix requirement) still applies.
Scopeper node, not global

Use it when a templated host is supposed to reach an internal upstream — a per-environment internal API, say. It is the only opt-out, it is explicit, and it is scoped to the one node that needs it.

hookway validate rejects allow_private_targets: true on a node whose URL host is literal:

allow_private_targets has no effect unless the url host contains a
{variable}; a literal host is already the operator's own choice

That is deliberate. A literal host was never subject to the policy, so setting the flag there does nothing — and a no-op security flag in a config file is worse than no flag, because it reads like a decision was made. Failing validation forces the config to say what is actually true.

An initial_backoff or max_backoff without max_attempts: 2 or more is rejected by hookway validate rather than silently ignored.

Node fields (every type):

Flow fields:

Protection stores

Declare state_stores at the top level with a stable deployment namespace and exactly one of memory, redis or postgres. Nodes must explicitly reference a store. Startup fails when any configured store cannot be verified. Apply PostgreSQL migrations with hookway state-store migrate before serving. Runtime outages withdraw readiness and reject protected traffic. Configuration, expiry, policy-update and deployment examples are in Protection stores.

Trigger, gates, and body

Auth

The additional separate-header HMAC, multi-signature HMAC and P-256 ECDSA protocols are documented in Request signatures.

Each flow builds its verifier once at startup from the auth (or no_auth) node. An unknown scheme fails closed.

HMAC schemes verify the raw body bytes. New providers are added by registering a scheme in internal/auth.

Envelope and publish

For body_format: json, envelope.json.max_depth defaults to 128 (maximum 1024), and envelope.json.max_nodes defaults to 1000000 (maximum 1000000). Positive overrides are optional. A syntax/structure preflight checks valid JSON before allocating the parsed tree, including values supplied under duplicate object keys. Exceeding either structural budget returns 413; malformed JSON retains the existing raw-base64 fallback. These limits do not replace read_body.max_bytes. A downstream json_validate has its own fixed depth/node limits, so raising envelope depth does not raise validation limits.

Shared destinations

A publish node can receive multiple incoming edges. Each active path sends its own payload and variables independently. Processing and response nodes still allow only one incoming edge; duplicate edges and cycles are invalid.

For two active branches, both transformed inputs reach the same destination:

edges:
  - env>left
  - env>right
  - left>send
  - right>send

Here left and right are processing nodes and send is a publish node. See the complete shared-destination example. To share a destination across exclusive conditional outputs, connect both labels:

edges:
  - choose:true>send
  - choose:false>send

Here choose is an if_else node. Only its selected output runs, so one input reaches send. The same pattern works with declared switch outputs and with compatible success/error paths. Dropped or unselected paths do not publish.

Each publish invocation runs its own applicable success or error continuation with that input. Existing delivery failure policies and timeout/concurrency rules still apply: an earlier failure can prevent a later arrival from being attempted. Delivery outcomes and eligible failure records are separate even when they share a node ID. Replaying a failure starts from that record's saved input and follows existing continuation behavior; it does not rerun sibling arrivals.

Batch child flows may likewise connect several paths to their one publish node. Response counts remain per child event, and any failed or unattempted delivery makes that child fail. Existing message identities and broker deduplication settings still apply; multiple publish calls do not guarantee distinct broker messages if the destination deduplicates their configured identities.

Builder templates

The config UI's palette has a Templates group above the node types. A template drops several pre-wired nodes in at once:

Each template is backed by a complete config in examples/templates/, so make validate-examples validates it like any other example and a renamed node option cannot break the palette silently. The builder inserts only the node ids listed in the manifest (web/src/graph/templates.ts).

Inserted nodes are renamed on collision (fmt, then fmt_2), so dropping the same template in twice is safe.

After inserting a notification template, edit two things: the code node's script (what the message says) and the http_request node's url (which should stay an environment reference such as ${SLACK_WEBHOOK_URL} — the webhook URL is the credential). Never paste a live webhook URL into the node. Both notification templates treat a 4xx as terminal, because a payload the platform rejected will not become acceptable on a retry.

stop_on_failure (default) publishes publish successors sequentially and returns 503 on the first failure. skip_failed attempts every direct publish successor with bounded concurrency and returns 202 when at least one succeeds, with failed_destinations listing failures. Fan-out through processing nodes remains sequential and stops on error. Filter drops are successful branch termination.

Filtering

A filter node is a list of AND-combined conditions. Place it after envelope and before every publish to drop the whole event, or on only some outgoing edges to skip a branch. on_no_match: drop (default) is a silent 202 with no destinations; on_no_match: reject returns 422. A dropped branch is never a delivery failure.

- id: filt
  type: filter
  on_no_match: drop
  conditions:
    - source: header        # header | body
      path: X-GitHub-Event  # header name, or dotted body path such as repository.private
      operator: eq
      value: "push"

Header values are strings, so numeric/bool operators parse them ("42" matches eq: 42). A missing or incomparable value fails the condition, including ne. A body that is neither a JSON object nor an XML document (for example after body_format: raw) fails every body condition except not_exists; hookway validate rejects that combination rather than letting it drop every event silently. Silent drops are counted by hookway_filtered_total with level="flow". Per-node metrics record execution; condition values are not logged.

Config files pass through os.ExpandEnv before parsing, so $name inside a regex value is expanded. Escape or avoid literal $-prefixed tokens.

See examples/filters.yaml.

Sampling

Place a sample node after envelope to keep a random fraction of events:

- id: sample_events
  type: sample
  rate: 0.2

rate is required and must be a finite number from 0 to 1 inclusive. It is the fraction kept: 0 drops all events, 1 keeps all, and 0.2 keeps roughly 20%. Smaller fractions work too: 0.001 keeps roughly 0.1% and 0.00001 keeps roughly 0.001%. Each node execution makes an independent random decision, including retries and replays; this is an approximate proportion, not an exact quota or a stable selection by event ID. The builder starts new sample nodes at rate: 1; YAML without a rate is invalid.

Selected events continue unchanged. Sampled-out events stop that branch successfully, receive the default HTTP 202 acknowledgment (or the flow's responses.dropped policy), produce no failure capture, and retain any preceding replay_guard reservation. Other fan-out branches continue normally. Existing deliveries and responses follow the usual flow response rules.

Sampling supports every envelope body format. Before a batch, it selects whole requests; inside a batch child flow, it selects individual items. Sampled-out children use the existing successful filtered-child skip counts, so they do not cause a dedicated batch to return 502. Node metrics use outcome="dropped", and hookway_filtered_total records level="sampled_out".

See the runnable sampling configuration. Release the matching core backend, builder, and WASM validator together before using this node; older versions do not recognize type: sample.

Inspecting webhooks

An inspect node captures the current rendered body and request context for viewing in the replay console's Inspections tab. It takes no options and requires the global replay: connection, even with capture_failures: false. Connect it inline (env>inspect>publish) to observe every event that reaches it, or leave it as a successful observation leaf. It passes its input onward unchanged; downstream changes cannot alter a capture.

For spot-checking without reducing delivery traffic, put Sample and Inspect on an independent branch before the Publish edge:

nodes:
  # ... http_trigger → read_body → auth → envelope (id: env)
  - {id: spot_check, type: sample, rate: 0.05}
  - {id: inspect_sample, type: inspect}
  - {id: send_all, type: publish, destination: primary, target: events}
edges: [env>spot_check, spot_check>inspect_sample, env>send_all]

The executor visits the observation branch first in edge order. Approximately 5% of requests are inspected while Publish receives every request. Putting Sample inline before Publish instead would reduce deliveries. An inspection-only flow uses the inspected response outcome (default 202); publication, explicit responses and processing errors retain their normal precedence. A later failure or non-2xx sender response does not discard an earlier inspection.

View-only records contain inspection time, flow/node and correlation identifiers, and the final inbound HTTP status. The versioned JSON payload contains the rendered body (UTF-8, or base64 for binary), content type, filtered/redacted headers, variables, method and path. JSON number precision is preserved. Headers use existing logging policies and authentication credential redactions. Body and variable contents are operator-authored data; there is no field-level redaction.

Uploads are best effort, reuse the bounded replay uploader, and never fail the webhook request. Inspections and failures share retention, record-size limits, upload-buffer memory and storage caps. Oversized/unsupported captures retain metadata with an unavailable-payload reason. View-only records have no executable snapshot, retry, or delivery-resolution action; dismissal deletes their payload. V1 supports inbound flows, excludes batch-child placement, and collects no new inspections during replay redrives. Deploy the replay migration/API first, then matching console/builder/WASM assets, before core configs using Inspect.

See the runnable spot-check example and console inspection controls.

XML bodies

An envelope with body_format: xml parses the verified bytes into an element tree. Parsing follows the flow's authentication decision; explicit no_auth flows still accept unsigned input.

What understands that tree:

NodeBehavior
filtersource: body paths address element local names below the root, @name for an attribute. A leaf compares as a string, so gt/lt work on numeric text.
codeThe script sees {declaration, root, before, after}; elements include names/namespaces, legacy attribute/text views, ordered attributes and content, and children. Preserve that tagged tree; see ordered XML editing.
transformkind: xml_prune only. field_map and changed_fields are JSON-only and are rejected at validate time.
http_requestbody: transformed re-serializes the tree and sends application/xml. body: raw sends the original bytes.
publishSerializes the tree and base64-encodes it into the envelope's raw_body_base64, with body: null.

Limits worth knowing before you design a flow around it:

examples/xml.yaml is a complete flow using all of it.

Multipart bodies

body_format: multipart parses a multipart/form-data request into a shape the rest of the flow can address:

- id: env
  type: envelope
  body_format: multipart
  multipart:
    max_parts: 4            # parts allowed before the request is rejected (default 16, max 256)
    max_part_bytes: 2097152 # per-part cap (default 1 MiB)
    max_total_bytes: 4194304 # optional aggregate decoded bytes (maximum 64 MiB)
    include_content: true   # carry the bytes as base64 (default true)

The body becomes:

{
  "fields": {"account": "acct_1"},
  "files": {
    "document": {
      "filename": "report.pdf",
      "content_type": "application/pdf",
      "size": 20481,
      "content": "<base64>"
    }
  }
}

Files are keyed by form field name rather than collected into a list, so the ordinary dotted-path filter reaches them: files.document.content_type. A repeated field name gets a #2, #3 suffix in document order (files.document#2.filename). code sees the same object as input.body, and publish sends it as the envelope's body.

For authenticated flows, signature verification uses the raw bytes before multipart parsing. Explicit no_auth flows accept unsigned uploads.

Memory. The whole request is buffered by read_body; parsing temporarily buffers each decoded part, and carried file content retains a base64 copy of 4 * ceil(decoded_bytes / 3) bytes. Raw bytes, parsed metadata, transient part buffers and branch copies can overlap, so this expansion formula is not a peak heap guarantee. Use read_body.max_bytes, max_parts, max_part_bytes and an optional max_total_bytes together. Set include_content: false when you only route on metadata: each file then carries size and content_omitted: true instead of a retained base64 copy. The parser still reads a bounded temporary part buffer to determine its size. Genuinely large uploads want a streaming intake, which Hookway does not have.

Limits worth knowing before you design around it:

LimitDetail
Form-data onlymultipart/mixed and multipart/related are rejected with 400.
Closing boundary requiredA body with no closing --boundary-- is rejected with 400. mime/multipart reports a truncated body as a clean end of input, so without this check a cut-off upload would parse to zero parts instead of failing.
No re-serializationhttp_request body: transformed JSON-encodes the parsed shape, which is not a multipart body. To forward an upload unchanged, use body: raw, which sends the original bytes.
Claimed typescontent_type is what the vendor's part headers said, verbatim. There is no magic-byte sniffing, so a filter matches the claim, not the content.
Repeated fieldsNon-file fields keep the last value; files get #2/#3 keys.
Transform kindsfield_map, changed_fields, and xml_prune all reject a multipart body at validate time — read the parts in a code node instead.
Aggregate capOptional max_total_bytes limits all decoded parts together, including files omitted from the output. Omitted means no additional aggregate cap; wire, part-count and per-part caps still apply. The reader stops at the smaller of the part cap and remaining aggregate cap plus one byte, without draining an over-limit part.
StatusesOver max_parts, max_part_bytes or max_total_bytes is 413; a malformed body or a non-form-data content type is 400.

examples/multipart.yaml is a complete flow.

To send the file to one destination and the form fields to another, fan out after envelope and reshape each branch with a code node (see examples/multipart-split.yaml). Use publish.payload: object on the S3 branch so PutObject writes decoded file bytes (with the vendor Content-Type and filename on the key) instead of a JSON envelope.

Publish payload modes

publish.payload selects how object-capable destinations store the event:

ValueDestinationsWhat is stored
envelope (default)allJSON Message (application/json)
objects3, redis_kv onlyDecoded file bytes; S3 also uses supplied filename / Content-Type metadata

For payload: object, the envelope body must be a file object:

{"content": "<base64>", "filename": "report.pdf", "content_type": "application/pdf"}

That is the shape multipart parsing already uses for each entry under files.*. A code node can lift files.document onto body before publish. When the envelope used body_format: raw (empty parsed body), object mode falls back to the raw request bytes and the original inbound Content-Type header. That header is used only as object metadata; it does not bypass security.forwarded_header_allowlist in the stored envelope.

hookway validate rejects payload: object on any other destination type. examples/multipart-split.yaml is the reference flow (S3 + RabbitMQ). examples/multipart-s3-lambda.yaml is the same pattern with an Event Lambda notify instead of a queue.

Variables

Path placeholders on http_trigger (path: /webhooks/{envName}/events) are copied onto envelope.Vars. A transform field_map can copy a JSON body field to vars.tenant; a code node can derive variables from its parsed body, existing vars, or filtered headers input. {name} placeholders in publish.target and in a webhook destination url are filled per request. This can select a pre-provisioned queue per environment, such as {envName}-events. Unresolved templates and unsafe values fail closed at publish. A lookup node can resolve a key through a named provider under top-level lookups and store the result as a variable; see named lookup providers.

A placeholder in a webhook url host must be followed by a literal domain suffix ({envName}-api.example.com) so a request cannot choose an arbitrary host. ${ENV_VAR} references expand at load and are not request variables.

See examples/variables.yaml.

Published envelope reference

Every publish node with the default payload: envelope writes one JSON document, the same shape on every destination type. This is the consumer contract; schema_version governs its compatibility. The Go source of truth is queue.Message in internal/queue/publisher.go.

FieldJSON typeAlways presentDescription
schema_versionstringyesEnvelope contract version, currently "1.0". Additive changes keep the major; a removal or a change of meaning requires a new version. Gate your parser on this.
idstringyesUUIDv7 ID assigned per inbound request and retained by internal delivery retries and replay. A sender retry creates a new ID; deduplicate those with a stable sender event ID.
idempotency_keystringno — omitted on ingestPresent in the struct, but ingest never populates it, so it is omitted from every normally-published envelope. Do not build dedup on it. See Idempotency.
request_idstringwhen non-emptyOne nonempty non-whitespace printable ASCII inbound X-Request-ID (or security.request_id_header) of at most 128 bytes; missing, invalid or repeated values use a generated UUID. Matches the X-Request-ID response header and request_id log field.
trace_idstringwhen non-empty32-hex W3C trace id, taken from the inbound traceparent or X-Trace-ID when either is a valid trace id, else generated.
traceparentstringwhen non-emptyThe outbound W3C traceparent value: the inbound header verbatim when syntactically valid, otherwise one synthesized from trace_id. The webhook destination sets it as a real header on the outbound request (envelope and proxy modes); every other destination carries it inside the JSON and does nothing else with it.
endpointstringyesThe request path the event arrived on (e.g. /webhooks/github). Note this differs from the endpoint in Hookway's HTTP response body and in metric/log labels, which is the flow name. The name is kept for compatibility with pre-graph consumers.
destination_queue_idstringyesThe destinations: map key this envelope was published through.
queuestringyesThe publish target — queue, topic, routing key, stream, collection, object prefix, or Temporal task queue. Rendered, if the target was a {var} template.
received_atstring (RFC 3339)yesWhen Hookway received the request.
methodstringwhen non-emptyInbound HTTP method.
pathstringwhen non-emptyInbound request path.
headersobjectwhen non-emptyInbound headers after redaction and allowlisting (security.redacted_header_denylist, security.forwarded_header_allowlist). Redacted values are the literal string "redacted".
queryobjectwhen non-emptyInbound query parameters. Query keys used for auth are redacted.
bodyanyyes (may be null)The decoded body. An object for body_format: json; the element tree for xml; {fields, files} for multipart; null for raw and for XML published as bytes.
raw_body_base64stringonly when setBase64 of the original bytes. Set by body_format: raw, and by XML publishes (which serialize the tree here and leave body null).
source_ipstringwhen non-emptyClient IP, resolved through security.trusted_proxies / trusted_proxy_headers.
authobjectyes{"type", "scheme", "verified"} describing how the request was authenticated — the auth node's identity and scheme, with verified: true. A no_auth flow still emits the object.
variablesobject (string→string)when non-emptyThe routing variables resolved for this event — path placeholders, transform field_map outputs, code-derived values. Publishers render templated destination settings (e.g. a webhook url) from these.
dead_letterobjectonly on dead-lettersPresent only when an async webhook delivery terminally failed and was redirected to a configured dead_letter destination. It records why. Additive, so schema_version stays 1.0. Its absence is the normal case.

Fields on queue.Message tagged json:"-" are in-memory only and never serialized: RawBody and OrigHeaders (proxy-mode webhook reconstruction), Hops (loop protection — the webhook destination stamps Hops+1 as a header), MessageAttributes (SNS/SQS attributes ride the AWS API, not the JSON body), and the PayloadMode / ObjectBytes / ObjectContentType / ObjectFilename group used by payload: object on s3 and redis_kv.

With payload: object there is no envelope at all — the decoded file bytes are stored directly. See Publish payload modes.

Response headers

Hookway sets a fixed set of headers on every response from the webhook listener, including error responses, before any flow runs:

HeaderValueWhen
X-Content-Type-Optionsnosniffalways
Cache-Controlno-storealways
X-Request-IDthe request idalways — a single nonempty non-whitespace printable ASCII inbound value of at most 128 bytes is echoed; missing, invalid or repeated values use a generated UUID. This applies to security.request_id_header too. It matches the envelope's request_id and log field; give the returned value to a sender investigating a delivery.
Retry-After1on the 503 returned when server.max_inflight_requests is exhausted ({"error":"server busy"}). Not set on other 503s.
Allowthe flow's configured methodson a 405, when the request method is not in the trigger's methods list. A trigger with methods: [ANY] never 405s.

A respond node controls status and body and may add its own headers, but the three always-on headers above are already written by the time it runs.

Code node

code runs JavaScript on a goja VM compiled at config load. A syntax error fails hookway validate, not the first request. Known transforms (changed_fields, field_map, xml_prune) belong on transform, not in JS.

// `input` is provided; return an object {body, vars}.
// There is no I/O, no require, no timers, and no access to other flows.
return { body: input.body, vars: { computed: String(input.body.id).toUpperCase() } };

Input is an isolated view of {body, vars, headers, request}. request contains method, path and redacted query values. Sensitive headers are redacted. Optional upstream contains only status, explicitly selected scalar values and a selection error; raw upstream bodies, raw request bytes, Verified and resolved auth secrets are unavailable. Return {body, vars}; omitted keys keep the previous envelope values.

An XML body (from body_format: xml) arrives as a tagged node tree — {declaration, root}, where each element is {name, ns, attr, attrns, text, children} — and the script must return the same shape, which keeps the body an XML document for a downstream xml_prune or a body: transformed forward. The shape is deliberately explicit rather than a convenience mapping of element names to values: a convenience mapping cannot represent attributes, repeated siblings, or mixed content, so a script that read and returned such a body would silently drop parts of the document. attr is keyed by local name so a script need not know the vendor's namespace prefix; attrns carries the namespace URI of any attribute that has one, keyed by the same local name, so a read-and-return script round-trips namespaces without having to think about them.

Options:

The interrupt is checked between JavaScript statements. A pathological regex may run until it finishes; use filter instead. Each request gets a separate VM.

Logging & observability

Hookway emits structured slog JSON logs. Each log record carries a consistent set of field keys so log queries and alerts are stable across destinations.

hookway_config_load_failures_total still increments on a failed load, but fatal pre-listener failures are primarily visible via structured logs and a supervisor restart. The process usually exits before Prometheus can scrape that counter.

Canonical log fields

KeyPresent onDescription
destinationpublish path, startupStable destination id from the config map (e.g. billing).
targetpublish path, startupQueue/topic/routing-key/stream name (e.g. stripe-events).
event_idpublish pathEnvelope event id (id on the published message).
endpointpublish pathFlow name (e.g. stripe_billing); the metric label remains endpoint for compatibility.
request_idpublish pathValid bounded value of the configured request_id_header, or generated UUID; matches the response/envelope.
trace_idpublish pathTrace id propagated from the inbound request, when present.
errorerror/warn recordsDiagnostic error text with URL/credential redaction on protected backend paths; not a place for payloads or custom-code secrets.
subscribersredis_pubsubInteger subscriber count at publish time; 0 triggers a drop warning.
attemptrabbitmq retryPublish attempt number. attempt=1 is the initial try (not logged); the first logged retry is attempt=2.
groupredis_streamConsumer group name used or created at startup.
attr / want / got / policysqs, sns reconcileAttribute name, expected value, actual value, and policy action during queue/topic attribute reconciliation at startup.

Breaking change (log keys only): publish logs previously used destination_queue_id and queue as field keys. They are now destination and target. Prometheus metric label names and the JSON response/envelope body are unchanged — only the structured log field names changed.

Log levels by event

LevelEvent
ErrorRequest/flow publication failures and terminal async failures; repeated hot-path diagnostics are storm-sampled in normal mode. Counters count every outcome.
InfoStartup resource creation: "created queue", "created topic", "created bucket", "ensured table", "created consumer group".
DebugStartup verify/exists checks; redis_list and redis_kv per-target registration; RabbitMQ publish retry (with attempt); MongoDB duplicate-key skip ("skipped duplicate event").
Warnredis_pubsub no-subscriber drop ("published to channel with no subscribers; message dropped") — also increments hookway_redis_pubsub_dropped_no_subscriber_total.
WarnRabbitMQ connection lost; initial-connect failure; dead-letter delivery.
InfoRabbitMQ reconnected.

Replay operations console

Frontend URL configuration, API CORS and scoped credentials belong to the console deployment guide.

MCP event delivery

The optional event_delivery block enables PostgreSQL-backed subscriptions. MCP triggers select shared named events, and destinations of type mcp_events route Publish output to their active subscribers. See configuration and limits.