Guides

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

Auto-replay is an outbound HTTP side effect aimed at a caller-supplied URL — exactly the shape that becomes an SSRF or a duplicate-delivery problem if it fires without a human in the loop. Gostly keeps capture passive and gates every replay behind an authenticated, explicit API call with an SSRF-checked target.

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:

stripe

Stripe-Signature present (carries the t= timestamp and one or more v1= signatures).

github

X-Hub-Signature-256 (or the legacy X-Hub-Signature) present.

standard

webhook-id + webhook-timestamp + webhook-signature all present — the Standard-Webhooks scheme (standardwebhooks.com).

generic

Anything 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

The same 16-header credential redaction floor that protects recorded traffic applies here: credential headers (Authorization, Cookie, API keys, and the rest of the floor) are stripped before anything is written to disk. Signature headers are not on that floor — they are kept verbatim, because replay needs the original signature bytes, and the timestamp and nonce live in the same header set. PII in webhook bodies follows the standard posture: kept verbatim in the local replay library on your host, and scrubbed when synced into the Postgres store and any export. See the header redaction reference for the full list.

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

WebSocket frames are captured for observability, not replayed. The replay API below operates on HTTP webhook captures. Gostly records and replays HTTP and HTTPS (HTTP/1.1 and HTTP/2 over TLS); gRPC, async messaging, and database protocols are not supported today (roadmap).

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.

GET
/v1/webhooks/{service_id}

List captured webhooks for a service, newest first. Paginated (limit 1–500, default 50; offset). Optional signature_kind filter.

GET
/v1/webhooks/{service_id}/{webhook_id}

Full detail for one capture, including the parsed-signature breakdown (timestamp, nonce, scheme) for stripe / github / standard kinds.

POST
/v1/webhooks/{service_id}/{webhook_id}/replay

Replay this capture to a target_url. Returns the target's status code, headers, body, and elapsed time.

DELETE
/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_url

Absolute URL to replay against. Must start with http:// or https:// (a malformed value is rejected with a 422 before any socket opens).

preserve_signature

Default 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_seconds

HTTP timeout for the replay request: 1–60 seconds, default 10.

No re-signing — signatures replay verbatim

Replay forwards the captured signature header byte-for-byte; it never re-signs. The captured timestamp and nonce go out unchanged, so the replay verifies only while the provider's freshness window still accepts that timestamp — Stripe defaults to a 5-minute window; GitHub signs the body only (no timestamp, so the signature stays valid); Standard-Webhooks uses a configurable TTL. This is deliberate: not re-signing means Gostly never has to see, store, or transmit your signing secret. Indefinite replay via re-signing is roadmap, not shipped.

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.

400

target_url scheme / host / port rejected by the safety guard, or a DNS failure.

404

Capture not found, or owned by a different tenant.

502

target_url unreachable, timed out, or a network error.

DELETE removes from the database, not from the JSONL

Deleting a capture removes the row from the Postgres store; it does not touch the agent-side 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.

Next steps