Webhooks
A webhook endpoint captures incoming HTTP requests with no server behind it. Point third-party services (Stripe, GitHub, Slack, etc.) at the URL and every request is captured with full headers, body, and metadata. Billed at 1 credit/hour but never more than 200 credits per 30 days - an always-on endpoint costs at most $3/month.
npx otterkit webhook$ npx otterkit webhook
Provisioning webhook...
Webhook provisioned: hook-e5f6g7h8
Webhook ready: https://hook-e5f6g7h8.otterkit.app
Press Ctrl+C to stop the endpoint
POST /webhook (Stripe) 204 (3ms)
POST /events (GitHub) 204 (5ms)Every request is saved to ~/.otterkit/requests/<subdomain>.jsonl, so nothing is lost when the terminal closes. Captures stay on your machine - webhook payloads often carry secrets, so CLI endpoints never persist bodies server-side (browse the local log with otterkit inspect or the Mac app). Prefer the browser? Signed-in users mint cloud endpoints at console.otterkit.com/webhooks - live feed on the page, no terminal involved.
Options
| Option | What it does |
|---|---|
| --subdomain <name> | Stable reserved name; the URL survives restarts |
| --ttl <duration> | Auto-stop server-side (default 24h, or "never" to run until stopped) |
| --respond <status> | Auto-response status (default 200) |
| --respond-body <body> | Auto-response body (default {"received":true}) |
| --respond-content-type <type> | Auto-response Content-Type (default application/json) |
| --respond-header <name:value> | Extra response header (repeatable, max 16) |
| --respond-delay <ms> | Delay before answering, up to 10s (simulate a slow consumer) |
| --respond-match <k=v> | Make the --respond* flags conditional: only matching requests get them (repeatable) |
| --respond-script <code|@file> | Compute the response per request with JS/TS in a sandboxed isolate |
| --verify <provider:secret> | Check each request's provider signature at arrival (stripe, github, svix, …) |
| --verified-only | Rules from this command only fire on requests with a valid signature |
| --notify-email | Email you on arrivals - works with your machine off (1 / 15 min) |
| --notify-match <k=v> | Email you when a matching request arrives (repeatable, throttled) |
| --notify-push [k=v] | Push notifications to your devices - enable each device once in the console |
| --daemon | Detach into the background; manage with status / stop |
| --claim <code> | Continue a otterkit.com/try webhook in your terminal |
| --json | Machine-readable output |
Custom auto-response
Some providers require a specific status or body before they deliver events (challenge echoes, strict 2xx checks):
npx otterkit webhook --respond 204
npx otterkit webhook --respond 200 --respond-body '{"challenge":"accepted"}'
npx otterkit webhook --respond-body '<ok/>' --respond-content-type 'application/xml'
npx otterkit webhook --respond-header 'X-Handled: yes' --respond-delay 2000Extra headers (--respond-header, repeatable) and an artificial delay (--respond-delay, up to 10s - simulate a slow consumer) round out the response config, which the server applies to every answer. Cloud endpoints (created in the console) configure the same response from the endpoint page, editable after creation - changes apply to the live endpoint immediately, no restart, no new charge.
Dynamic responses: answer differently per request
Beyond one fixed response, an endpoint can carry response rules: ordered, first-match-wins, each matching on method, path prefix, or a JSON body field and answering with its own status, body, headers, and delay - anything unmatched falls through to the default response. Bodies can be templates, rendered per request with the same placeholders as forward transforms - which makes challenge echoes one rule:
# Slack URL verification: echo the challenge; every other request gets the default
npx otterkit webhook \
--respond-match type=url_verification \
--respond 200 --respond-body '{"challenge": "{{challenge}}"}'
# Simulate failures on one path to test a sender's retries
npx otterkit webhook --respond-match path=/flaky --respond 500The CLI arms one rule per invocation (--respond-match binds the --respond* flags to it; bodies containing {{ render as templates). Cloud endpoints manage the full ordered list from the Rules section of the Response card - add, edit, reorder, with one-click presets for Slack challenges, body echo, and failure simulation. Changes apply to the live endpoint immediately.
When rules and templates aren't enough, a rule can be a script: real JavaScript or TypeScript run per request in the same no-egress isolate as forward transforms (50ms CPU, no network). Export respond(req); return {status?, body?, contentType?, headers?, delayMs?}, a string for just a body, or null to fall through to the next rule. A script that throws fails open - the sender is always answered by something. Randomized chaos, rate-limit simulation, computed mocks:
// respond.js - fail ~30% of requests to exercise the sender's retries
export default {
respond(req) {
if (Math.random() < 0.3) return { status: 500, body: '{"error":"chaos"}' };
return null; // everything else gets the default response
},
};npx otterkit webhook --respond-script @respond.js
# scope it to one path:
npx otterkit webhook --respond-match path=/flaky --respond-script @respond.jsSignature verification
Arm an endpoint with your provider's signing secret and every arriving request gets its signature checked at arrival, server-side - before you ever look at it. The verdict shows as a badge on every capture (verified / invalid / unsigned), is stored with the request, and reaches your local server as an X-OtterKit-Verified: valid|invalid|missing header. Verification never blocks a request - the endpoint answers normally either way; you just know.
npx otterkit webhook --verify stripe:whsec_abc123
npx otterkit webhook --daemon --verify github:my-webhook-secretPresets: stripe, github, shopify, slack, svix (which covers Resend, Clerk, Polar, and every other Svix-powered sender), paddle, zoom, linear, lemonsqueezy, dropbox, gitlab, twilio, square, hubspot, discord, and sendgrid. Twilio, Square, and HubSpot sign the full endpoint URL - handled automatically. Discord and SendGrid verify with a public key (Ed25519 / ECDSA) - paste the key where the secret goes. Cloud endpoints configure this from the Verification card on the endpoint page - which adds a generic HMAC mode (any header, SHA-1/256/512, hex or base64, optional prefix and timestamp) for the long tail of providers. Changes apply to the live endpoint immediately; timestamped schemes (Stripe, Slack, Svix, …) check signature age too (default tolerance 300s, configurable).
All comparisons are constant-time, and a sender who tries to smuggle in their own X-OtterKit-Verified header gets it stripped. Stored history can be filtered by verdict: ?verify=valid|invalid|missing on the history API, and the badge carries the failure reason (signature_mismatch, timestamp_out_of_tolerance, …) so a bad secret and a stale replay look different.
otterkit verify checks signatures on already-captured requests against a secret you supply locally - useful forensics. --verify is the live version: the server checks every request as it lands, even while your machine is off.Always on: the server answers, your terminal watches
A webhook endpoint is answered by OtterKit's servers from the moment it exists - responses, captures, verification, and forwarding all happen server-side, whether or not anything is connected. Your terminal (and the console page) is a live viewer: requests stream in while attached, and anything that arrived while you were away (up to 200 buffered) replays into your local log on reconnect. The provider never sees downtime.
Lifetime follows how you run it: a foreground run stops the endpoint on Ctrl+C - interactive means "capture while I watch". A --daemon run (or a console endpoint) stays up until its TTL or an explicit stop, billing 1 credit/hour, never more than $3/month.
npx otterkit webhook # foreground: Ctrl+C stops the endpoint
npx otterkit webhook --daemon --ttl 3d # always-on until the TTL or otterkit stopWork with what you captured
Captures are inputs to the rest of the CLI - inspect them, block on the next one, or replay one into your local server once it's ready:
# Browse captures (add --follow to tail live, --har to export)
npx otterkit inspect stripe-dev
# Block until the next matching request lands (great in scripts)
npx otterkit await stripe-dev --method POST --path /webhook
# Re-send capture #3 into your local server on port 3000
npx otterkit replay stripe-dev --index 3 --target localhost:3000Server-side history
Cloud endpoints store every capture server-side (up to 10,000 per endpoint); CLI endpoints keep captures in their local log only. Read cloud history from any machine on the account - no local log needed:
npx otterkit requests stripe-dev # stored history, newest first
npx otterkit requests stripe-dev --json # full fidelity: headers + base64 body
npx otterkit requests stripe-dev --method POST --path /stripe --limit 100 --bodyThe same data is available to agents over the token-authed API (GET api.otterkit.com/api/me/webhooks/<subdomain>/requests, plus /requests/export?format=json|csv) and via the MCP webhook_history tool. On the endpoint's console page you can also export JSON/CSV and resend any stored capture to a target URL - editing the method, headers, and body first if you want to probe variants.
webhook_history answer cli_session with a pointer to otterkit inspect and the local JSONL instead of an empty list.Use cases
Stripe development without stripe-cli. A stable endpoint that survives restarts and keeps answering overnight:
npx otterkit webhook --daemon --subdomain stripe-dev --ttl never
# paste https://stripe-dev.otterkit.app into the Stripe dashboardInspect an unfamiliar provider's payload. Spin up a throwaway endpoint, trigger the event, read exactly what they send:
npx otterkit webhook --ttl 1h
npx otterkit inspect hook-e5f6g7h8 --last 5Catch a delivery while you're offline. The server answers and captures whether or not your laptop is open:
npx otterkit webhook --subdomain gh-ci --ttl 24h --daemonAgents waiting on external events. An agent provisions an endpoint, hands the URL to a service, then blocks on await - or arms a forward (below) and disconnects entirely.
Forwarding: part of the endpoint's config
A webhook endpoint has a URL, a response, a lifetime - and forwarding rules. A rule says "every request matching event.type = payment.succeeded is also delivered to my URL" - HMAC-signed, with retries, even while your machine is off - until you remove the rule. The endpoint keeps capturing and responding normally either way.
npx otterkit webhook \
--match event.type=payment.succeeded \
--forward https://ci.example.com/hooks/payments
# --match is repeatable: method=, path=, or any JSON body dot-path
npx otterkit webhook --forward https://... --match method=POST --match path=/stripeMatch clauses (all must hold): method= (exact), path= (prefix), and any other key is a dot-path equality into a JSON body. Up to 20 rules per endpoint; rules live with the endpoint. Manage them on the endpoint's console page (add, remove, delivery stats), or via the MCP webhook_forwarding tool.
Each rule gets a secret, shown once. Deliveries carry X-OtterKit-Signature: sha256=<hmac> - the HMAC-SHA256 of the delivered body - so your receiver can verify the callback came from OtterKit, plus X-OtterKit-Rule-Id and X-OtterKit-Delivery-Id. Failed deliveries retry with backoff (1, 2, 4, 8 minutes; five attempts), and each payload is persisted before its first attempt so a crash can't lose it. A callback that keeps failing pauses the rule instead of retrying forever - remove and re-add it to reset.
Rules can also be gated on the signature verdict: verified only (the checkbox in the rule modal, or --verified-only) skips tampered and unsigned requests entirely - verification becomes a gate, not just a badge. Fail-closed: with verification unarmed, a gated rule fires for nothing.
Transforms: reshape the delivery
By default a rule delivers a JSON envelope with the full captured request. Add a transform template and matching requests deliver the rendered template instead - which turns forwarding into notifications and format-bridging. {{data.object.id}} pulls a dot-path from the JSON body; built-ins are {{$method}}, {{$path}}, {{$body}} (the raw body), and {{$header.stripe-signature}}.
# Stripe payment → Slack message, no relay server anywhere
npx otterkit webhook --daemon --subdomain stripe-dev \
--match type=payment_intent.succeeded \
--forward https://hooks.slack.com/services/T00/B00/xxxx \
--forward-transform '{"text": "💰 Paid: {{data.object.amount}} {{data.object.currency}}"}'
# Template from a file, delivered as plain text (ntfy.sh push notification)
npx otterkit webhook --forward https://ntfy.sh/my-topic \
--forward-transform @notify.tpl --forward-content-type text/plainRendering is safe by construction: string values are JSON-escaped so a quote in a payload can't break a JSON template, a path that resolves to nothing renders empty, and the template is validated when the rule is armed - a typo is a 400 at create time, not a blank delivery later. The rendered body is what gets signed, and it renders once per matched request, so retries deliver the exact bytes the signature covers. In the console, the rule modal has a template editor with syntax highlighting and one-click presets for Slack, Discord, and ntfy.sh.
--forward at it and shaping its payload with a template.Scripts: real JavaScript on delivery
When a template isn't enough - conditional logic, computed fields, reshaping arrays - attach a script instead. It's real JavaScript, run per matching request in a dedicated V8 isolate (Cloudflare Dynamic Workers) with no network access and a 50ms CPU cap. Export a transform(req) function; its return value becomes the delivery body, or it skips the delivery entirely:
export default {
transform(req) {
// req: { method, path, headers, text, json }
const amount = req.json?.data?.amount ?? 0;
if (amount < 100) return null; // return null → skip this delivery
const tier = amount >= 100000 ? 'whale' : 'normal';
return { // string | {body, contentType?} | object (JSON) | null
body: JSON.stringify({
text: `[${tier}] $${(amount / 100).toFixed(2)} from ${req.json.data.email}`,
}),
};
},
};# Attach a script from a file (multi-line JS is painful to escape inline)
npx otterkit webhook --forward https://hooks.slack.com/services/... \
--forward-script @transform.jsThe sandbox is strict by construction: globalOutbound: null means the isolate physically cannot make a network call, so a script can reshape a payload but never exfiltrate it - and it never sees the rule's signing secret, since delivery (signed, retried) stays with OtterKit. The script is loaded and probed in a throwaway isolate when you add the rule, so a syntax error or missing export is a 400 at create time. A script that throws, times out, or blows the CPU cap fails that one delivery and is retried like any other failure; keep failing and the rule pauses. Isolates cache by content hash, so a hot rule runs with no per-request load cost (~$0.06/month per unique script).
Notifications: know when something lands
Four ways to hear about arriving requests - on the endpoint's console page, or with CLI flags at creation:
Browser notifications. The bell on the live-traffic header fires a desktop notification when a request arrives while the tab is in the background - switch away to your editor, get pinged when the webhook you're waiting for lands. Bursts coalesce into one notification; clicking it focuses the tab.
Email on arrival. The mail toggle (or --notify-email) emails you when requests arrive - and it works with the page closed and your machine off. Edge-triggered and throttled (at most one email per 15 minutes per endpoint, 20/day): the first request after a quiet spell emails immediately, the rest fold into a count in the next one.
Conditional email rules. The Notifications tab takes the same matching and transforms as forwarding rules, but delivers to your inbox instead of a URL: "email me when event.type = payment.failed", with a template or script shaping the email body. Same 15-minute throttle per rule. From the CLI: --notify-match event.type=payment.failed (repeatable).
Push notifications. Pick Push notifications in the Notifications tab (or pass --notify-push, optionally with k=v conditions) and matching requests push to your devices within seconds - no app, no account, no phone number. Delivery is standard Web Push, end-to-end encrypted to each browser you enable: hit Enable on this device once per device in the Notifications tab (works on Android, desktop, and iPhone - on iOS add the console to your Home Screen first, then enable from the installed app). Every push deep-links back to the endpoint's traffic. Throttled to one push per minute per rule (extra matches fold into a "+N more" on the next push), 300/day per endpoint.
# push on every request
npx otterkit webhook --notify-push
# push only when a payment fails, and only if the signature verified
npx otterkit webhook --verify stripe:whsec_… --verified-only \
--notify-push event.type=payment.failedDrift Watch: know when a provider changes its payloads
Providers add fields, retire them, and change types - usually silently, with the changelog arriving later, if at all. On cloud endpoints, enable Drift Watch from the Drift tab and OtterKit learns the shape of what the endpoint actually receives - one baseline per event group, keyed by the request path and the payload's own type/event/action field, so providers routed to different paths never mix. Each group arms after ~20 samples; from then on every arriving body is diffed inline and changes become findings: new field, missing field (a ~always-present field gone 5 requests in a row), type change, format change, and new event type.
Each finding alerts once by email (throttled to one digest per 15 minutes), then the baseline follows reality - repeats just bump a counter. In the Drift tab, Accept closes a finding; Ignore also mutes that exact path forever (un-mutable from the same tab). With signature verification armed, only verified requests feed the baseline, so spoofed traffic can't poison it. Agents get the same machine-readable diff via the MCP schema_drift tool - notice the drift, read the sample, patch the handler.
Continue a /try webhook
Started on otterkit.com/try? The claim command moves the webhook into your terminal - same public URL, converted to a metered session on your account, with everything captured on the page imported into your local log:
npx otterkit webhook --claim <code-from-the-try-page>webhook_create and webhook_forwarding.