Webhooks: Capture & Replay
The proxy normally records outbound traffic — your app calling an external API. Webhook support adds the inbound direction: an external service POSTing to your app. Gostly captures those requests losslessly so you can replay them on demand. Two halves, two different trust models: capture is automatic and runs in the agent; replay is operator-triggered through the control-plane API. The agent never re-fires a captured webhook on its own.
Two halves: capture vs. replay
Capture — automatic
The agent records every inbound webhook it receives to a per-service JSONL file on disk. Always on. No scheduling, no fan-out, no replay side effects.
Replay — operator-triggered
An authenticated operator (or the dashboard) calls the control-plane API to re-send a captured webhook to a target URL. The agent does not auto-replay. Scheduled / fan-out / re-signing replay is roadmap, not shipped.
Why this split matters
Capturing inbound webhooks
Point a provider's webhook at the agent. Each captured request is appended as one line to a per-service JSONL file under the shared data volume (data/webhooks/{service_id}.jsonl). The capture is lossless — method, full request URI (including query string), headers, and the body are all preserved so a downstream HMAC signature still verifies on replay.
# One captured inbound webhook (one JSONL line per request)
{
"id": "wh_a1b2c3d4e5f60718",
"received_at": "2026-06-17T09:14:22Z",
"tenant": "default",
"service_id": "stripe",
"method": "POST",
"uri": "/webhook/default/stripe",
"headers": { "content-type": "application/json" },
"body": { "type": "payment_intent.succeeded" },
"signature_kind": "stripe"
}Each record carries a short unique id of the form wh_<16 hex> and an ISO-8601 capture timestamp. Capture is best-effort by design: a webhook the agent fails to persist (disk full, permission error) is logged and counted, and the sender still receives a success response so it does not retry. Storage failures increment ghost_io_errors_total{operation} with operation labels like webhook_open, webhook_write, and webhook_mkdir.
Provider detection
On capture, the agent infers a signature_kindfrom the header set — narrower providers win first so a request that happens to carry two providers' headers is tagged by the more specific one:
stripeStripe-Signature present (carries the t= timestamp and one or more v1= signatures).
githubX-Hub-Signature-256 (or the legacy X-Hub-Signature) present.
standardwebhook-id + webhook-timestamp + webhook-signature all present — the Standard-Webhooks scheme (standardwebhooks.com).
genericAnything else. The capture is still stored verbatim — only the parsed-signature view is skipped.
Detection is read-only: Gostly never verifies a signature for you and never stores a signing secret. The signature_kind only tells the dashboard which timestamp / nonce fields to parse out of the captured header for the detail view.
Binary and non-UTF-8 bodies
UTF-8 payloads (JSON, form-urlencoded, plain text — the common case) are stored as a plain string so the JSONL line stays human-readable and grep-friendly. A body containing non-UTF-8 bytes (protobuf, MessagePack, EDI, a binary frame) is captured byte-for-byte via base64 instead — recorded as {"b64": "…", "size_bytes": N} rather than a string. Preserving the exact bytes is what keeps an HMAC computed over the body valid when the webhook is later replayed.
On sensitive headers
WebSocket frames: captured, not replayed
When TLS interception is enabled, WebSocket frames are captured for observability into the same per-service store, tagged with a transport marker, a connection id, a direction (client-to-server or server-to-client), and a frame kind (text / binary / ping / pong / close). This is a recording surface only.
Scope
Operator-triggered replay
Replay lives entirely in the control-plane API. There is no agent-side scheduler and no fan-out: a captured webhook is re-sent only when an authenticated operator calls the replay endpoint. The control plane reconstructs the original request — same method, same body, same headers (signature header included by default) — and POSTs it to the target URL you supply, then returns the target's response so you can inspect it.
All four endpoints live under /v1/webhooks and require an API key and a tenant. Replay is available on every paid plan — there is no tier gate on webhook playback.
/v1/webhooks/{service_id}List captured webhooks for a service, newest first. Paginated (limit 1–500, default 50; offset). Optional signature_kind filter.
/v1/webhooks/{service_id}/{webhook_id}Full detail for one capture, including the parsed-signature breakdown (timestamp, nonce, scheme) for stripe / github / standard kinds.
/v1/webhooks/{service_id}/{webhook_id}/replayReplay this capture to a target_url. Returns the target's status code, headers, body, and elapsed time.
/v1/webhooks/{service_id}/{webhook_id}Remove the capture from the database.
Replaying a capture to a target URL:
curl -X POST \
http://localhost:8000/v1/webhooks/stripe/wh_a1b2c3d4e5f60718/replay \
-H "X-API-Key: $GHOST_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"target_url": "http://localhost:4000/webhooks/stripe",
"preserve_signature": true,
"timeout_seconds": 10
}'
# Returns the target's response, e.g.
# { "target_url": "...", "status_code": 200,
# "response_headers": { ... }, "response_body": "...", "elapsed_ms": 37 }Replay options
target_urlAbsolute URL to replay against. Must start with http:// or https:// (a malformed value is rejected with a 422 before any socket opens).
preserve_signatureDefault true: the original signature header is forwarded verbatim. Set false to strip signature headers entirely — useful for replaying into a debug sink or logging endpoint that doesn't verify signatures. Hop-by-hop headers (Host, Content-Length, Connection, …) are always stripped regardless.
timeout_secondsHTTP timeout for the replay request: 1–60 seconds, default 10.
No re-signing — signatures replay verbatim
Replay target safety
Replay lets an authenticated tenant POST a captured body to a URL they choose — an SSRF-shaped capability if left open. Every replay target is validated before any socket opens, in layers: scheme, then hostname literal, then port, then the DNS-resolved IPs. Loopback, private (RFC 1918), CGNAT, link-local, and cloud instance-metadata addresses are rejected by default.
For local development against a service on the same host, an operator can relax the private / loopback checks with GOSTLY_ALLOW_LOCAL_REPLAY=1. The scheme and blocked-port guards stay strict regardless.
400target_url scheme / host / port rejected by the safety guard, or a DNS failure.
404Capture not found, or owned by a different tenant.
502target_url unreachable, timed out, or a network error.
DELETE removes from the database, not from the JSONL
data/webhooks/{service_id}.jsonl file. The sync layer will re-import the line on its next run. For a permanent delete today, clean the JSONL too. A tombstone the sync layer respects is roadmap.Tenancy
Each Gostly deployment is single-tenant: the tenant id defaults to default, and one license key gates one stack. Every webhook endpoint scopes its query to the requesting tenant, and a capture owned by a different tenant reads as a 404. Per-tenant Postgres row-level-security policies are defined as defense-in-depth beneath that; isolation in the default configuration comes from the single-tenant deployment model, with RLS as a defined policy layer rather than engine-enforced multi-tenancy.
What is not shipped yet
To set expectations precisely, the webhook surface today is automatic capture plus operator-triggered, manual replay. The following are roadmap and do not exist in the shipped product:
- Scheduled replay — the agent does not fire captured webhooks on a timer or trigger.
- Fan-out — a single capture is replayed to one target per API call; there is no broadcast to many endpoints.
- Re-signing— Gostly forwards the original signature verbatim and never mints a fresh one, so replay is bounded by the provider's freshness window.
- WebSocket replay — WS frames are captured for observability only.