Inbound Webhook Guide
How to add a new inbound webhook handler — for engineers wiring up a new external provider that pushes events to RestartiX (Stripe, Daily.co recordings, Clerk Svix, SES SNS bounces, Google Calendar push, Microsoft 365 change notifications, …).
Foundation 1C.6 ships the framework — the
inbound_webhook_deduptable, thededup.WasProcessed/dedup.MarkProcessedhelpers, the P52 convention, and thecmd/check-inbound-webhooksCI guard. Zero per-provider handlers ship at foundation. This guide is for the engineer landing the first Cat D handler at their feature tier.
Architectural model
Cat D is the inverse of Cat C: a third party POSTs to us at /webhooks/{provider}, signed. Compare:
| Category | Direction | Auth | Handler lives in | Configured by |
|---|---|---|---|---|
| Cat C — Outbound Webhook Subscription | We POST to clinic URL | We sign HMAC-SHA256 | internal/core/domain/webhooks/ | Clinic admin via UI |
| Cat D — Inbound Webhook | Provider POSTs to us | Per-provider scheme | internal/integration/{provider}/inbound/ | Platform (single endpoint per provider) |
Inbound webhook handlers run JWT-naked — the signature is the auth. The /webhooks/ router group sits as a sibling of /v1/, with no Authenticate / RequireOrganizationScope middleware applied.
Per-provider package layout
By convention, every Cat D provider gets a sibling subpackage:
internal/integration/{provider}/inbound/
├── verify.go // Verify(req *http.Request, secret []byte) error
├── parse.go // Parse(body []byte) (Event, error) — typed event extraction
└── handler.go // mounted on /webhooks/{provider}; runs the standard flowThe CI guard (cmd/check-inbound-webhooks) walks every inbound/ package and asserts the four required call sites are present (Verify, dedup.WasProcessed, dedup.MarkProcessed, events.Publish/PublishWith/NewEvent). The guard fails the build if any is missing — no opt-out without a documented exception.
The standard flow
Every handler runs the same five steps. The order is locked.
package inbound
import (
"net/http"
"github.com/restartix/restartix-platform/services/api/internal/core/events"
"github.com/restartix/restartix-platform/services/api/internal/core/inboundwebhooks/dedup"
)
const ProviderName = "stripe" // or "dailyco", "clerk", "ses", etc.
type Handler struct {
Service *DomainService // your domain service
Dedup *dedup.Repo
Bus events.Bus
SigningSecret []byte
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 1. Verify signature. Return 401 on mismatch.
if err := Verify(r, h.SigningSecret); err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
body, err := readBody(r)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
evt, err := Parse(body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// 2. Dedup check. Return 200 OK early on re-delivery.
already, err := h.Dedup.WasProcessed(r.Context(), ProviderName, evt.ID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if already {
w.WriteHeader(http.StatusOK)
return
}
// 3. Mutate state via the domain service.
if err := h.Service.Apply(r.Context(), evt); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
// 4. Mark processed in the same tx as the mutation.
if _, err := h.Dedup.MarkProcessed(r.Context(), ProviderName, evt.ID); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
// 5. Emit the Cat E Internal Event. Standard fan-out (audit, notify,
// outbound, automations) consumes this.
h.Bus.Publish(r.Context(), events.NewEvent(events.AppointmentRecordingAvailable, ...))
w.WriteHeader(http.StatusOK)
}The skeleton above shows the call-site shape the CI guard requires. Wrap-the-mutation-and-mark-in-the-same-tx is critical — if step 3 rolls back, dedup must roll back too. See P52 for the full reasoning.
Per-provider Verify helpers
Per-provider verifiers are deliberately not abstracted. Stripe's t=...,v1=... HMAC scheme, SES SNS's X.509 cert chain, Svix's three-header format, and Google's opaque token echo don't share enough structure to make a shared interface useful. Each provider's verify.go exports a single Verify(req *http.Request, secret []byte) error function and owns its own constant-time comparison.
When implementing a new provider:
- Read the provider's docs for their signature scheme. Note the headers, the canonical signed-string format, and the timestamp tolerance.
- Implement
Verifywithsubtle.ConstantTimeCompare. Reject signatures with a stale timestamp (most providers recommend ±5 min — match what they document; rejecting their own valid retries is worse than accepting a slightly stale forgery, which still has to forge the HMAC). - Add tests: known-good signature, tampered body, tampered timestamp, stale timestamp, malformed header.
- SOUP entry for any verification library (Svix's official lib, Stripe's SDK, etc.) — see soup.md.
Where credentials come from
Provider signing secrets are Cat A — they live in platform_service_providers keyed by capability inbound_signing_<provider> (e.g., inbound_signing_stripe, inbound_signing_dailyco). The verify helper receives the secret as []byte from the resolved provider row. Operators rotate via the Console superadmin endpoints; runbook in credential-rotation.md.
For Cat B providers that push notifications per-org (Google Calendar, Microsoft 365), the per-connection token lives in organization_integrations.config.push_channel.token (GIN-indexed JSONB scan) — the inbound handler reads the body to extract the channel token, looks up the matching organization_integrations row, and resolves the org from there.
Internal event emission (step 5)
The Cat E event you emit should describe the inbound effect, not the wire-level provider event. For example:
| Provider event | Internal event |
|---|---|
Daily.co recording.ready | appointment.recording_available |
Stripe payment_intent.succeeded | payment.received |
Clerk Svix user.updated | auth.user_synced |
Register the internal event via the 1C.3 events registry in the same PR. The downstream consumers (audit, notification dispatcher, outbound webhook subscribers) hook off the internal event, not the wire event — this lets us swap providers without rewiring fan-out.
Routing
Mount under /webhooks/{provider} at the chi router root, as a sibling to /v1/:
s.router.Route("/webhooks", func(r chi.Router) {
r.Use(ratelimit.Middleware(s.rateLimitStore, s.inboundWebhookPolicy, ratelimit.IPKey))
r.Post("/stripe", s.stripeInboundHandler.ServeHTTP)
r.Post("/dailyco", s.dailycoInboundHandler.ServeHTTP)
// ...
})The first F-tier consumer adds the per-provider rate-limit policy (inbound_webhook — default 100 req/sec/provider). Sustained breaches indicate runaway loops or attacks; the rate limiter rejects with 429 before the handler runs.
Retention
inbound_webhook_dedup is range-partitioned monthly per P41. Default retention is 60 days — covers Stripe's 30-day retry window with margin. The partition runner (cmd/audit-partition-roll) provisions forward partitions; aging out old partitions is a future operational concern (drop partitions older than the retention window via a scheduled job). For now, with no inbound traffic, the table grows by zero rows per day and retention is academic.
Testing checklist
Per-provider integration test should cover:
- Happy path: signed POST → dedup miss → state mutation → mark-processed → event emitted → 200.
- Replay: same
event_idPOSTed twice → second call returns 200 immediately, no second mutation, no second event emission. - Forgery: tampered signature → 401.
- Tampered body: body changed but signature unchanged → 401 (the signed string includes the body).
- Stale timestamp: timestamp older than the tolerance window → 401.
- Mutation failure: state mutation errors → 5xx, dedup row NOT inserted (provider retries clean).
The 1C.6 acceptance test in internal/test/rlstest/inbound_dedup_test.go covers the dedup helpers themselves; per-provider tests cover the rest.