Hookway

Webhook — destinations/webhook

# examples/destinations/webhook.yaml
#
# Reference config for a Webhook destination. Hookway delivers each inbound
# webhook event to a configured HTTPS endpoint.
#
# DELIVERY MODE (delivery_mode: async | sync; default async)
#   async (default): Publish() enqueues the job and returns immediately (the
#     caller's inbound request receives hook_way's own 202 Accepted — NOT the
#     downstream response). Delivery outcome is observable via logs, metrics,
#     and (optionally) a dead_letter destination.
#   sync: Publish() delivers inline (with retries) and returns the outcome, so
#     the inbound request only succeeds when the downstream delivery does. This
#     adds delivery/retry latency within the finite publish budget. Use it for
#     critical, low-volume hooks where the sender
#     should see delivery failures directly. dead_letter does not apply to sync.
#
# Each webhook destination has its OWN delivery engine (worker pool + queue), so
# a slow target cannot block deliveries to other destinations. The pool size is
# controlled by server.fanout_concurrency; max_queue bounds queued job count
# (per-destination, default 1000), and max_queue_bytes bounds queued plus active
# payload bytes (default 64 MiB). When a budget is full, Publish fails and the
# upstream sender receives a non-2xx response, creating backpressure.
#
# On shutdown, hook_way attempts to drain the queue within drain_timeout
# (defaults to server.shutdown_timeout). An expired deadline or forced exit can
# abandon queued and in-flight deliveries.
#
# NON-DURABLE (async): a hard crash (kill -9, OOM, host failure) can lose events
# already acknowledged to the sender. dead_letter handles observed terminal
# failures while the process runs; it cannot recover jobs lost with the process.
# Publish directly to a durable broker when accepted work must survive a crash.
# See ../reliable-ingestion.yaml and docs/operations/delivery-guarantees.md.
# Retries after a destination commits but loses its response may repeat work.
# Preserve a stable provider identity and deduplicate atomically at the consumer;
# Hookway's envelope ID changes on each new inbound provider request.
# hook_way is not a transparent proxy — the downstream response is not returned
# to the inbound sender in async mode.
#
# `on_existing_conflict` is NOT valid for webhook destinations — the field is
# rejected at config validation time. Do not set it on webhook entries.
#
# LOOP PROTECTION: every outbound delivery is stamped with an X-Hookway-Hops
# header (incremented per Hookway hop). If this URL points back at a Hookway
# flow, put a `loop_guard` on that flow — `security.loop_protection` is leftover
# YAML and is not wired to inbound rejection. See docs/reference/configuration.md.

server:
  addr: ":8088"
  max_body_bytes: 1048576
  # fanout_concurrency sets the worker count for each destination's own pool.
  fanout_concurrency: 8

destinations:
  forward:
    type: webhook
    webhook:
      # Required: the target endpoint URL. Must be http:// or https://.
      url: "${WEBHOOK_URL}"

      # Optional: delivery mode. "async" (default) enqueues and returns 202;
      # "sync" delivers inline and returns the downstream outcome to the caller.
      delivery_mode: "async"

      # Optional: per-request HTTP timeout (default 10s).
      timeout: "10s"

      # Optional (async only): max number of pending deliveries in this
      # destination's queue (default 1000, now truly per-destination). When the
      # queue is full, inbound Publish calls fail (backpressure).
      max_queue: 1000

      # Optional (async only): how long to drain queued deliveries on shutdown
      # (defaults to server.shutdown_timeout).
      drain_timeout: "30s"

      # Optional (async only): dead-letter routing for terminally-failed
      # deliveries (4xx, or 5xx/timeout after retries are exhausted). The
      # referenced destination MUST exist and MUST NOT be a webhook destination
      # (this makes delivery loops structurally impossible). The failed message
      # is enriched with a "dead_letter" block (destination, target, outcome,
      # attempts, failed_at) and published to the named destination/target.
      # dead_letter:
      #   destination: "dlq-rabbit"   # id of a non-webhook destination below
      #   target: "webhook-dead-letters"

      # Optional retry policy. Retries are attempted on HTTP 429, 5xx, and
      # network-level errors (connection refused, timeout). Other 4xx responses
      # are terminal — no retry is attempted. 2xx is success.
      retry:
        max_attempts: 5
        initial_backoff: "500ms"
        max_backoff: "30s"

      # --- Auth: choose ONE type; comment out the others ---

      # auth.type: hmac  (default)
      # Signs each request with HMAC-SHA256. The default scheme
      # (hmac_sha256_ts_dot) sends:
      #   X-Hookway-Signature: t=<unix_ts>,v1=<hex_HMAC_SHA256>
      # where the signing string is "<unix_ts>.<raw_body>".
      # hook_way also sends an X-Hookway-Event-Id header (the event id) so the
      # receiver can deduplicate replayed requests.
      auth:
        type: "hmac"
        secret: "${WEBHOOK_SECRET}"   # signing secret; never log or expose
        # scheme: "hmac_sha256_ts_dot"  # default; see docs for other schemes
        # header: "X-Hookway-Signature"  # default signature header
        # event_id_header: "X-Hookway-Event-Id"  # default event-id header

      # auth.type: api_key
      # Sets a static header on every outbound request.
      # auth:
      #   type: "api_key"
      #   header: "X-Api-Key"
      #   value: "${WEBHOOK_API_KEY}"

      # auth.type: none
      # No authentication headers are added. Suitable for internal/trusted
      # networks only.
      # auth:
      #   type: "none"

      # auth.type: proxy
      # Forwards the ORIGINAL inbound raw body and headers (minus hop-by-hop
      # headers) without adding any hook_way auth. The vendor's original auth
      # headers (e.g. X-Stripe-Signature) pass through to the downstream
      # endpoint. The event envelope is NOT used; the raw body is forwarded.
      # auth:
      #   type: "proxy"

      # tls:                           # optional; only needed for a custom CA / mTLS
      #   ca_cert: ""                  # path to PEM bundle for private/self-signed CAs
      #   client_cert: ""              # path to PEM cert for mutual TLS
      #   client_key: ""               # path to PEM key for mutual TLS (must pair with client_cert)
      #   server_name: ""              # override SNI/verification hostname
      #   insecure_skip_verify: false  # dev-only; disables cert verification; never use in production

  # Example dead-letter destination for the webhook above. Uncomment together
  # with the dead_letter block on `forward`. Must be a NON-webhook destination.
  # dlq-rabbit:
  #   type: rabbitmq
  #   rabbitmq:
  #     url: "${RABBITMQ_URL}"
  #     exchange: "hookway.dlq"
  #     exchange_type: "direct"
  #     declare_queues: true   # declares the dead-letter target queue on startup

flows:
  order_events:
    nodes:
      - {id: in, type: http_trigger, path: /webhooks/orders, methods: [POST]}
      - {id: hops, type: loop_guard, max_hops: 3}
      - {id: body, type: read_body, max_bytes: 1048576}
      - {id: auth, type: no_auth}
      - {id: env, type: envelope, body_format: json}
      # target is passed in the X-Hookway-Target header on envelope deliveries
      # and is used in log fields / metrics labels.
      - {id: out, type: publish, destination: forward, target: orders}
    edges: [in>hops, hops>body, body>auth, auth>env, env>out]