User guide
HookStead documentation
Everything you need to receive webhooks on a stable URL, forward them to your endpoints with signatures and retries, and find, fix and replay the ones that went wrong.
Last updated: October 2026
01 / Getting started
Getting started
From sign-up to your first delivered webhook in about five minutes.
How HookStead works
HookStead sits between the services that send you webhooks and the endpoints that handle them.
| Term | What it is |
|---|---|
| Source | A URL you give to a sender (Stripe, GitHub, your own app). It decides who may send and how requests are checked. |
| Event | One request received on a source: headers, body, and when it arrived. |
| Destination | Your endpoint. HookStead forwards events to it, signed with its own secret. |
| Connection | Links a source to a destination. One source can feed many destinations, and the other way round. |
| Delivery | One event on its way to one destination, with every attempt recorded. |
| Workspace | Keeps environments apart, e.g. Production and Staging. Each has its own sources and destinations. |
| Organization | Your team and your plan. Members and billing belong to the organization. |
Account and organization
- Create an account with your email address and a password, then click the link in the verification email.
- Create an organization: a name and a URL (e.g.
acme). You become its owner, and a Production workspace is created for you. - The workspace page shows a Get started checklist. It ticks itself off as you go and disappears once your first delivery succeeds.
Your first webhook
- Sources → enter a name → Create source. Copy the URL it shows.
- Send something to it, or use Send a test event on the checklist:
curl -X POST "https://<your Hookstead>/in/<source token>" -H 'content-type: application/json' -d '{"type":"order.paid","id":"ord_123"}'- Destinations → your endpoint's name and URL → Create destination. Copy the signing secret now: it's shown once.
- On the destination's page, pick the source and Connect source.
- Send another event. It arrives at your endpoint, and the delivery shows as succeeded.
02 / Receiving webhooks
Receiving webhooks
Every request to a source URL is checked, stored and acknowledged before the sender gets its answer.
Sources
A source URL looks like https://<your HookStead>/in/<token>. Anything after the token is kept as the event's path, so one source can take /in/<token>/orders and /in/<token>/refunds. On the source's page you can disable it (requests then get 404) or rotate the URL if it leaked; rotating stops the old URL immediately. It also shows the methods the source accepts (POST by default) and the status it answers with (202 by default); change those through the API.
The sources list shows each source's status: Receiving (events in the last 24 hours), Quiet (nothing for longer), Rejecting (its latest request, within the hour, failed verification; usually a wrong or rotated secret), Paused or Waiting for first event. Next to it are the last 24 hours: events per hour, delivery success and rejected requests.
A new source's page waits for its first event and updates by itself when it arrives. Each source page also breaks down the last 7 days by event type (for example invoice.paid, read from the field each provider uses) and lists the last 50 rejected requests with the reason, such as a missing header, a timestamp outside the allowed window, or a signature that doesn't match. Bodies of rejected requests are never stored.
Verifying senders
When you create a source, first pick the provider: Stripe, PayPal, Lemon Squeezy, QuickBooks, Shopify, HubSpot, GitHub, GitLab, Linear, Vercel, AWS SNS, Docker Hub, OpenAI, Clerk, Supabase or Standard Webhooks. HookStead then sets up verification for that provider and shows its setup steps, so you only paste the secret. Choose Custom / other to pick the method yourself under Verify requests with. Requests that fail get 401 and are never stored. Signatures are compared in constant time.
| Option | Checks | Secret |
|---|---|---|
| Stripe signature | Stripe-Signature | The endpoint's whsec_… from Stripe (5-minute timestamp tolerance) |
| GitHub signature | X-Hub-Signature-256 | The webhook secret you set in GitHub |
| Shopify signature | X-Shopify-Hmac-Sha256 | Your app's client secret |
| Standard Webhooks | webhook-signature or svix-signature | The sender's whsec_… secret (also OpenAI and Clerk) |
| Custom HMAC-SHA256 | A header you name, hex or base64, optional prefix | Your shared secret |
| Secret header | A header you name must equal the secret | Any long random string |
| Basic auth | Authorization: Basic | user:password |
| Vercel signature | x-vercel-signature | The webhook's secret (HMAC-SHA1) |
| HubSpot signature (v3) | X-HubSpot-Signature-v3 | Your app's client secret (5-minute timestamp tolerance) |
| AWS SNS signature | Certificate signature in the message body; optional topic ARN allowlist | None: SNS's certificate is fetched from AWS. Subscriptions are confirmed automatically |
| PayPal signature | PAYPAL-TRANSMISSION-SIG | None: your PayPal webhook ID, plus PayPal's certificate |
| None | Nothing: anyone with the URL can send | — |
With None, the URL is the only secret. Use it only for senders that can't sign, and rotate the URL if it leaks.
Duplicates
Senders retry, so the same webhook can arrive twice. Set Dedupe header (optional) to a header that is unique per webhook (GitHub: X-GitHub-Delivery). A repeat is answered with 200, "duplicate": true and the original event's id, and isn't stored or delivered again. Through the API you can dedupe on a JSON field instead (e.g. $.id for Stripe).
Redaction and the original
What you see in the dashboard is a copy with sensitive values replaced by [redacted]: headers such as authorization, cookie and anything containing signature, token or secret, and body fields such as password, card_number, cvv and iban. Add your own patterns per source through the API (redactionRules).
The original request is stored encrypted and forwarded unchanged. Admins can open it with Reveal original; every view is recorded in the audit log.
What senders get back
| Status | Meaning |
|---|---|
| 202 | Stored (or your source's configured status). The body has the event id. |
| 200 duplicate | Seen before (dedupe). The original event id is returned. |
| 401 | The verification check failed. Nothing is stored. |
| 404 | Unknown, disabled or deleted source. Always the same response. |
| 405 | A method the source doesn't accept. |
| 413 | The body is over your plan's or the source's size limit. |
| 429 | Rate limited, or the monthly event quota is used. Retry-After says when to try again. |
| 503 | Temporarily unable to store. Senders retry these. |
03 / Delivering webhooks
Delivering webhooks
Each event goes to every connected destination, byte for byte, until your endpoint accepts it.
Destinations and connections
A destination needs a Name and an HTTPS URL. Optional settings: the Method, the Timeout (seconds) (15 by default, up to 60), Max attempts (capped by your plan), extra headers (stored encrypted, masked in the dashboard), and how many deliveries may be in flight at once (10 by default).
Connect sources from the destination's page. A connection can be disabled without deleting it; new events then aren't delivered there.
Destinations must be public: addresses on private, loopback and cloud-metadata networks are refused, and redirects are never followed.
Retries and backoff
Any 2xx answer counts as delivered. Anything else (or no answer within the timeout) is retried with exponential backoff with jitter: the first retry comes within about 30 seconds, and the wait can double with each attempt, up to 6 hours between attempts. A Retry-After header from your endpoint is honored.
- 4xx answers (except 408 and 429) are final unless you turn on retrying them: they usually mean the request itself is wrong.
- 410 Gone stops retries and flags the destination.
- After the last attempt the delivery is failed (dead). You can retry it any time.
Verifying our signature
Every attempt carries three headers your endpoint should check:
| Header | Content |
|---|---|
webhook-id | The delivery id. The same on every retry: store it to skip duplicates. |
webhook-timestamp | Unix seconds when this attempt was signed. Reject anything more than 5 minutes off. |
webhook-signature | `v1,` plus the base64 HMAC-SHA256 of {id}.{timestamp}.{raw body}, keyed with the destination's secret. Two values while a secret rotates. |
import { createHmac, timingSafeEqual } from "node:crypto";
// secret: your destination's signing secret ("whsec_…")
// headers: the request headers; rawBody: the body as received (a Buffer), before JSON.parse
export function verifyHookstead(secret, headers, rawBody, toleranceSec = 300) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
return signatures.split(" ").some((entry) => {
const [version, sig] = entry.split(",");
const given = Buffer.from(sig ?? "", "base64");
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
}The scheme is Standard Webhooks, so any Standard Webhooks library works. Complete receivers for Node.js, Python and Go: Verify signatures.
The sender's own signature header (e.g. Stripe-Signature) is forwarded unchanged, but it expires after a few minutes, so a later retry or replay fails it. Verify HookStead's signature instead.
Rotating a signing secret
- On the destination's page, choose how long the old secret keeps signing (Keep the old secret signing for) and Rotate signing secret. The new secret is shown once.
- During that time every delivery carries two signatures, old and new. Update your endpoint to the new secret whenever it suits you.
- When the overlap ends, only the new secret signs.
Pausing a destination
Pause deliveries holds new deliveries instead of sending them, for example during a maintenance window. Resume deliveries sends everything that was held. Deleting a destination cancels its pending deliveries.
04 / Debugging and recovery
Debugging and recovery
Find what went wrong, fix your endpoint, and send it again. Nothing is lost while you do.
Finding events
Events lists everything received, newest first. Filter by text anywhere in the body or headers, by source, by a header (x-shop: shop-7), by a JSON value ($.data.object.customer=cus_42), by time range, or by delivery outcome (e.g. only events with a failed delivery). Deliveries lists every delivery, filterable by status, error type, HTTP status and source.
A delivery's attempts
Open a delivery to see every attempt: when it ran, what triggered it (first try, automatic retry, manual retry or replay), the request that was sent, and the response: status, headers, the start of the body, and how long it took. Errors are named (TIMEOUT_RESPONSE, HTTP_5XX, CONNECTION_REFUSED, …) so you can filter by them.
Retry, replay, bulk retry
| Action | Where | What it does |
|---|---|---|
| Retry | A delivery | Sends the same delivery again now: same webhook-id, so your endpoint can dedupe it. |
| Replay event | An event | Creates new deliveries of the event to the destinations you choose, with new webhook-ids. Counts toward your monthly events. |
| Bulk retry | Deliveries, with a filter | Retries up to 1,000 matching deliveries in the background, with progress. One bulk job per workspace at a time. |
A double click never sends twice: each of these carries an idempotency key. Replays are rate limited per organization and per person.
05 / Monitoring and alerts
Monitoring and alerts
Know about a broken endpoint before your customers do.
Destination health
Each destination shows a health status, from its last 15 minutes:
| Status | When |
|---|---|
| Healthy | Everything else. |
| Degraded | 2 failures in a row, or at least 10% of attempts failed (with 5 or more attempts). |
| Failing | 5 failures in a row, or at least 50% of attempts failed (with 5 or more attempts). |
Metrics
Metrics shows events received, delivered and failed attempts, the success rate and p95 latency for the last 24 hours, 7 days or 30 days. Each chart has a table view.
Alert rules
Alerts → Create alert rule. Alerts are emailed to members of your organization you pick.
| When | Fires when |
|---|---|
| Failure rate | The share of failed attempts in the window reaches your threshold (with enough attempts). |
| Failures in a row | A destination fails your chosen number of times in a row. |
| Failed deliveries | Deliveries give up (fail for good) in the window. |
| Source inactive | A source receives nothing for the window, e.g. a sender that stopped sending. |
You get one email when an incident opens and one when it resolves (after 3 quiet minutes). A quiet period stops repeat emails for an endpoint that keeps flapping. Acknowledge an incident to show your team someone is on it.
06 / Team and settings
Team and settings
Who can do what, and the trail they leave.
Roles
| Role | Can |
|---|---|
| Viewer | See everything in the organization's workspaces: events (redacted), deliveries, metrics, alerts. |
| Member | Also create and change sources, destinations, connections and alert rules; retry and replay. |
| Admin | Also change verification and redaction, view originals, manage workspaces, members, API keys and the audit log. |
| Owner | Also manage billing and grant the owner role. Every organization keeps at least one owner. |
Invite people under Settings → Members → Send invite. Invitations are single-use, valid for 7 days, and only work for the invited email address.
Workspaces
Settings → Workspaces. Use one per environment, so test traffic never mixes with production. Members and roles apply to all workspaces of the organization. Deleting a workspace deletes its sources, destinations and events.
API keys
Settings → API keys. A key belongs to the organization, has a role (up to Admin), and can expire after 1 to 365 days. It's shown once; we store only a hash. Revoking takes effect immediately. Keys can never manage keys, members or billing.
Audit log
Settings → Audit log (admins) records security-relevant changes: members and roles, API keys, secrets, views of original requests, retries and replays, billing, and deletions, with who did it and from where.
07 / Plans and billing
Plans and billing
Every plan has every feature. Plans differ in volume, retention and team size.
Plans and limits
| Free | Pro | Team | |
|---|---|---|---|
| Events per month | 10,000 | 500,000 | 5,000,000 |
| Retention | 3 days | 14 days | 30 days |
| Workspaces | 1 | 3 | 10 |
| Sources / destinations per workspace | 3 / 3 | 25 / 25 | 100 / 100 |
| Members | 1 | 5 | 25 |
| Delivery attempts | up to 3 | up to 8 | up to 15 |
Prices are on the pricing page. Events older than your plan's retention are deleted automatically.
Usage and the monthly quota
Settings → Billing shows this month's usage: each webhook received counts once, however many attempts its deliveries take; each replayed delivery counts once more. Owners get an email at 80% and 100%. At 100%, new webhooks are answered with 429 and Retry-After until the month resets (on the 1st, UTC). Senders retry 429s, so upgrading lets them through.
Changing plans
- Upgrade: Settings → Billing → Upgrade to Pro or Upgrade to Team, monthly or yearly. Checkout is handled by Stripe; the new limits apply as soon as the payment is confirmed.
- Change, cancel, update your card or download invoices: Manage billing opens Stripe's customer portal.
- Downgrades and cancellations take effect at the end of the period you paid for. Nothing is deleted, but you can't create more than the new plan allows.
- A failed payment keeps your plan for 7 days while you update your card.
Only owners can change billing.
08 / REST API
REST API
Everything in the dashboard is in the API, over HTTPS and JSON.
Basics
- Base URL:
https://<your HookStead>/api/v1. Authenticate withAuthorization: Bearer hw_live_…. - Errors are
application/problem+jsonwith a stablecode(validation_failed,plan_limit_reached,quota_exceeded, …). Another organization's resources always answer 404. - Retry, replay, bulk retry and checkout require an
Idempotency-Keyheader: the same key and body return the stored response for 24 hours.
curl https://<your Hookstead>/api/v1/me -H "authorization: Bearer hw_live_…"Every endpoint, with its parameters and rules: API reference. The OpenAPI 3.1 description for code generators is at /api/openapi.json.
09 / Troubleshooting and FAQ
Troubleshooting and FAQ
The usual suspects, and what to do about them.
Receiving
My sender gets 401 and nothing shows up.
The verification check failed. Check that the source's secret is the one the sender signs with (for Stripe: that endpoint's signing secret, not your API key), and that nothing between the sender and HookStead changes the body. For timestamped schemes (Stripe, Standard Webhooks), the sender's clock must be within 5 minutes.
My sender gets 404.
The URL is wrong, the source is disabled or deleted, or its URL was rotated. Copy the URL again from the source's page.
My sender gets 413 or 429.
413: the body is bigger than your plan or the source allows. 429: you're over the ingest rate, or this month's quota is used up (see Usage). Both say what the limit is; 429 also says when to retry.
Delivering
Deliveries fail with a timeout.
Your endpoint took longer than the destination's timeout. Answer 2xx right away and do the work afterwards (a queue or a background job), or raise the timeout (up to 60 seconds).
My endpoint rejects HookStead's signature.
- Verify the raw body exactly as received, before any JSON parsing or re-encoding.
- Use the destination's secret (
whsec_…), base64-decoded after the prefix, not the source's or the sender's secret. - Accept any of the space-separated signatures: there are two while a secret rotates.
- Check your server's clock if you enforce the 5-minute window.
The destination URL is refused.
HookStead delivers only to public HTTPS addresses. For local development, expose your endpoint with a tunnel (e.g. a public HTTPS URL that forwards to your machine).
My endpoint received the same webhook twice.
Deliveries are at least once: a retry after a timeout can arrive although your endpoint did process the first attempt. Store each webhook-id you handle and answer 2xx again for a repeat.
Frequently asked questions
Is anything lost if my endpoint is down for hours?
No. Events are stored before the sender gets its answer, and retries continue with backoff until the destination's last attempt. After that, retry or bulk-retry the failed deliveries once you're back, within your plan's retention.
Who can see webhook contents?
Members of your organization see the redacted copy. Only admins can reveal the original, and every view is logged. Originals and secrets are encrypted at rest.
Can I self-host HookStead?
Yes: it ships as Docker images with a production compose file. With billing turned off, every organization gets one plan's limits and nothing talks to Stripe.
Where do I get help?
Email support@hookstead.dev. Include the event or delivery id from the dashboard, never secrets.