Shipping outbound webhooks with HMAC signing

· · 38 views
Table of Contents

An RSS reader is, at heart, a machine for noticing that something changed. For a long time the only thing SlashFeed did with that knowledge was light up a badge in the UI. Users kept asking for the other half: let me act on it — post the new entry to Slack, drop it in a queue, trigger a build. That is an outbound webhook, and outbound webhooks are one of those features that look trivial until you remember they involve making HTTP requests to URLs a stranger typed into a form.

This post walks the whole path: the schema, the signing protocol, the Oban worker that does the delivery, and the fan-out that wires it into the ingestion pipeline.

Two tables: webhooks and their deliveries

The schema is deliberately boring. A webhooks row is the user's configuration; a webhook_deliveries row is one attempt to hit it.

create table(:webhooks, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
  add :url, :string, null: false
  add :label, :string
  add :secret, :string, null: false
  add :events, {:array, :string}, null: false, default: ["new_entries"]
  add :enabled, :boolean, null: false, default: true
  add :last_attempted_at, :utc_datetime
  add :last_succeeded_at, :utc_datetime
  timestamps(type: :utc_datetime)
end

create unique_index(:webhooks, [:user_id, :url])

Two design choices matter here. First, last_attempted_at and last_succeeded_at live on the webhook itself, denormalised out of the delivery log, so the management screen can render a health dot without aggregating the whole history on every page load. Second, the secret is generated server-side, never accepted from the client:

defp generate_secret do
  :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
end

Thirty-two bytes of CSPRNG output, shown to the user exactly once at creation time — the flash message says "copy now, shown once" and means it.

The webhook_deliveries table is the audit trail: status (pending / success / failed), status_code, a truncated response_body, error_message, duration_ms, and which attempt produced it. Every attempt writes a row, which means a webhook that flaps produces three delivery records per event — exactly what you want when a user files a "why didn't my endpoint fire" ticket.

The signing protocol

If you POST JSON to an arbitrary URL, the receiver has no way to know the request genuinely came from you rather than from anyone who guessed the endpoint. The fix is a shared secret and a signature. SlashFeed signs the raw request body with HMAC-SHA256 and ships the digest in a header:

defp sign(secret, body) do
  :crypto.mac(:hmac, :sha256, secret, body) |> Base.encode16(case: :lower)
end

The signed value is the exact bytes of the JSON body — not a re-serialisation, not a canonicalised form. That distinction is the entire security property: the receiver must verify against the raw bytes it received, because any re-encoding on either side breaks the MAC. The digest goes out as a lowercase hex string, prefixed and namespaced:

headers: [
  {"content-type", "application/json"},
  {"x-slashfeed-signature", "sha256=#{sig}"},
  {"x-slashfeed-event", "new_entries"}
]

The sha256= prefix is there so the algorithm is self-describing and the protocol can grow a second scheme later without ambiguity. On the receiving end, verification in any language is a few lines — here it is in Elixir:

expected = :crypto.mac(:hmac, :sha256, secret, raw_body) |> Base.encode16(case: :lower)
"sha256=" <> got = List.first(get_req_header(conn, "x-slashfeed-signature"))
Plug.Crypto.secure_compare(expected, got)

Use a constant-time comparison (secure_compare), not ==. A byte-by-byte == leaks timing information that, in theory, lets an attacker recover the signature one character at a time. It costs nothing to do it right.

The payload

For the real event the body is a new_entries envelope carrying a batch of entries:

{
  "event": "new_entries",
  "timestamp": "2026-07-14T10:30:00Z",
  "user_id": "…",
  "data": {
    "entries": [
      {"id": "…", "title": "…", "url": "…", "summary": "…",
       "author": "…", "published_at": "…", "read": false}
    ]
  }
}

The test-ping from the UI reuses the same envelope with event: "test" and an empty entries list, so a receiver's handler exercises the identical code path whether it is a real event or a manual poke.

Delivery: an Oban worker that expects to fail

Delivery runs in Oban because talking to someone else's server is the definition of an operation that will fail at inconvenient times. The worker declares its queue and retry budget up front:

use Oban.Worker,
  queue: :webhooks,
  max_attempts: 3

Three attempts, and with no custom backoff/1 the worker inherits Oban's default exponential backoff — each retry waits meaningfully longer than the last, which is the polite thing to do to an endpoint that is briefly down. The request itself sets retry: false on the HTTP client so that Oban owns retries; you do not want two retry loops fighting each other.

Before any request goes out, the URL is checked:

case URLGuard.check_url(webhook.url) do
  :ok -> do_deliver(webhook, payload, attempt)
  {:error, reason} -> record_blocked_url(webhook, attempt, reason)
end

This is the SSRF guard, and it is not optional. webhook.url is attacker-controlled by definition — a malicious user can point it at 127.0.0.1, at a cloud metadata endpoint, or at an internal service. URLGuard refuses loopback, private, link-local, and metadata targets, and a blocked URL is logged as a failed delivery rather than silently dropped.

Every outcome — 2xx, non-2xx, or transport error — flows through record_delivery/3, which inserts the delivery row and updates the webhook's last_attempted_at (and last_succeeded_at on success) inside a single transaction. The failure branch stashes a truncated response body so the log stays useful without letting a chatty endpoint bloat the table.

Fan-out from the parser

The last piece is the trigger. When the parser ingests an entry, it fires side effects on exactly one outcome:

{:new, entry} ->
  broadcast_new_entry(feed_id, entry)
  # …
  BoringRSS.Webhooks.enqueue_for_user_feeds(feed_id, [entry])

{:new, entry} and nothing else — :updated, :unchanged, and :duplicate stay silent. This is what stops a feed without ETag support from re-firing its whole backlog every poll. enqueue_for_user_feeds/2 finds every enabled webhook whose owner subscribes to the feed and whose events include "new_entries", builds the payload, and enqueues one job per webhook. The whole function is wrapped in a rescue that logs and returns :ok — a webhook fan-out failure must never take down entry ingestion. Notifying people is nice; the reader working is the job.