Skip to content

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_dedup table, the dedup.WasProcessed / dedup.MarkProcessed helpers, the P52 convention, and the cmd/check-inbound-webhooks CI 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:

CategoryDirectionAuthHandler lives inConfigured by
Cat C — Outbound Webhook SubscriptionWe POST to clinic URLWe sign HMAC-SHA256internal/core/domain/webhooks/Clinic admin via UI
Cat D — Inbound WebhookProvider POSTs to usPer-provider schemeinternal/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 flow

The 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.

go
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:

  1. Read the provider's docs for their signature scheme. Note the headers, the canonical signed-string format, and the timestamp tolerance.
  2. Implement Verify with subtle.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).
  3. Add tests: known-good signature, tampered body, tampered timestamp, stale timestamp, malformed header.
  4. 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 eventInternal event
Daily.co recording.readyappointment.recording_available
Stripe payment_intent.succeededpayment.received
Clerk Svix user.updatedauth.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/:

go
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_id POSTed 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.