Kepler.Sink.Webhook (Kepler v0.1.0)

Copy Markdown View Source

POSTs each event as JSON to a URL.

Options

  • :url — required. Must be http or https.
  • :secret — signs the body. Strongly recommended; see below.
  • :headers — extra headers, as {name, value} string pairs.
  • :transport — a Kepler.Transport. Defaults to Kepler.Transport.Httpc.
  • :transport_opts — passed through to the transport. The default one accepts :timeout, :connect_timeout, and :ssl.
config :kepler,
  sinks: [
    {Kepler.Sink.Webhook,
     url: System.fetch_env!("KEPLER_WEBHOOK_URL"),
     secret: System.fetch_env!("KEPLER_WEBHOOK_SECRET"),
     headers: [{"x-team", "payments"}]}
  ]

Headers

Every request carries kepler-watch, kepler-event-id, kepler-severity, and kepler-schema, so a consumer can route without parsing the body.

Signing

With a :secret, requests carry:

kepler-signature: t=1754481296,v1=<hex>

where the hex is HMAC-SHA256(secret, "<t>.<raw body>"). Verify against the raw body, before any JSON parsing, and reject timestamps outside a few minutes of now to blunt replays. In Elixir:

def verify(secret, signature_header, raw_body) do
  ["t=" <> timestamp, "v1=" <> digest] = String.split(signature_header, ",")
  expected = Kepler.Sink.Webhook.signature(secret, timestamp, raw_body)
  Plug.Crypto.secure_compare(digest, expected) and fresh?(timestamp)
end

Use a constant-time comparison — Plug.Crypto.secure_compare/2 or :crypto.hash_equals/2 — not ==.

Failure

A non-2xx response or a transport error counts as a failed delivery and the event is dropped. There is no retry: Kepler will not retry into a wedged cluster, and it has no durable queue to retry from. Kepler.status/0 reports the drop count.

Summary

Functions

Turns the event into the JSON body and the routing fields the headers need.

The signature Kepler sends for body at timestamp.

Functions

format(event)

@spec format(Kepler.Event.t()) :: %{
  body: iodata(),
  id: String.t(),
  watch: String.t(),
  severity: String.t()
}

Turns the event into the JSON body and the routing fields the headers need.

Separated from deliver/2 so the wire format is replaceable without reimplementing the transport, signing, and failure handling around it.

signature(secret, timestamp, body)

@spec signature(String.t(), String.t(), iodata()) :: String.t()

The signature Kepler sends for body at timestamp.

Exposed so an Elixir consumer can verify without reimplementing the scheme. Compare the result in constant time.