Oakhook: webhook relay
A permanent inbound URL for webhooks from Stripe, GitHub, Shopify, or any other service. Oakstack accepts and stores every event the moment it arrives, then forwards it to your app with the original headers and body. It keeps retrying for about 11 hours while your app is down, and you can replay any event.
Use it so that a deploy, an outage, or a bug in your webhook handler never loses an event.
Quick start
- Get an API key at https://oakstack.dev/dashboard/api-keys and set it as
OAKSTACK_API_KEY. - Create an endpoint that forwards to your app's existing webhook route:
import { Oakstack } from "oakstack";
const oakstack = new Oakstack();
const endpoint = await oakstack.hook.endpoints.create({
name: "Stripe",
forwardUrl: "https://yourapp.com/api/webhooks/stripe",
});
console.log(endpoint.url); // https://oakstack.dev/h/...
- Give
endpoint.urlto the sender, in place of your app's URL. In Stripe that's Developers → Webhooks → Add endpoint. - Keep verifying the sender's own signature in your app, exactly as before. Oakstack forwards the original headers (for example
Stripe-Signature) and the exact body bytes, so it still passes. Optionally also verifyOakstack-Signaturewithendpoint.signingSecret(see Verifying requests).
When something goes wrong:
const failed = await oakstack.hook.endpoints.events(endpoint.id, { status: "failed" });
const detail = await oakstack.hook.events.get(failed[0].id); // headers, body, every attempt
// ...fix your handler, then:
await oakstack.hook.events.replay(failed[0].id);
Agents can do the same with the MCP tools hook_create_endpoint, hook_list_events, hook_get_event, and hook_replay_event.
What happens to an event
- The sender POSTs to
https://oakstack.dev/h/<token>. Oakstack stores the event and immediately answers200 {"received": true, "id": "..."}. The sender sees success even if your app is down. - Oakstack forwards it to
forwardUrlright away, with the same method, body, and headers, plus:
Oakstack-Event-Id: 2b7e... the same across retries; use it to skip duplicates
Oakstack-Endpoint-Id: 9c1d...
Oakstack-Received-At: 2026-10-05T14:02:11.000Z
Oakstack-Attempt: 1 1-8
Oakstack-Signature: t=1791208931,v1=a41f...
- Any
2xxfrom your app within 15 seconds marks the eventdelivered. Anything else is a failure, and it's retried after about 30 seconds, then 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, and 6 hours: 8 attempts over about 11 hours. After that the event isfailedand stays in the log, where you can replay it.
Delivery is at least once and order isn't guaranteed, so make your handler safe to run twice. Oakstack-Event-Id, or the sender's own event id, works as a dedupe key.
Redirects aren't followed. Forward to the final URL.
Pausing
Pausing an endpoint pauses forwarding only. Events keep arriving and are stored, and when you resume, they are all forwarded. Use it during a risky deploy or while you fix a broken handler.
Replay
POST /api/v1/hook/events/:id/replay (or the Replay button in the dashboard) forwards a copy of the event again, with replayOf pointing at the original. Replays don't count toward your monthly event limit.
Verifying requests
import { verifySignature } from "oakstack";
export async function POST(req: Request) {
const raw = new Uint8Array(await req.arrayBuffer()); // read once, use for every check
if (
!(await verifySignature(
process.env.OAKSTACK_SIGNING_SECRET!,
req.headers.get("oakstack-signature"),
raw,
))
) {
return new Response("bad signature", { status: 401 });
}
// Then verify the sender's own signature on the same raw body, as usual, e.g.
// stripe.webhooks.constructEvent(Buffer.from(raw), req.headers.get("stripe-signature")!, STRIPE_WEBHOOK_SECRET)
return Response.json({ ok: true });
}
A replayed event gets a fresh Oakstack-Signature, but the sender's original signature keeps its old timestamp. A strict sender check (Stripe's default tolerance is 5 minutes) may reject a replay of an old event. The scheme, and versions without the SDK, are in signatures.
API reference
SDK methods: oakstack.hook.endpoints.create, .list, .get, .update, .delete, .events; oakstack.hook.events.get, .replay.
All REST endpoints take Authorization: Bearer <API key>. Write endpoints accept Idempotency-Key.
| Method | Path | What it does |
|---|---|---|
POST |
/api/v1/hook/endpoints |
Create an endpoint: { "name", "forwardUrl" }. Returns 201. |
GET |
/api/v1/hook/endpoints |
List endpoints. |
GET |
/api/v1/hook/endpoints/:id |
Get one endpoint. |
PATCH |
/api/v1/hook/endpoints/:id |
Change name or forwardUrl, or set status to paused or active. |
DELETE |
/api/v1/hook/endpoints/:id |
Delete an endpoint and its events. Its inbound URL then answers 404. |
GET |
/api/v1/hook/endpoints/:id/events |
Recent events, newest first. ?status=failed, ?limit= up to 100. |
GET |
/api/v1/hook/events/:id |
One event with headers, body, and every delivery attempt. |
POST |
/api/v1/hook/events/:id/replay |
Forward the event again. Returns 202 with the new event. |
Endpoint fields: id, name, url (inbound), forwardUrl, status, pausedReason, lastEventAt, signingSecret, createdAt, updatedAt.
Event fields: id, endpointId, replayOf, receivedAt, method, sizeBytes, sourceIp, status (pending, delivering, delivered, or failed), attempt, maxAttempts, nextAttemptAt, lastResponseStatus, lastError, and deliveredAt. A single event also has headers, body, bodyEncoding (utf8, or base64 for binary bodies), and attempts.
The inbound URL
POST, PUT, or PATCH to /h/<token>. No API key is needed: the unguessable token is the address, so treat it like a secret. If one leaks, create a new endpoint and delete the old one.
| Status | When |
|---|---|
| 200 | Stored. Body: {"received": true, "id": "..."} |
| 404 | Unknown or deleted endpoint |
| 405 | Wrong method (for example GET) |
| 413 | Body over 1 MB |
| 429 | Your workspace reached its monthly event limit. Senders retry later. |
| 403 | Your workspace is paused or suspended |
Limits
| Plan | Events per month |
|---|---|
| Free | 1,000 |
| Builder | 50,000 |
| Studio | 250,000 |
Counted when an event arrives; replays are free. Event logs are kept for 7 days (30 on Studio). Bodies up to 1 MB.