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

```ts
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/...
```

3. Give `endpoint.url` to the sender, in place of your app's URL. In Stripe that's Developers → Webhooks → Add endpoint.
4. 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](#verifying-requests)).

When something goes wrong:

```ts
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...
```

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

```ts
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](https://oakstack.dev/docs/signatures.md).

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