Core Primitives

Webhooks

Always-on webhook endpoints with no server behind them: custom responses, signature verification, stored history, forwarding, notifications, and Drift Watch.

Docs for LLMs

A webhook endpoint is a public URL that captures incoming HTTP requests with no server of yours behind it. Point Stripe, GitHub, Slack, or any other sender at the URL and every request is captured with its full headers, body, and metadata. An endpoint costs 1 credit per hour while it is live, and never more than 300 credits ($3) per rolling 30 days, so an always-on endpoint costs at most $3 a month.

Terminal
npx otterkit webhook
Terminal
$ npx otterkit webhook
  ✓ Webhook ready

  URL      https://hook-e5f6g7h8.otterkit.app
  Rate     1 credit/hr · never more than 300/month

  Press Ctrl+C to stop the endpoint

  POST /webhook (Stripe) 200 (3ms)
  POST /events  (GitHub) 200 (5ms)

Every request is saved to ~/.otterkit/requests/<subdomain>.jsonl, so nothing is lost when the terminal closes. Captures from CLI endpoints stay on your machine, because webhook payloads often carry secrets: OtterKit doesn't store their bodies on the server. Browse the local log with otterkit inspect or the Mac app.

Prefer the browser? Create a cloud endpoint at console.otterkit.com/webhooks↗. Cloud endpoints show a live feed on the page and store their history on the server, so you can read it from any machine.

Options#

OptionWhat it does
--subdomain <name>Use a stable reserved name. The URL survives restarts.
--ttl <duration>Auto-stop on the server. Default 24h. Use never to run until stopped.
--daemonRun in the background. Manage it with status and stop.
--emailAdd an inbox at <subdomain>@otterkit.app. See Email Inboxes.
--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, or guessed from the body.
--respond-header <name:value>Extra response header. Repeatable, up to 16.
--respond-delay <ms>Wait before answering, up to 10 seconds.
--respond-match <k=v>Turn the --respond* flags into a conditional rule that only matching requests get. Repeatable.
--respond-script <code|@file>Compute the response per request with JavaScript or TypeScript in a sandbox.
--verify <provider:secret>Check each request's provider signature on arrival (stripe, github, svix, and more).
--verified-onlyRules created by this command fire only on requests with a valid signature.
--forward <url>Deliver matching requests to a URL, signed, with retries.
--match <k=v>Filter for --forward. Repeatable.
--forward-transform <tpl>Deliver a rendered template instead of the raw capture.
--forward-script <code|@file>Reshape each delivery with JavaScript in a sandbox.
--notify-emailEmail you when requests arrive, even with your machine off.
--notify-match <k=v>Email you when a matching request arrives. Repeatable.
--notify-push [k=v]Send push notifications to your enabled devices.
--deliver <target>Mirror each capture to a local server. See Local Delivery.
--deliver-exec <cmd>Run a shell command for each capture.
--claim <code>Continue a webhook started on the /try page.
--jsonMachine-readable output. Requires --daemon.

Custom auto-response#

Some providers need a specific status or body before they deliver events, such as a challenge echo or a strict 2xx check:

Terminal
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

Use --respond-header (repeatable) for extra headers and --respond-delay to simulate a slow consumer, up to 10 seconds. The server applies this response to every answer. Cloud endpoints set the same response on the endpoint page. You can edit it after creation, and changes apply to the live endpoint immediately, with no restart and no new charge.

Dynamic responses#

An endpoint can also carry response rules. Rules are ordered and the first match wins. Each rule matches on method, path prefix, or a JSON body field, and answers with its own status, body, headers, and delay. Anything unmatched gets the default response. A rule body can be a template, rendered per request with the same placeholders as forward transforms. That makes a challenge echo a single rule:

Terminal
# 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}}"}'

# Fail one path to test a sender's retries
npx otterkit webhook --respond-match path=/flaky --respond 500

The CLI creates one rule per command: --respond-match binds the --respond* flags to it, and bodies that contain {{ render as templates. Cloud endpoints manage the full ordered list, up to 20 rules, on the Responses tab. You can add, edit, and reorder rules, with one-click presets for Slack challenges, body echo, and failure simulation. Changes apply immediately.

When rules and templates aren't enough, a rule can run a script: real JavaScript or TypeScript, run per request in the same isolated sandbox as forward scripts (50 ms of CPU, no network access). Export respond(req) and return {status?, body?, contentType?, headers?, delayMs?}, a string for just a body, or null to fall through to the next rule. If a script throws, the request falls through, so the sender always gets an answer. Use scripts for random failures, rate-limit simulation, or computed mock responses:

respond.js
// Fail about 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
  },
};
Terminal
npx otterkit webhook --respond-script @respond.js
# scope it to one path:
npx otterkit webhook --respond-match path=/flaky --respond-script @respond.js

Signature verification#

Give an endpoint your provider's signing secret and the server checks every request's signature on arrival, before you look at it. The verdict (valid, invalid, or missing) shows as a badge on each capture, is stored with the request, and reaches your local server as an X-OtterKit-Verified header. Verification never blocks a request. The endpoint answers normally either way, and you know which requests to trust.

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

There are 16 provider presets: stripe, github, shopify, slack, svix (which covers Resend, Clerk, Polar, and other Svix-powered senders), paddle, zoom, linear, lemonsqueezy, dropbox, gitlab, twilio, square, hubspot, discord, and sendgrid. Twilio, Square, and HubSpot sign the full endpoint URL, and OtterKit handles that for you. Discord and SendGrid verify with a public key (Ed25519 and ECDSA): paste the key where the secret goes.

Cloud endpoints set verification on the endpoint page, which also offers a generic HMAC mode for other providers: any header, SHA-1, SHA-256, or SHA-512, hex or base64, with an optional prefix and timestamp. Changes apply to the live endpoint immediately. Timestamped schemes such as Stripe, Slack, and Svix also check the signature's age, with a default tolerance of 300 seconds that you can change.

All comparisons are constant-time, and the server strips any X-OtterKit-Verified header a sender tries to smuggle in. You can filter stored history by verdict with ?verify=valid|invalid|missing on the history API. Each badge carries the failure reason, such as signature_mismatch or timestamp_out_of_tolerance, so a wrong secret and a stale replay look different.

Always on: the server answers, your terminal watches#

OtterKit's servers answer a webhook endpoint from the moment it exists. Responses, captures, verification, and forwarding all happen on the server, whether or not anything is connected. Your terminal and the console page are live viewers. Requests stream in while you are attached, and up to 200 requests that arrived while you were away replay into your local log when you reconnect. The sender never sees downtime.

How long it lives depends on how you run it. A foreground run stops the endpoint on Ctrl+C. A --daemon run, or a console endpoint, stays up until its TTL runs out or you stop it.

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

Each endpoint can serve up to 100,000 requests per rolling 30 days. Past that, it answers 429 until the window rolls. If your balance runs out, the endpoint enters grace standby. It keeps answering senders and buffers up to 200 requests, but pauses forwarding and history. Grace time is never billed. Top up within 72 hours and everything resumes on its own; after that the endpoint stops, and you can restart it on the same URL.

Work with what you captured#

Captures feed the rest of the CLI. You can inspect them, wait for the next one, or replay one into your local server once it is ready:

Terminal
# Browse captures (add --follow to tail live, --har to export)
npx otterkit inspect stripe-dev

# Block until the next matching request lands
npx otterkit await stripe-dev --method POST --path /webhook

# Re-send capture #3 to your local server on port 3000
npx otterkit replay stripe-dev --index 3 --target localhost:3000

Server-side history#

Cloud endpoints store every capture on the server, up to 10,000 per endpoint. CLI endpoints keep captures in their local log only. Read cloud history from any machine on the account, with no local log needed:

Terminal
npx otterkit requests stripe-dev                  # stored history, newest first
npx otterkit requests stripe-dev --json           # full fidelity: headers and base64 body
npx otterkit requests stripe-dev --method POST --path /stripe --limit 100 --body

Agents get the same data from the token-authenticated API (GET api.otterkit.com/api/me/webhooks/<subdomain>/requests, plus /requests/export?format=json|csv) and from the MCP webhook_history tool. On the endpoint's console page you can export JSON or CSV and resend any stored capture to a target URL, after editing the method, headers, or body if you like. Agents resend with the MCP history_replay tool.

Use cases#

Stripe development without the Stripe CLI. A stable endpoint that survives restarts and keeps answering overnight:

Terminal
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. Start a throwaway endpoint, trigger the event, and read exactly what the provider sends:

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

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

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

Agents waiting on external events. An agent creates an endpoint, hands the URL to a service, and blocks on await. Or it sets up a forward (below) and disconnects entirely.

Forwarding#

Forwarding rules are part of an endpoint's configuration, like its URL and response. A rule says "every request matching event.type = payment.succeeded is also delivered to my URL". Deliveries are HMAC-signed and retried, and they keep going while your machine is off, until you remove the rule. The endpoint keeps capturing and responding normally either way.

Terminal
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

All match clauses must hold. method= is an exact match, path= is a prefix, and any other key is a dot-path equality check on a JSON body. Each endpoint holds up to 20 rules. Manage them on the endpoint's Forwards tab in the console, which shows delivery stats, or with 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 confirm the callback came from OtterKit. They also carry X-OtterKit-Rule-Id and X-OtterKit-Delivery-Id. A failed delivery is retried with backoff (after 1, 2, 4, and 8 minutes, five attempts in all), and each payload is saved before its first attempt so a crash can't lose it. If 10 deliveries in a row fail, the rule pauses and you get an email. Fix the destination, then click Resume on the rule.

A rule can also require a valid signature. Tick verified only in the rule dialog, or pass --verified-only, and the rule skips tampered and unsigned requests. Verification becomes a gate instead of just a badge. This fails closed: if verification isn't set up, 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. This turns forwarding into notifications and format bridges. {{data.object.id}} pulls a dot-path from the JSON body. The built-ins are {{$method}}, {{$path}}, {{$body}} (the raw body), and {{$header.stripe-signature}}.

Terminal
# Stripe payment → Slack message, with no relay server
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 design. 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. The template is validated when you create the rule, so a typo is a 400 error at creation time, not a blank delivery later. The rendered body is what gets signed, and it renders once per matched request, so retries deliver exactly the bytes the signature covers. In the console, the rule dialog has a template editor with presets for Slack, Discord, and ntfy.sh.

Scripts: JavaScript on delivery#

When a template isn't enough, for conditional logic, computed fields, or reshaped arrays, attach a script instead. It is real JavaScript, run per matching request in a dedicated V8 isolate with no network access and a 50 ms CPU limit. Export a transform(req) function. Its return value becomes the delivery body, or null skips the delivery:

transform.js
export default {
  transform(req) {
    // req: { method, path, headers, text, json }
    const amount = req.json?.data?.amount ?? 0;
    if (amount < 100) return null;                 // null skips 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}`,
      }),
    };
  },
};
Terminal
# Attach a script from a file
npx otterkit webhook --forward https://hooks.slack.com/services/... \
  --forward-script @transform.js

The sandbox has no outbound network, so a script can reshape a payload but never send it anywhere. It never sees the rule's signing secret either, because OtterKit handles the signed, retried delivery. The script is loaded and tested in a throwaway isolate when you add the rule, so a syntax error or missing export is a 400 error at creation time. A script that throws, times out, or exceeds the CPU limit fails that delivery, which is retried like any other failure. Isolates are cached by content hash, so a busy rule has no per-request load cost.

Notifications#

There are four ways to hear about arriving requests. Set them on the endpoint's console page, or with CLI flags at creation.

Browser notifications. The bell on the live-traffic header shows a desktop notification when a request arrives while the tab is in the background. Bursts combine into one notification, and clicking it focuses the tab.

Email on arrival. The mail toggle, or --notify-email, emails you when requests arrive. It works with the page closed and your machine off. The first request after a quiet spell sends an email right away. After that, you get at most one email per 15 minutes per endpoint, and 20 per day, with the requests in between counted in the next email.

Conditional email rules. The Notifications tab takes the same match clauses, templates, and scripts as forwarding rules, but delivers to your inbox instead of a URL. For example: "email me when event.type = payment.failed". Each rule has the same 15-minute throttle. From the CLI, use --notify-match event.type=payment.failed (repeatable).

Push notifications. Choose Push notifications on the Notifications tab, or pass --notify-push with optional k=v conditions. Matching requests then reach your devices within seconds, with no app, account, or phone number. Delivery uses standard Web Push, end-to-end encrypted to each browser you enable. Click Enable on this device once per device on the Notifications tab. This works on Android, desktop, and iPhone; on iOS, add the console to your Home Screen first, then enable it from the installed app. Every push links back to the endpoint's traffic. Each rule sends at most one push per minute, with extra matches counted as "+N more" on the next push, and each endpoint sends at most 300 pushes per day.

Terminal
# Push on every request
npx otterkit webhook --notify-push

# Push only when a payment fails, and only if the signature is valid
npx otterkit webhook --verify stripe:whsec_… --verified-only \
  --notify-push event.type=payment.failed

Agents set up email and push rules with the MCP webhook_forwarding tool, using destination: 'email' or destination: 'push'.

Drift Watch: know when a provider changes its payloads#

Providers add fields, remove them, and change types, often without notice. On cloud endpoints, turn on Drift Watch from the Drift tab and OtterKit learns the shape of what the endpoint receives. It keeps one baseline per event group, keyed by the first segment of the request path plus the payload's own type, event, or action field, so providers sent to different paths never mix. Each group starts watching after about 20 samples. From then on, every arriving body is compared and changes become findings: new field, missing field (a field that was almost always present, gone 5 requests in a row), type change, format change, and new event type.

Each finding sends one email alert, batched into at most one digest per 15 minutes. After that, the baseline follows the new shape and repeats only bump a counter. On the Drift tab, Accept closes a finding. Ignore closes it and also mutes that exact field path, which you can undo from the same tab. With signature verification set up, only verified requests feed the baseline, so spoofed traffic can't poison it. Agents get the same findings from the MCP schema_drift tool: notice the change, read the sample, and patch the handler.

Continue a /try webhook#

The try page gives you a webhook URL with no account. It lasts 30 minutes and captures up to 50 requests. To keep it, run the claim command with the code from the page. The webhook moves to your terminal on the same public URL, becomes a metered session on your account, and everything captured on the page is copied into your local log:

Terminal
npx otterkit webhook --claim <code-from-the-try-page>