Events and delivery

Webhooks

Endpoints, fan-out into frozen jobs, signatures, and the formats Ghost speaks.

Users register webhook endpoints on a repository or organization, choose events, and get a secret. The resource is apps/api/src/resources/webhooks/; the user guide is the webhooks section of apps/docs.

Fan-out, in the API

apps/api/src/services/webhooks/webhook-fanout.service.ts is the second outbox consumer. For each event it finds the subscribed endpoints, builds the payload, and freezes it into a delivery_job row:

  • The body is the exact bytes the receiver gets and the signature covers, frozen at fan-out, so a retry days later sends the same thing.
  • The idempotency key is derived from the event and the endpoint, and is unique, so an outbox event handled twice enqueues once.
  • After inserting, the API runs NOTIFY delivery so the Go service wakes at once.

Payloads come in formats: GitHub-style JSON, and message formats for Discord, Slack, Microsoft Teams and Google Chat in apps/api/src/lib/webhooks/formats/. "Send test" sends a ping.

Secrets are stored encrypted (with WEBHOOK_SECRET_KEY), not hashed, because the delivery service must read them to sign.

Sending, in Go

The next lesson covers the delivery service. The receiver's view:

  • X-Ghost-Delivery: a unique id per job. Deduplicate on it; delivery is at least once.
  • An HMAC-SHA256 signature over timestamp.body, computed at send time, so a late retry carries a fresh timestamp.
  • No ordering guarantee, even per endpoint: per-endpoint order would let one failing delivery hold back every later one.

Check yourself

Why is the webhook body built at fan-out rather than at send time?

On this page