Skip to content
j-256Public

About

Route webhooks and email to push, chat, or signed webhooks, with queued delivery, retries, and an event dashboard.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

hookrelay

GitHub authentication supports a separate App, explicit manual tokens and standalone gh mode, with protected registration, activation and recovery.

Manual GitHub handoff creates reviewed receivers for trusted external administrators, with protected URL/secret retrieval, immutable encrypted custody and compatible local recovery. Its guide records restoration requirements and Free-plan estimates.

Run pnpm commands for a one-screen reference to routine setup, sync, development, and deployment commands.

Hookrelay is a small, provider-agnostic webhook notification gateway for Cloudflare Workers. Add adapters for new webhook senders, receive ordinary email, and route normalized updates to sinks such as push, chat, or logs. Provider configuration uses KV in legacy mode or a separately activated provider-owned D1 configuration authority. Hookrelay's commands manage either authority; an external dashboard is an additional policy client, not the exclusive control plane. Inbound bearer credentials are represented by hashes, and recoverable credentials live in Wrangler secrets.

The Hookrelay admin events dashboard showing normalized webhook events and independent sink-delivery states

What it does

Receives webhooks at /hook/<source>/<slug> or MIME email through Cloudflare Email Routing, normalizes the payload, persists every event to D1+R2, then records one durable decision per configured sink. Allowed deliveries are queued independently, policy-filtered destinations remain visible with their decision reason, and exhausted deliveries remain manually retryable in the admin page.

Built-in sources (v1):

  • Statuspage incidents and scheduled maintenance (Atlassian)
  • GitHub repo webhooks
  • Cloudflare Notifications
  • UptimeRobot
  • HMAC-authenticated structured CloudEvents 1.0
  • Generic email, including multipart plain text and HTML messages

Built-in sinks (v1):

  • ntfy.sh push (also self-hostable)
  • Discord channel webhook
  • Signed structured CloudEvents webhook

Admin: /admin redirects to the Cloudflare Access-protected /admin/events dashboard for browsing recent events, inspecting per-sink delivery state, and retrying exhausted deliveries. /admin/health summarizes delivery state, redacted operational signals, alert attempts, route activity, and retention configuration.

Deploy your own

You will need:

  • A Cloudflare account on Workers Paid for the checked-in execution ceilings; the Free fallback and its tradeoffs are documented below
  • A custom domain on Cloudflare (the WAF rule and Cloudflare Access only work on a real zone, not *.workers.dev)
  • Node 22 + pnpm 11
  • Wrangler authenticated to your account (npx wrangler login)
  • CLOUDFLARE_ACCOUNT_ID set in your environment (wrangler reads the account id from it, so it is not committed to wrangler.jsonc)

Steps:

  1. Clone and install:

    git clone https://github.com/j-256/hookrelay.git
    cd hookrelay
    pnpm install
  2. Create the Cloudflare resources (one-time):

    npx wrangler kv namespace create SUBS
    npx wrangler kv namespace create SINKS
    npx wrangler d1 create hookrelay-events
    npx wrangler r2 bucket create hookrelay-events-raw
    npx wrangler queues create hookrelay-delivery
    npx wrangler queues create hookrelay-delivery-dlq

    Copy the printed ids into the kv_namespaces and d1_databases entries in wrangler.jsonc. These are opaque, account-scoped resource handles, not secrets – they grant no access without your API token, so they are safe to commit. Queue bindings use the two queue names directly, so keep those names aligned with wrangler.jsonc.

    Note: Wrangler names the KV namespaces hookrelay-SUBS / hookrelay-SINKS (worker name + binding), so that is what shows in the dashboard. SUBS/SINKS are the binding names your code uses (env.SUBS); the id in wrangler.jsonc is what actually links them. Dashboard title and binding name differ by design.

  3. Apply the D1 migration:

    npx wrangler d1 migrations apply hookrelay-events --remote
  4. Set the Cloudflare Access values as Wrangler secrets (they identify your Zero Trust org, so they are kept out of source):

    npx wrangler secret put CF_ACCESS_TEAM_DOMAIN
    npx wrangler secret put CF_ACCESS_AUD
    • CF_ACCESS_TEAM_DOMAIN: your Zero Trust team subdomain -- the part before .cloudflareaccess.com. Find it in the Zero Trust dashboard under Settings, or run npx wrangler access (note: it may not match your org name -- e.g. an org named my-org can have the team domain myorg)
    • CF_ACCESS_AUD: the AUD tag from your CF Access application configured for /admin/*
    • For local wrangler dev, put both in a .dev.vars file (gitignored) instead.
  5. Initialize the local routing config:

    cp routes.example.jsonc routes.jsonc
  6. Deploy the Worker:

    npx wrangler deploy

    Then in the Cloudflare dashboard, attach the Worker to a custom domain (hooks.example.com). The route is managed in the dashboard rather than in wrangler.jsonc, so no hostname is committed to source (workers_dev is false to keep the Worker off *.workers.dev).

    GitHub Actions runs source verification on PRs and pushes to main. Production deployment is a separate, manually started workflow supporting published releases or a full unreleased commit SHA already on main. Configure your fork's deployment credentials and follow Manual deployment and upgrades. Apply migrations before deploying dependent code; source publication and release preparation do not require production access. Keep Cloudflare Workers Builds disconnected to avoid an independent automatic deployment path.

  7. Add a sink. The command reads the Discord webhook URL without echoing it, stores it in .dev.vars, adds the secret reference to routes.jsonc, and offers to install the Wrangler secret and sync the selected provider authority:

    pnpm sink:add discord discord
  8. Add subscriptions. Each command writes the hash-only route locally, saves private receiver recovery, and offers to install any sender secret and sync the selected provider authority. GitHub's non-manual event selections create the repository webhook only after the route is live:

    pnpm sub:add claude-status statuspage --fallback-url https://status.claude.com/
    pnpm sub:add github-yourname-yourrepo github --repo yourname/yourrepo --events activity,alerts
    pnpm sub:add automation-events cloudevents
    pnpm sub:add openai-status email --email-base [email protected] --primary-link-label "View incident" --fallback-url https://status.openai.com/

    Email subscriptions require the one-time Email Routing setup described in Forwarding email notifications.

  9. (Optional) Deploy the edge WAF rule that keeps scanner traffic off the Worker:

    ./scripts/deploy-waf.sh hooks.example.com          # dry-run, shows the plan
    ./scripts/deploy-waf.sh hooks.example.com --apply   # deploy

    The default rule is a Free-plan-compatible default-deny: it edge-blocks every path that is not /hook/*, /admin, or /admin/*, using operators allowed on all plans. A blocked request terminates at Cloudflare's edge and never invokes the Worker, so it does not count against the Workers request quota -- this is a cost optimization, not a correctness gate (the Worker already 404s unknown paths and 401s bad signatures on its own). deploy-waf.sh appends its rule by description and leaves any other rules in your custom ruleset untouched. See scripts/waf-rules.example.jsonc for the rule and the tradeoff it accepts.

  10. Configure Cloudflare Access for /admin/* to require login (email OTP or GitHub OAuth, your choice). Note the AUD tag for step 4.

  11. Smoke test (see "Smoke tests" below).

Configuration reference

hookrelay separates configuration by sensitivity. The guiding rule: nothing secret is committed. If you are forking this to run your own instance, this section is the "what goes where, and why."

1. wrangler.jsonc – committed, safe to make public

wrangler.jsonc is the Worker's deployment config: its entry point, compatibility date, and the bindings that connect the Worker to its KV namespaces, D1 database, R2 bucket, and queues. The only account-specific values are the resource ids below.1

Key What it is
kv_namespaces[].id (SUBS, SINKS) KV namespaces holding legacy configuration and bounded operational fallback records
d1_databases[].database_id D1 database storing the event log and, after explicit activation, authoritative provider configuration
r2_buckets[].bucket_name R2 bucket for raw payloads (bound by name, so there is no id to set)
queues Producer and consumer bindings for per-sink delivery and its dead-letter queue
triggers.crons Five-minute recovery sweep for delivery rows that could not be published to the queue
limits Workers Standard ceilings of 1,000 ms CPU and 1,000 total subrequests per invocation
ratelimits Account-unique Rate Limiting binding namespaces for source, subscription, and coalesced signal budgets
observability Stored Workers Logs are disabled on purpose -- webhook URLs contain the slug (a bearer token) and Cloudflare enriches stored logs with the request URL, which would leak it. See the comment in the file; failure visibility comes from D1 (/admin/events) and wrangler tail instead.

Cost guardrails and Free compatibility

Hookrelay applies a 2,000-request-per-minute source-class budget before hashing the slug or reading provider configuration. Registered source types each receive one counter, while unregistered source names share an unknown counter so a scanner cannot evade the guardrail by varying the path. The log event omits the bearer path. After a subscription resolves and is enabled, a separate 1,200-request-per-minute subscription budget runs before the body is read, authenticated, stored in D1 or R2, or published to a queue. A refusal returns 429 with Retry-After: 60. A third one-per-minute binding coalesces each subscription's durable ingress-rate-limited operational signal so the diagnostic path cannot perform one D1 write per rejected request.

Production D1 observations for the 30 days ending 2026-09-07 recorded a maximum of 547 events for an entire source-minute and 530 events for one subscription-minute. The configured budgets therefore preserve at least about 3.7 times and 2.3 times those respective global observed peaks before Cloudflare's per-location behavior adds further permissiveness. Rate Limiting bindings are intentionally eventually consistent and maintain independent counters in each Cloudflare location, so distributed traffic and counter propagation can exceed these thresholds. They are denial-of-wallet guardrails, not exact quotas or monthly spending caps. Namespace IDs must remain unique within one account; change the checked-in example values if another Worker already uses them. See Workers Rate Limiting bindings, verified 2026-09-07.

The same configuration caps every invocation at 1,000 ms CPU and 1,000 total subrequests. Seven-day platform data ending 2026-09-07 put ordinary ingress CPU near 5 ms on average and approximately 22.7 ms at p99, but no verified maximum was available; the CPU choice is therefore about 44 times the observed p99, not a claimed max-headroom ratio. The subrequest ceiling preserves the bounded 100-row scheduled outbox and operations sweeps while reducing the Workers Paid default of 10,000. Cloudflare terminates an invocation that exhausts either ceiling. Inspect queue receipts, D1 operational state, and the platform resource-limit outcome before retrying or raising one.

Custom limits.cpu_ms and limits.subrequests require Workers Standard. A Free deployment must omit the custom limits block and rely on Cloudflare's fixed 10 ms CPU, 50 external-subrequest, and 1,000 internal-service-subrequest ceilings. That fallback removes the source-controlled 1,000 ms CPU boundary, and the observed p99 is above the Free CPU allowance, so this production workload is not verified on Free. A smaller deployment can keep subscriptions disabled and run representative signed canaries before enabling ingress, at the cost of providing no live relay until it proves the fixed allowance. The exact platform limits and configuration behavior were verified against Workers limits and Wrangler limits configuration on 2026-09-07.

2. Environment variables – your shell or CI

Read by Wrangler at deploy time; never committed.

Variable What it is
CLOUDFLARE_ACCOUNT_ID Your account id. Kept in the local environment or a GitHub Actions repository variable (not wrangler.jsonc) so the committed config carries no account identifier.
CLOUDFLARE_API_TOKEN Auth for Wrangler and operator commands. Store the CI value as a GitHub Actions repository secret; CI deployment needs Workers Scripts Write, Queues Read and Write, and D1 Read for the migration check. Other KV, D1, R2, and Zone WAF operations require their corresponding scopes. Guard each token and grant only the scopes its workflow uses.

3. Wrangler secrets – recoverable only by Worker code

Set with npx wrangler secret put <NAME>. Wrangler lists their names but does not return their values, while Worker code can read the values when it needs to call an outbound service. Record them in your own password manager. For local wrangler dev, mirror them into a .dev.vars file (gitignored).

Secret What it is
CF_ACCESS_TEAM_DOMAIN Your Zero Trust team subdomain (the part before .cloudflareaccess.com). Identifies your org, so it is a secret rather than a committed var.
CF_ACCESS_AUD The AUD tag of the Access application protecting /admin/*.
HMAC_<label> Per-subscription HMAC key for signed senders, such as GitHub or structured CloudEvents. The name is your choice; it is referenced by auth.secretEnv in routes.jsonc.
SINK_<name>_TOKEN Access token for an authenticated sink such as ntfy. Referenced by the sink's tokenEnv in routes.jsonc.
SINK_<name>_URL A sink credential, such as a Discord webhook URL. Referenced by the sink's urlEnv in routes.jsonc.
SINK_<name>_SIGNING_SECRET HMAC key used to sign the exact body sent by a generic webhook sink. Referenced by signingSecretEnv in routes.jsonc.

The names are a convention, not a requirement – whatever you put in routes.jsonc (auth.secretEnv, a sink's tokenEnv or urlEnv) must match a secret name you have set. pnpm sync validates that every referenced secret exists before it writes anything.

4. Provider configuration via routes.jsonc – reconciled with pnpm sync

routes.jsonc is Hookrelay's local provider topology in both authority modes. It contains a hash of each incoming slug, never the raw slug. It remains gitignored because some sink types, such as an unreserved ntfy topic, can still place bearer credentials there. pnpm sync validates the file, reads the authority mode and revision, and compares it with the authoritative configuration. The runtime reads SUBS/SINKS KV in legacy mode and indexed D1 entries in active mode; active D1 never falls back to configuration in KV.

In legacy mode, pnpm sync -y retains its complete desired-state behavior, including deletion of remote entries absent from the file. In active mode, plain pnpm sync remains the complete comparison but pnpm sync -y refuses an unscoped write. Guided lifecycle and fleet commands submit exact revision-checked D1 changes for the resources and policy fields they own. A manual --put-sub <name> selection explicitly applies that subscription's complete local configuration, including policy; --put-sink <name>, --put-retention, and --put-operations select their exact resources. Selecting omitted retention or operations configuration explicitly deletes that singleton resource. Add -y only after reviewing the scoped plan.

For an existing active subscription, ordinary lifecycle reconciliation preserves the online enabled, sinks, filter, and sinkFilters policy fields unless that workflow explicitly owns a field. Subscription retirement owns enabled; a sink switch owns sinks and sinkFilters; GitHub event-profile reconciliation owns filter; the fleet integrations document their narrower ownership. Online clients use that same authority for policy edits and, with the separate provision grant, reviewed GitHub subscription creation and hook installation.

Legacy KV writes pass values through an owner-only temporary file, never a command argument. The write subprocess suppresses both output streams, disables Wrangler log files and telemetry, and forces log sanitization. Active D1 changes use one bounded compare-and-swap transaction and a retained receipt. If an active response is lost, the command checks that receipt before reporting an uncertain outcome; rerun the same staged lifecycle command rather than reconstructing values.

Private provider reads capture values only in process memory. Legacy Wrangler reads suppress child diagnostics and log files, while active reads use the parameterized D1 API. Secret installation uses protected subprocess settings with values supplied through stdin. See configuration authority for migration, alias, policy, and recovery details.

Field What it is
baseUrl Optional public Worker origin used by pnpm sub:add to construct provider webhook URLs. Without it, the command discovers the single production custom domain attached to the Worker through Cloudflare's API and saves the result here. This local setup value is not written to runtime provider configuration.
emailBaseAddress Base address for Cloudflare Email Routing, such as [email protected]. pnpm sub:add appends a private plus-address route token. This local setup value is not written to runtime provider configuration.
operations Optional { sinks, alertCooldownMinutes, staleDeliveryMinutes } configuration for proactive health alerts. Every named alert sink must exist in sinks[]; alert delivery bypasses subscription fanout and skips a sink implicated by the signal.
retention Optional { r2Days?, d1Days? } lifecycle configuration. At least one lifetime must be present.
subs[].slugHash Lowercase SHA-256 digest of the private slug. The Worker hashes the incoming path segment and uses sub:sha256:<slugHash> for provider lookup; neither provider storage nor this file needs the raw slug. Generate it with pnpm sub:add rather than choosing a low-entropy slug.
subs[].source Source name (statuspage, github, cloudflare-notifications, uptime, cloudevents, email).
subs[].sinks Names of sinks (from sinks[]) to fan out to.
subs[].auth Optional { scheme, secretEnv, alternateSecretEnvs? } for signature or secret verification on top of the slug. GitHub uses github-sha256; structured CloudEvents use hookrelay-sha256. Alternate secret references allow staged HMAC rotation.
subs[].fallbackUrl Optional public HTTPS URL used only when a source event has no more specific target. Credentials, query strings, and fragments are rejected so the value remains safe to publish.
subs[].email.allowedSenders Optional email noise filter containing exact mailboxes or exact domains written as @example.com. Both the SMTP envelope sender and parsed From/Sender identities must match when the list is nonempty.
subs[].email.primaryLinkLabels Exact, case-insensitive visible labels allowed to select one email deep link, such as View incident. Every email URL is removed from sink-visible text, and zero or multiple distinct matching targets fail closed to fallbackUrl.
subs[].filter Optional subscription-wide delivery filter over normalized event types and severities. Event-type include and exclude accept lowercase exact values, including URI-shaped CloudEvent types such as urn:service:problem:v1, plus * or trailing wildcards such as pull_request.*; severity values are debug, info, warning, error, and critical. Exclusion wins within each dimension and every configured dimension must pass.
subs[].sinkFilters Optional per-sink filters keyed by names present in subs[].sinks. The subscription filter runs first, then each selected sink's filter, so one event can be delivered to one destination and deliberately filtered from another.
subs[].setup Local-only provider setup metadata, including a GitHub repository and event profile names. pnpm sync validates it but does not write it to runtime configuration.
sinks[].type: ntfy -> topic Bearer secret for unreserved topics. Anyone who knows an unreserved topic can read its notifications. Use a long random topic and treat it like a password.
sinks[].type: ntfy -> server Optional. Base URL of a self-hosted ntfy server; defaults to https://ntfy.sh.
sinks[].type: ntfy -> tokenEnv Optional Wrangler secret name containing an ntfy access token. Strongly recommended for Cloudflare Workers so publishes use the account's quota instead of a shared anonymous egress-IP quota. Authentication does not make an unreserved topic private.
sinks[].type: discord -> urlEnv Name of the Wrangler secret holding the Discord webhook URL (not the URL itself).
sinks[].type: webhook -> urlEnv Name of the Wrangler secret holding a public HTTPS endpoint. Credentials, fragments, private hosts, and redirects are rejected.
sinks[].type: webhook -> signingSecretEnv Name of the Wrangler secret used for the outbound X-Hookrelay-Signature-256 HMAC.

Incoming HTTP slugs, email plus-route tokens, and Discord webhook URLs are bearer values, but Hookrelay uses them differently. It hashes route tokens because it only needs to recognize an incoming value. It keeps the Discord URL in a Worker secret because it must recover that value to make an outbound request. Guard generated webhook URLs and email addresses, unreserved ntfy topics, and sink credentials in a password manager and keep them out of logs, screenshots, and commits.

Guided setup lifecycle

sink:add and sub:add follow this order:

  1. Read and validate gitignored routes.jsonc and .dev.vars. sink:add reads the destination URL through concealed input. sub:add generates its private values and resolves the HTTP origin or email base address for the selected source.
  2. Write the local desired state. Routing and hash-only subscription data go to routes.jsonc; Worker-readable secrets go to .dev.vars, which is kept at mode 600. The commands do not populate Miniflare's local KV.
  3. Save receiver custody to a new owner-only file under ignored .hookrelay-private/, or at --private-export <new-private-file>. Copy the bearer URL, email route and HMAC from that protected artifact into appropriate private custody. Routine output names the artifact; the raw incoming slug is not added to .dev.vars, because the Worker only needs its hash.
  4. Ask before changing production. Approval installs any new Wrangler secret, then runs a scoped pnpm sync comparison for the selected provider resource.
  5. Ask whether to apply that plan. Approval writes only that exact resource to legacy KV or active D1.
  6. For a non-manual GitHub selection, ask whether to create the repository webhook. This only happens after the remote sender secret and subscription route are live.

If every prompt is approved, no later sync is needed. Declining a production prompt or encountering a remote error leaves the completed local files in place. Install any deferred Wrangler secret, preview the named resource with pnpm sync --put-sink <name> or pnpm sync --put-sub <name>, then add -y to apply it. Those commands finish Worker state only; if automatic GitHub hook creation was skipped, create the hook manually from the protected receiver recovery saved by sub:add. Passing -y or --yes skips the approvals but keeps the same local-first order.

Adding a sink

pnpm sink:add <name> discord is the guided Discord path. It reads the webhook URL from concealed input, validates that it is a Discord webhook, derives SINK_<NAME>_URL, appends a sink containing only that secret reference to routes.jsonc, and mirrors the URL into the gitignored .dev.vars. It then offers to install the same value as a Wrangler secret, preview the exact provider change, and apply it.

Discord descriptions preserve supported Markdown while turning embedded HTML structure into readable text. Long descriptions end at a readable boundary with an explicit continuation notice, and selecting a linked embed title opens the complete source event.

pnpm sink:add <name> webhook adds a generic signed destination. It reads a public HTTPS endpoint without echoing it, generates a separate HMAC key, writes both values only to .dev.vars, stores their secret names in routes.jsonc, and offers one bulk Wrangler installation before provider synchronization. Save the endpoint under SINK_<NAME>_URL and the generated signing key under SINK_<NAME>_SIGNING_SECRET when the command prints the password-manager handoff.

The sink name is a stable routing label, not a description of what feeds it. discord is a sensible first name. If several Discord destinations exist, names such as discord-personal and discord-builds produce independent SINK_DISCORD_PERSONAL_URL and SINK_DISCORD_BUILDS_URL secrets. Subscriptions refer to these names and remain independent of the sink implementation.

The command refuses to replace an existing sink or secret. A raw destination URL never enters routes.jsonc, provider configuration, process arguments, or command output. Save it in your password manager under the derived key printed by the command.

Pass -y or --yes to skip the production confirmation prompts. The URL is still read through concealed input when interactive, or from stdin for automation.

Renaming a deployed sink

A deployed sink name is both a routing label and a delivery identity, so renaming it in one routes.jsonc edit can orphan queue messages that still carry the old name. Use the phased rename command instead:

pnpm sink:rename discord discord-service-status
pnpm sink:rename discord discord-service-status --switch
pnpm sink:rename discord discord-service-status --finalize

The prepare phase copies convention-derived local credentials such as SINK_DISCORD_URL to SINK_DISCORD_SERVICE_STATUS_URL, installs the new Wrangler secret after confirmation, and deploys both sink names with the new secret reference. Subscription routes still use the old name during this phase.

The switch phase verifies both remote sink aliases and the new secret before changing every subscription reference to the new name. The old alias remains deployed and uses the new secret, so already queued deliveries and historical admin retries continue to work.

The finalize phase explicitly deletes the obsolete local and Wrangler secret. With active D1 it atomically moves the original stable resource identity to the new name, retains the old runtime name as a D1 alias for queued and historical retries, and removes the temporary duplicate name from routes.jsonc. Legacy KV retains both equivalent local entries because it has no separate alias record. Custom secret names that do not follow the SINK_<NAME>_<CONCEPT> convention remain unchanged.

Each phase accepts -y or --yes to apply its production changes without confirmation. Run the phases separately. In legacy mode, let the prepare change propagate before switching subscription routes because Workers KV reads are eventually consistent; active D1 changes are revisioned and atomic, while the command retains the same explicit phase and verification boundaries.

Renaming a sink secret reference

Changing a sink's urlEnv, tokenEnv, or another *Env field does not require changing the sink name. Use the secret rename command with the existing sink name and the old and new Wrangler secret names:

pnpm sink:secret:rename discord SINK_DISCORD_URL SINK_DISCORD_SERVICE_STATUS_URL
pnpm sink:secret:rename discord SINK_DISCORD_URL SINK_DISCORD_SERVICE_STATUS_URL --finalize

The prepare phase copies the value from .dev.vars, installs the new Wrangler secret, and then syncs the selected sink configuration to reference it. The old secret remains available through the explicit verification boundary, including while cached legacy KV values expire, so queued messages carrying the unchanged sink name can dispatch safely.

The explicit finalize phase verifies that provider configuration matches the new local sink config and that no route references the old secret before deleting it from Wrangler and .dev.vars. Sink names, subscription routes, queued delivery identities, and historical retry links do not change.

Adding a subscription

In legacy mode, routes.jsonc is the complete desired state for subscriptions and sinks, so keep every existing entry when adding one because pnpm sync -y removes absent KV entries. In active mode it remains the complete topology comparison, but ordinary scoped commands preserve online policy they do not own and an unscoped pnpm sync -y is rejected.

Run the guided command after the destination sink exists:

pnpm sub:add <subscription-name> <source> [-s <sink-name>]

If exactly one sink exists, it is selected automatically. Repeat -s or --sink to route to several sinks. The command generates a private incoming slug, stores only its SHA-256 hash in routes.jsonc, and saves the raw slug in protected receiver recovery for your password manager. Signed providers also get a per-subscription sender secret, mirrored into .dev.vars and offered to Wrangler. Production changes are previewed and confirmed unless -y or --yes is supplied.

sub:add also accepts -b for --base-url, --fallback-url for a public target used when an event has no specific URL, --email-base for an Email Routing address, repeated --allow-sender filters, repeated --primary-link-label selectors, -r for --repo, and -e for --events. --private-export <new-private-file> selects receiver recovery custody. Run any setup command with -h or --help for its complete usage.

fallbackUrl never replaces an event URL supplied by an adapter. For example, a Statuspage incident keeps its incident shortlink while a component status change can link to the provider's canonical status page.

GitHub event profiles can supply a shared filter for every subscription using that profile. Configure filter.eventTypes directly in a subscription only when it needs an explicit override:

"filter": {
  "eventTypes": {
    "include": ["push.*", "pull_request.opened", "pull_request.closed"],
    "exclude": ["push.deleted"]
  },
  "severities": {
    "exclude": ["debug"]
  }
},
"sinkFilters": {
  "ntfy:urgent": {
    "severities": {
      "include": ["error", "critical"]
    }
  }
}

An omitted include accepts every value, an omitted exclude rejects none, and at least one must be present in each configured dimension. Filtering happens after authentication and normalization. Every configured sink gets a durable decision row: matching destinations are queued, while nonmatching destinations receive a terminal filtered decision with subscription-filter or sink-filter as its reason. Adapter record-only events use source-record-only, and no configuration filter can re-enable them. All filtered events remain recorded in D1 and R2 and receive the ordinary successful sender response. An explicit subscription filter replaces its profile-derived filter.

For a GitHub repository, pass the repository separately instead of deriving it from the subscription name. Subscription names may still use a readable namespace such as github:example-owner/example-repo:

pnpm sub:add github:example-owner/example-repo github --repo example-owner/example-repo --events stars,watchers

The --events value accepts comma-separated profiles. Profiles compose by set union, so activity,alerts combines concise repository activity with actionable security findings and recommended,stars adds star activity to the broad general-purpose set. push remains the default, while all and manual are exclusive presets. The complete profile-to-event table and noise guidance live in GitHub event profiles.

The base URL resolves from an explicit --base-url, then the value already saved in routes.jsonc, then the single production custom domain attached to the Worker named in wrangler.jsonc. Automatic discovery uses CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN; an absent or ambiguous domain produces an error asking for --base-url.

With manual, copy the payload URL and sender secret from protected receiver recovery, choose JSON content, and keep SSL verification enabled. With an explicit --github-auth app|token|gh choice and a non-manual profile, the command checks for an existing hook with the same URL and creates it only after the Worker secret and provider route are live. App/token modes require a provider descriptor, admitted workspace and protected credential file; see GitHub authentication. With no authentication choice, create the upstream hook manually.

Provider-specific setup

Structured CloudEvents and signed webhook delivery

Create a generic route and destination with the guided commands:

pnpm sink:add automation webhook
pnpm sub:add automation-events cloudevents --sink automation

Send a valid signed test event through the configured route:

pnpm cloudevents:send

The sender reads the full private Hookrelay URL and sender HMAC through concealed input, generates a uniquely identified structured CloudEvent, signs its exact bytes, and gives the URL and signature to curl through stdin configuration rather than process arguments. The temporary request body is mode 0600 and is removed after the request. For non-interactive use, provide the URL and HMAC as exactly two newline-separated stdin values.

CloudEvents ingress accepts only POST requests with application/cloudevents+json. The envelope must use specversion: "1.0" and contain nonempty id, source, and type strings. Sign the exact UTF-8 request bytes with the subscription HMAC and send the lowercase digest as X-Hookrelay-Signature-256: sha256=<hex>. The optional time, subject, title, severity, url, and data fields become the normalized event; a missing severity is info, invalid or private URLs are omitted, and the complete verified envelope remains available as raw data.

The webhook sink sends application/cloudevents+json without following redirects. Its Idempotency-Key and X-Hookrelay-Delivery-Id remain stable for the same persisted event and sink across automatic or manual retries. X-Hookrelay-Event-Id identifies the persisted event, while the envelope data includes the current delivery generation and attempt. X-Hookrelay-Signature-256 is HMAC-SHA256 over the exact outbound body using signingSecretEnv; receivers should verify the bytes before parsing JSON and deduplicate on the idempotency key.

For staged ingress-key rotation, add the next Wrangler secret name to auth.alternateSecretEnvs, apply the exact subscription with pnpm sync --put-sub <name>, switch senders, then remove the old reference and apply the same exact subscription again. Hookrelay tries every available referenced key without exposing which one matched.

Forwarding email notifications

Email ingress is provider-independent. Hookrelay parses MIME with postal-mime, prefers a text/plain body, converts HTML-only bodies to safe plain text without loading remote content, and stores the untouched MIME message in R2. Before normalized content enters persistence or a sink, Hookrelay removes every HTTP and HTTPS target from the subject and body. It promotes a deep link only when the visible label matches primaryLinkLabels and all matches identify one distinct target; otherwise the event uses fallbackUrl when configured. Attachment metadata is retained, but attachment content is not sent to sinks. The Worker rejects a raw message larger than 1 MiB, including attachments. Generated email routes use 128-bit lowercase hexadecimal tokens, and the Worker normalizes incoming token case because mailbox providers may lowercase recipient addresses.

Cloudflare setup is one-time:

  1. Deploy this Worker, then open Compute > Email Service > Email Routing in the Cloudflare dashboard. Onboard the zone or, preferably, add a dedicated routing subdomain such as mail.example.com so existing apex-domain mail remains untouched.

  2. In Email Routing > Settings, enable subaddressing.

  3. In Routing Rules, create the base address, such as [email protected], choose Send to a Worker, and select the Hookrelay Worker. One rule handles every generated relay+<route>@mail.example.com address because Cloudflare preserves the plus detail in message.to.

  4. Add and sync a subscription, then copy the email address from protected receiver recovery into the provider's email subscription form:

    pnpm sub:add openai-status email --email-base [email protected] \
      --primary-link-label "View incident" \
      --fallback-url https://status.openai.com/

The label is an explicit trust decision about the provider's visible template, not its tracking hostname. OpenAI's incident and unsubscribe links can share the same tracking URL shape, so Hookrelay selects the exact View incident label and removes every other target. If the template changes, missing or ambiguous matches use the public status page instead of exposing an unclassified URL. The parser handles multipart encodings generically, and the provider's confirmation message follows the same guarded path to Discord or another selected sink.

If the provider's sender identity is already known, add one or more exact mailboxes or exact domains:

pnpm sub:add service-status email --email-base [email protected] \
  --allow-sender [email protected] \
  --allow-sender @mailer.example.com \
  --primary-link-label "View incident" \
  --fallback-url https://status.example.com/

If the identity is not documented, omit the allowlist. View raw in /admin/events exposes the original headers when troubleshooting, while the normalized R2 object records the envelope sender. Add filters only after confirming every legitimate identity, then preview with pnpm sync --put-sub <name> and add -y to apply that explicit local policy. The allowlist is a noise filter, not cryptographic authentication: the documented Email Worker message interface exposes envelope fields, headers, and raw MIME, but no verified SPF or DKIM result.

Treat each generated address as a bearer route. Cloudflare preserves the full plus address in Email Routing activity logs, and Hookrelay's protected raw MIME contains the recipient plus any confirmation or unsubscribe links. Hookrelay does not copy the route token into D1, normalized R2 objects, queue messages, or its own logs. Unselected email targets remain only in protected raw MIME; one explicitly labeled primary target or the public fallback may enter normalized storage and sinks. Restrict access to the Cloudflare account, R2 bucket, admin page, and destination sink accordingly.

Updating GitHub event profiles

Use the saved subscription name to replace an existing webhook's event selection:

pnpm github:events github:example-owner/example-repo --events recommended,stars --github-auth gh

The command updates only setup.github.eventProfiles in local routes.jsonc, preserving its comments, applies the selected subscription's compiled filter field to provider configuration, then finds the exact repository hook by hashing each Hookrelay URL slug and comparing it with the subscription's saved slugHash. It changes GitHub only after the provider filter is accepted, previews the GitHub change, and asks before applying it; pass -y or --yes to skip confirmations. Neither the private URL nor its slug is printed.

If you edit eventProfiles in routes.jsonc yourself, omit --events to reconcile that saved selection:

pnpm github:events github:example-owner/example-repo --github-auth gh

For several edited subscriptions, preview the saved selections together, then apply the reviewed plan:

pnpm github:sync --github-auth gh
pnpm github:sync --github-auth gh -y

github:sync reads only the GitHub subscriptions already saved in routes.jsonc; it does not discover or enroll repositories. It checks matching hooks for non-manual selections and the secrets needed for changed hooks before writing, applies only the GitHub subscriptions' provider filter fields, and changes only webhook event arrays that differ. It rechecks hook state before applying and verifies the updated hooks afterward. Each run shows one provider configuration plan when GitHub subscriptions exist; a provider plan with no changes can still precede GitHub hook updates. The preview prints subscription names and event selections without webhook URLs or secrets. The manual preset leaves GitHub's event selection unchanged.

GitHub may return the same events in a different order, so order alone does not trigger an update. GitHub's general webhook update clears an existing secret unless the request supplies it again, so the command requires the subscription's auth.secretEnv value in .dev.vars and resends it with the unchanged URL, content type, SSL setting, and active state. The secret-bearing request travels through stdin and is never placed in process arguments or output. Selecting manual changes local metadata but leaves GitHub's checkboxes under manual control.

Staged subscription and sink retirement

Individual subscriptions and sinks use a separate strict, versioned retirement manifest. Pass its path explicitly on every command; the file is created as a regular, non-symlink file with mode 0600, can contain recoverable secret values, and must be stored privately. It is not the GitHub fleet manifest.

pnpm sub:retire <subscription> --manifest <file>
pnpm sub:retire <subscription> --manifest <file> --finalize

pnpm sink:retire <sink> --manifest <file>
pnpm sink:retire <sink> --manifest <file> --finalize

The first subscription phase disables the local route, previews an exact provider change that owns only enabled, and asks before applying it; -y applies without prompts. Finalization requires the production route to be disabled, archives the full hash-only subscription configuration and locally recoverable auth values before destructive work, records and removes only a matching Hookrelay-owned GitHub hook when present, removes the route, deletes that exact provider entry, and deletes auth secrets only when no remaining route references them. Missing local values are recorded as unavailable and their remote secrets are retained rather than becoming unrecoverable.

The first sink phase requires no enabled subscription or operations alert to reference the sink, moves its definition from sinks to retiredSinks, and keeps that definition in provider configuration with retired lifecycle state in active D1. Disabled subscriptions may continue to reference the staged sink while their own retirements are completed. Sink finalization uses the parameterized D1 query API to prove that no pending, queued, processing, retrying, or exhausted delivery row names the sink. It then archives the sink and recoverable values, removes the retired definition and exact provider entry, and deletes only unshared sink secrets. The read-only D1 check uses CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, and the EVENTS_DB database ID from wrangler.jsonc; the token needs D1 Read permission.

Both finalizers write recovery data before provider configuration or secret deletion and persist phase markers after each successful boundary. A cancelled or interrupted finalization can leave the local desired state advanced while the disabled production resource remains safe; rerun the identical command to continue. Existing manifest fields retain their versioned names across authority modes for recovery compatibility. Manifests and logs never print raw slugs or secret values.

Higher-level operators that own Hookrelay's deployment files may pass --routes <file> and --dev-vars <file> to sub:retire; the selected route path is also used by each scoped sync preview and apply. Pass --expected-slug-hash <hash> when another control plane owns the raw route slug so a same-named but differently owned route or archive is rejected before mutation. pnpm sync --routes <file> provides the corresponding read-only comparison for an explicit hash-only route file; complete active changes through the owning lifecycle command or an exact --put-* selection.

pnpm new-sub <name> <source> remains available as a low-level generator for HTTP sources. It prints a hash-only stub and saves the private URL in protected receiver recovery; it leaves routing files, Worker secrets, provider configuration and provider hooks unchanged. Email sources require pnpm sub:add because their route also needs a base address and email-specific configuration.

Statuspage incident and scheduled-maintenance updates use the same statuspage subscription. No second hook is needed for maintenance.

Optional integrations

integrations/github-fleet contains the repository-fleet tooling used to dogfood Hookrelay across many GitHub repositories. It manages repository discovery, hooks, per-repository HMACs, event-profile selections, HMAC and bearer-path rotation, reconciliation, retirement, and verification. It is not required by the Hookrelay runtime or by ordinary GitHub subscriptions.

integrations/subscription-fleet reconciles externally managed, non-repository signed CloudEvents subscriptions. A private manifest supplies recovery values and optional Cloudflare Worker sender-secret targets; the integration prepares hash-only routes, applies selected Worker secrets and provider entries, and verifies authentication with a sink-filtered event. It is not required by ordinary CloudEvents subscriptions.

Durable delivery

The webhook request writes the event to D1 and R2 and creates one D1 delivery decision per configured sink before returning 200. Allowed destinations start as pending; policy-rejected destinations are terminal filtered rows and are never queued. Hookrelay publishes a compact queue message containing only the event ID, sink name, and delivery generation for each pending destination, and the consumer reloads the normalized event from R2. A failure in one sink never resends a sink that already succeeded or re-evaluates a settled filtered decision.

The primary queue retries failures with bounded exponential backoff and honors a valid HTTP Retry-After delay. After automatic retries are exhausted, Cloudflare moves the message to hookrelay-delivery-dlq, whose consumer marks the D1 row exhausted. That final D1 update also retries for the queue's retention window if D1 is unavailable. The Access-protected admin page exposes a retry button for that sink only.

If queue publication itself fails, the row remains pending. The Cron Trigger republishes pending rows every five minutes, so recovery does not depend on the webhook provider sending the event again. Every publication reserves a new generation first, which makes late messages from an uncertain earlier publish or manual-retry cycle harmless.

Delivery is at least once. The D1 claim and lease suppress ordinary duplicate queue messages, but a sink can still receive a duplicate if it accepts a request and the Worker stops before recording success. That tradeoff avoids silently losing the notification.

Operational health

The sanitized observability guide describes the dedicated Tail Worker, retained safe summaries, deployment order and Paid requirement. Raw producer logs remain unpersisted because receiver paths contain bearer secrets. Stored telemetry complements protected delivery receipts and does not establish notification delivery or complete coverage.

Use pnpm operations --help for reviewed operational dispositions. This workflow records why historical signals or exhausted deliveries were acknowledged, preserves their failed state and history, and never sends a notification. The CLI and scoped management API share the same exact-state review and receipt implementation.

Hookrelay records actionable known-route ingress failures, coalesced subscription rate-limit refusals, exhausted deliveries, and stale active deliveries as fixed-code operational signals. Signal summaries are selected from constants, aggregate by a secret-free fingerprint, and never contain request paths, slugs, payloads, exception text, source addresses, or sink credentials. Unknown routes remain unrecorded so internet scanning cannot create health data.

If D1 is temporarily unavailable while a signal is being recorded, the Worker writes a compact fixed-field record with an expiration under ops-fallback: in the SUBS namespace. Scheduled maintenance imports and deletes those records after D1 recovers. pnpm sync preserves fallback keys while reconciling subscriptions and the special operations configuration key.

When operations is configured, scheduled maintenance detects deliveries that exceed staleDeliveryMinutes and sends due alerts directly to the named sinks. Per-signal, per-sink state enforces alertCooldownMinutes; an alert skips the sink implicated by that signal, and alert-send failures store only a fixed error without generating another signal. A successful delivery or manual redrive resolves the corresponding current delivery condition while retaining its aggregate history.

The Access-protected /admin/health page shows delivery totals, the oldest active delivery, recent redacted signals, alert state, last accepted event per subscription, last successful delivery per sink, and configured retention. From a filtered Needs attention view, Retry matching deliveries opens a confirmation page that rechecks the filters on POST, requires the browser's same origin, redrives only still-exhausted rows, enforces a fixed batch maximum, and reports succeeded, skipped, and capped selections. Individual retry remains available for a single exhausted sink.

Scoped management

Reviewed lifecycle management adds encrypted destination custody, subscription retirement/restoration, and explicit adoption, update, and deletion of owned upstream hooks, with protected input and durable recovery.

The scoped management API provides a versioned, credential-restricted contract for operator clients, including dashboard backends, CLI tools and MCP services: bounded redacted reads, exact-state retry reviews, and durable reconciliation receipts. Its separately granted GitHub setup flow lets any authorized client create reviewed subscriptions and install their upstream webhooks using the same provider configuration authority. Maintainer HQ is one integration of this shared contract. Raw-event access remains separate.

Retention

Retention is disabled by omission. Add retention.r2Days to manage raw and normalized objects under the events/ R2 prefix, add retention.d1Days to prune persisted event metadata, or configure both. Removing d1Days stops D1 pruning after provider synchronization. Removing r2Days makes absence of Hookrelay's managed lifecycle rule the desired state, so a confirmed pnpm retention apply removes only that rule and stops future managed R2 expiration. Neither disablement path deletes data directly.

The R2 lifecycle workflow reads the EVENTS_RAW bucket name from wrangler.jsonc, the desired lifetime from routes.jsonc, and Cloudflare credentials from CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN. The token needs the Workers R2 Storage Write permission. Credentials remain in environment headers and never enter command arguments or plan output.

pnpm sync --put-retention
pnpm sync --put-retention -y
pnpm retention plan
pnpm retention apply
pnpm retention verify
pnpm sync

Every retention phase first requires the selected provider retention value to match routes.jsonc; it refuses before any R2 request when provider synchronization is incomplete. plan otherwise performs only a lifecycle GET. apply asks before its production PUT unless -y is supplied, preserves every unrelated lifecycle rule value-for-value, and adds, replaces, or removes only the stable hookrelay-events-retention rule. With r2Days configured, verify requires that rule to be enabled, scoped to events/, age-based, and exactly equal to the configured lifetime. Without r2Days, it requires the managed rule to be absent. Cloudflare applies lifecycle expiration asynchronously, so an applied rule does not imply that every older object disappears immediately.

After retention configuration reaches the selected runtime authority, scheduled maintenance claims at most one D1 pruning pass per day. Each pass selects a bounded oldest-first event batch, deletes those events, relies on the event foreign key to cascade delivery rows, and records the last successful cutoff and event count in maintenance_state. A failed pass creates the same fixed-code signal or KV fallback used by operational health.

The admin raw endpoint reports 410 expired only when a missing object's event is older than configured r2Days. If R2 retention is omitted or runtime configuration cannot be read, a missing object remains a 502 storage failure rather than being assumed expired.

For an existing deployment that predates queues, the D1 migration imports old successful fanout results as delivered and old failures as exhausted. The Worker performs the same import lazily if it encounters an old event that was written after the migration. Roll out in this order so the Worker never references missing resources or schema:

npx wrangler queues create hookrelay-delivery
npx wrangler queues create hookrelay-delivery-dlq
npx wrangler d1 migrations apply hookrelay-events --remote
npx wrangler deploy

Smoke tests

After deploy, run each of these and check /admin/events to confirm. They are POSTs that mimic real webhook senders.

# Statuspage
curl -s -X POST -H 'content-type: application/json' \
  https://hooks.example.com/hook/statuspage/<your-statuspage-slug> \
  -d @test/fixtures/statuspage/incident-investigating.json

# GitHub (HMAC required) -- compute the signature first
BODY=$(cat test/fixtures/github/issues-opened.json)
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$HMAC_GITHUB_HOOKRELAY" -hex | awk '{print $2}')
curl -s -X POST \
  -H 'content-type: application/json' \
  -H "x-github-event: issues" \
  -H "x-github-delivery: $(uuidgen)" \
  -H "x-hub-signature-256: sha256=$SIG" \
  https://hooks.example.com/hook/github/<your-github-slug> \
  -d "$BODY"

# Cloudflare Notifications
curl -s -X POST \
  -H 'content-type: application/json' \
  -H "cf-webhook-auth: $YOUR_CF_SECRET" \
  https://hooks.example.com/hook/cloudflare-notifications/<your-cf-slug> \
  -d @test/fixtures/cloudflare/ddos-start.json

# UptimeRobot
curl -s -X POST -H 'content-type: application/json' \
  https://hooks.example.com/hook/uptime/<your-uptime-slug> \
  -d @test/fixtures/uptime/down.json

# Structured CloudEvents
BODY='{"specversion":"1.0","id":"smoke-1","source":"urn:example:smoke","type":"example.smoke","data":{"message":"hello"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$HMAC_CLOUDEVENTS_SMOKE" -hex | awk '{print $2}')
curl -s -X POST \
  -H 'content-type: application/cloudevents+json' \
  -H "x-hookrelay-signature-256: sha256=$SIG" \
  https://hooks.example.com/hook/cloudevents/<your-cloudevents-slug> \
  -d "$BODY"

Expected for each webhook command: it returns {"ok":true,"eventId":"..."} after the event and delivery intent are durable. The event appears at /admin/events, normally moving from queued or processing to delivered within seconds.

For email, send a message to the address saved in protected receiver recovery by pnpm sub:add, or complete a provider's email-subscription confirmation flow. If an allowlist is active, send from an allowed identity. Email has no HTTP response to inspect; the event should appear with source email and type email.received.

Writing an adapter

To support a new webhook source ("Linear"):

  1. Create src/adapters/linear.ts exporting an Adapter:
    import type { Adapter } from '.'
    import type { NormalizedEvent, Subscription } from '../types'
    
    const adapter: Adapter = {
      sourceType: 'linear',
      async verify(req, raw, sub, env) {
        // implement signature verification or no-op if unsigned
      },
      async parse(req, raw, sub) {
        const payload = JSON.parse(new TextDecoder().decode(raw))
        return {
          source: 'linear',
          subName: sub.name,
          type: 'issue.created', // or whatever the event is
          id: payload.deliveryId, // or a content hash if Linear has no per-delivery id
          timestamp: payload.createdAt,
          title: payload.data.title,
          body: payload.data.description ?? '',
          url: payload.url,
          severity: 'info',
          raw: payload,
        }
      },
    }
    export default adapter
  2. Add one line to src/registry.ts:
    import linear from './adapters/linear'
    // ...
    registerAdapter(linear)
  3. Add an HTTP transport profile and any sender-authentication scheme to scripts/subscription-sources.ts.
  4. Write tests in test/unit/adapters/linear.test.ts -- see existing adapter tests for the shape.

Writing a sink

To support a new destination ("Slack"):

  1. Create src/sinks/slack.ts:
    import { z } from 'zod'
    import type { Sink } from '.'
    import { postJson } from '../lib/http'
    
    const configSchema = z.object({ urlEnv: z.string().min(1) }).strict()
    type Config = z.infer<typeof configSchema>
    
    const sink: Sink<Config> = {
      type: 'slack',
      configSchema,
      async send(event, config, env, _context, fetchFn) {
        const url = (env as any)[config.urlEnv]
        if (!url) throw new Error(`slack webhook url not set: ${config.urlEnv}`)
        await postJson(url, { text: `*${event.title}*\n${event.body}` }, { fetch: fetchFn })
      },
    }
    export default sink
  2. Add one line to src/registry.ts:
    import slack from './sinks/slack'
    // ...
    registerSink(slack)
  3. Add the type to knownSinkTypes and the schema to sinkSchemas in scripts/sync.ts.
  4. Tests in test/unit/sinks/slack.test.ts.

Verification

Run pnpm typecheck and pnpm test before integration. The test command checks the documentation asset and dependency boundaries, runs native Node operator tests, and runs the Worker suite. pnpm test:node exercises actual filesystem permissions, private subprocess behavior, and installed Wrangler file-input compatibility against isolated local storage; it does not access production.

Versioning and releases

Prepare a version on a clean named topic branch with npm version --no-git-tag-version <major|minor|patch>. The preversion guard refuses main, detached or dirty checkouts and implicit tag creation, then runs typechecking and tests without production credentials. npm updates the version metadata without creating a commit, tag or provider push. Review and commit those files, then integrate the version PR through the repository's normal required checks.

After the version PR is integrated, explicitly approve tag publication from clean updated main. Confirm that the selected package version matches the intended tag, then create and push that tag:

git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

Replace vX.Y.Z with the integrated package version. Tag publication prepares a release draft; deploying an instance is a separate manual operation.

The pushed tag triggers the release draft GitHub Actions workflow. It checks out the exact tag, requires the tag to equal v plus the package version, installs from the lockfile, repeats typechecking and tests, and creates a draft GitHub Release with generated starter notes. The write-capable token is available only to the dependent release-creation job, not to dependency installation or project scripts. Rerunning the workflow is safe when a draft or published release already exists.

For an existing tag whose automatic run was missed or failed, run the workflow manually from GitHub Actions or use:

gh workflow run release.yml --field tag=vX.Y.Z

Review and refine the draft's highlights, compatibility notes, upgrade instructions, and full-changelog link before publishing it through GitHub. Draft creation is automatic; publication remains an explicit human decision.

If the intended release commit changes after a tag is created, run pnpm retag from clean main. The command moves the current package version tag to HEAD, preserves an existing annotated tag message, and force-pushes only that tag. Draft GitHub Releases preserve this repair window; review their generated notes against the repaired tag before publication. The command queries GitHub before changing the local tag and refuses to move a tag attached to a published Release. For an intentional repair of an already published release, use pnpm retag --allow-published; this rewrites a public version tag and should remain exceptional.

Project layout

The repository separates runtime code, operator tooling, and optional integrations:

  • src/ contains the deployed Worker, built-in adapters and sinks, persistence, delivery, and administration
  • scripts/ contains generic operator commands, with source-specific helpers under scripts/providers/
  • integrations/ contains optional operational integrations that depend on Hookrelay but are not part of its runtime identity
  • test/ contains core, generic operator, and provider-support tests; integrations keep their dedicated tests beside their code

Key runtime files:

  • src/router.ts – request pipeline (slug parse, provider lookup, verify, parse, persist, enqueue)
  • src/email.ts – MIME parsing, sender filtering, email normalization, and routing
  • src/ingest.ts – shared durable persistence and delivery preparation for HTTP and email
  • src/delivery.ts – D1 outbox, queue consumers, retries, dead-letter handling, and manual redrive
  • src/persistence.ts – D1 idempotent insert plus R2 raw and normalized objects
  • src/fanout.ts – validation and dispatch for one sink attempt
  • src/registry.ts – the only place where adapters and sinks are wired in

Project cover automation

The cover renders the actual admin event handler and styles against an in-memory event/delivery fixture. It uses no operator configuration, remote database, or live webhook content. Install the capture tooling with npm ci --prefix tools/cover and npm exec --prefix tools/cover -- playwright install chromium, then run npm run capture:cover. Use -- --output FILE to write a review image elsewhere. CI captures during source verification and retains the image as an artifact. Successful main builds open an image-only bot PR for a changed docs/screenshots/cover.png; pull requests render without publishing, and superseded revisions skip publication. Reruns reuse the source-bound automation branch and PR. The publisher compares the candidate image bytes and file mode with the generated artifact, then validates the exact source, repository, bot author, checked candidate head and sole modified image before admitting only that PR's CI workflow and requesting ordinary auto-merge for that same head.

Enable GitHub Actions PR creation and repository auto-merge for automated cover publication. Keep default workflow permissions read-only; the dependent main-only publisher receives scoped contents, PR and Actions write permissions. Bot-created PR workflow runs can require approval. scripts/start-cover-checks.mjs admits only the checked image PR's allowed workflow; it does not approve PR reviews or bypass required checks. See GitHub's workflow trigger rules.

Cover image density

The project cover is rendered at 4x pixel density while preserving its logical viewport, so enlarged previews retain more detail. Higher density does not increase the displayed text size; use zoom to inspect small labels.

License

MIT. See LICENSE.

Support

Best-effort. Issues and PRs welcome but not promised.

Footnotes

  1. The binding ids are opaque, account-scoped resource handles, not secrets: they name a resource but grant no access on their own. Every Cloudflare API call still requires your API token, and a binding id from one account cannot be used from another. ↩

About

Route webhooks and email to push, chat, or signed webhooks, with queued delivery, retries, and an event dashboard.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages