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

  1. Get an API key at https://oakstack.dev/dashboard/api-keys and set it as OAKSTACK_API_KEY.
  2. 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/...
  1. Give endpoint.url to the sender, in place of your app's URL. In Stripe that's Developers → Webhooks → Add endpoint.
  2. 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 verify Oakstack-Signature with endpoint.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

  1. The sender POSTs to https://oakstack.dev/h/<token>. Oakstack stores the event and immediately answers 200 {"received": true, "id": "..."}. The sender sees success even if your app is down.
  2. Oakstack forwards it to forwardUrl right 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...
  1. Any 2xx from your app within 15 seconds marks the event delivered. 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 is failed and 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.