Core Primitives

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.

bash
npx otterkit webhook
bash
$ 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

OptionWhat 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-onlyRules from this command only fire on requests with a valid signature
--notify-emailEmail 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
--daemonDetach into the background; manage with status / stop
--claim <code>Continue a otterkit.com/try webhook in your terminal
--jsonMachine-readable output

Custom auto-response

Some providers require a specific status or body before they deliver events (challenge echoes, strict 2xx checks):

bash
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 2000

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

bash
# 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 500

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

javascript
// 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
  },
};
bash
npx otterkit webhook --respond-script @respond.js
# scope it to one path:
npx otterkit webhook --respond-match path=/flaky --respond-script @respond.js
When rules are armed - and always, for cloud endpoints - the server answers every request itself. The live-feed page and CLI stay attached purely as viewers, so responses are identical whether or not anything is connected.

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

bash
npx otterkit webhook --verify stripe:whsec_abc123
npx otterkit webhook --daemon --verify github:my-webhook-secret

Presets: 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.

Related but 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.

bash
npx otterkit webhook                     # foreground: Ctrl+C stops the endpoint
npx otterkit webhook --daemon --ttl 3d   # always-on until the TTL or otterkit stop

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

bash
# 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:3000

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

bash
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 --body

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

Stored history is a cloud-endpoint feature. CLI endpoints keep captures in the local log on the machine running them - the history API and 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:

bash
npx otterkit webhook --daemon --subdomain stripe-dev --ttl never
# paste https://stripe-dev.otterkit.app into the Stripe dashboard

Inspect an unfamiliar provider's payload. Spin up a throwaway endpoint, trigger the event, read exactly what they send:

bash
npx otterkit webhook --ttl 1h
npx otterkit inspect hook-e5f6g7h8 --last 5

Catch a delivery while you're offline. The server answers and captures whether or not your laptop is open:

bash
npx otterkit webhook --subdomain gh-ci --ttl 24h --daemon

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

bash
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=/stripe

Match 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}}.

bash
# 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/plain

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

Notifications are a recipe, not an integration: any service with an incoming-webhook URL (Slack, Discord, ntfy.sh, PagerDuty, …) works by pointing --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:

javascript
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}`,
      }),
    };
  },
};
bash
# 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.js

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

Template or script, never both on one rule. Reach for a template for the common reshape-and-forward case (auditable at a glance, no failure modes); reach for a script when you need real logic. An agent can generate either from a plain-English description.

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.

bash
# 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.failed

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

bash
npx otterkit webhook --claim <code-from-the-try-page>
Webhook endpoints are first-class: configure forwarding at creation or on the webhook page, and manage everything from the console. Agents get the same via MCP: webhook_create and webhook_forwarding.