# Oakstack overview Oakstack is one API key for the infrastructure every app ends up needing: | Module | What it does | Status | | --------- | ---------------------------------------------------------------------------------- | ----------- | | **clock** | Call a URL in your app on a cron schedule, with retries and a run log | Available | | **hook** | A permanent inbound URL for webhooks: store every event, forward it, retry, replay | Available | | **print** | HTML in, PDF out, with hosted templates | Coming soon | | **post** | Transactional email, plus inbound email delivered to a hook | Coming soon | It's built for coding agents. The SDK, the MCP server, and these docs are designed so an agent can set everything up from a one-line request like "run my cleanup every night at 3am Chicago time." ## Setup 1. Create an account and an API key at https://oakstack.dev/dashboard/api-keys. 2. Put the key in your app's environment as `OAKSTACK_API_KEY`. Keys start with `ok_live_` or `ok_test_`; never commit them. 3. Pick one or more ways in: **SDK (TypeScript/JavaScript):** ```sh npm install oakstack ``` ```ts import { Oakstack } from "oakstack"; const oakstack = new Oakstack(); // reads OAKSTACK_API_KEY ``` The SDK has zero dependencies and runs in Node 18+, Bun, Deno, Vercel Edge, and Cloudflare Workers. **MCP (lets your coding agent manage Oakstack directly):** ```sh # Hosted, nothing to install: claude mcp add --transport http oakstack https://oakstack.dev/mcp --header "Authorization: Bearer $OAKSTACK_API_KEY" # Or local over stdio: claude mcp add oakstack -e OAKSTACK_API_KEY=ok_live_... -- npx -y oakstack-mcp ``` **REST:** every endpoint is under `https://oakstack.dev/api/v1/` and takes `Authorization: Bearer `. ## Quick examples ```ts // Call your app every weekday at 9:00 Chicago time. const job = await oakstack.clock.jobs.create({ name: "Morning digest", schedule: "0 9 * * 1-5", timezone: "America/Chicago", url: "https://yourapp.com/api/cron/digest", }); // A permanent webhook URL for Stripe that never loses events. const endpoint = await oakstack.hook.endpoints.create({ name: "Stripe", forwardUrl: "https://yourapp.com/api/webhooks/stripe", }); // Give endpoint.url to Stripe. ``` Requests Oakstack sends to your app are signed. Verify them with `verifySignature` (see [signatures](https://oakstack.dev/docs/signatures.md)). ## Errors Every error response is JSON with a machine-readable `name` and a `message` that says what to fix: ```json { "statusCode": 400, "name": "invalid_request", "message": "schedule: must be a 5-field cron expression ..." } ``` The SDK throws these as `OakstackError` (`statusCode`, `errorName`, `message`). | Status | `name` | Meaning | What to do | | ------ | ------------------------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------- | | 400 | `invalid_request` | A field is missing or invalid | Fix the field named in `message` | | 400 | `invalid_idempotency_key` | Bad Idempotency-Key header | Use 1-255 printable characters, such as a UUID | | 401 | `missing_api_key` / `invalid_api_key` | No key, or the key is wrong, revoked, or expired | Check `OAKSTACK_API_KEY` | | 403 | `restricted_api_key` | A standard key used on a full-access endpoint | Use a full-access key | | 403 | `workspace_paused` / `workspace_suspended` | The workspace has been stopped | See the dashboard | | 404 | `not_found` | No such object in your workspace | Check the id | | 409 | `idempotency_in_progress` | The same Idempotency-Key is still being processed | Retry shortly (the SDK does this) | | 422 | `idempotency_key_reused` | The same key was sent with a different request | Use a new key | | 429 | `rate_limited` | Too many requests this minute | Wait for `Retry-After` (the SDK does this) | | 429 | `usage_limit_reached` | Your plan's limit is used up | Upgrade, free up capacity, or wait for the monthly reset. Don't retry. | | 500 | `internal_error` | Something failed on Oakstack's side | Retry (the SDK does this) | ## Retries and idempotency Send an `Idempotency-Key` header on any `POST`, `PATCH`, or `DELETE`. If you retry with the same key, you get the original response back instead of a second job or endpoint. Keys are remembered for 24 hours per workspace. The SDK does this for you: every write gets a fresh key that's reused across its automatic retries. To make a write safe across process restarts too (for example, a setup script), pass your own key: ```ts await oakstack.clock.jobs.create(params, { idempotencyKey: "setup-nightly-cleanup-v1" }); ``` ## Plans and limits Limits are hard caps. When you reach one, Oakstack stops with `usage_limit_reached` instead of charging you more. | | Free | Builder ($20/mo) | Studio ($49/mo) | | ------------------------ | -------------- | ---------------- | --------------- | | Scheduled jobs | 3 | 25 | 100 | | Webhook events per month | 1,000 | 50,000 | 250,000 | | PDFs per month (soon) | 25 | 1,000 | 5,000 | | Emails per month (soon) | 100, test mode | 10,000 | 50,000 | | API requests per minute | 60 | 600 | 1,200 | | Log retention | 7 days | 7 days | 30 days | Monthly limits reset on the 1st (UTC). Check usage any time: ```ts const { plan, usage } = await oakstack.usage(); ``` Every API response also carries `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` headers. ## What Oakstack will and won't call Cron jobs and webhook forwarding only call **public `https://` URLs**. Localhost, private networks, and cloud metadata addresses are refused, including hostnames that resolve to them. Redirects are not followed. Your endpoint should answer `2xx` within 15 seconds; for longer work, answer `202` and continue in the background. To test locally, expose your dev server with a tunnel (for example `cloudflared tunnel` or `ngrok`) and use its https URL. ## More - [Scheduled jobs (clock)](https://oakstack.dev/docs/clock.md) - [Webhook relay (hook)](https://oakstack.dev/docs/hook.md) - [Verifying signatures](https://oakstack.dev/docs/signatures.md) - Everything in one file: https://oakstack.dev/llms-full.txt --- # Oakclock: scheduled jobs Oakstack calls a URL in your app on a cron schedule. Every run is logged with its result. Failed calls are retried, and a job that keeps failing pauses itself instead of hammering a broken URL. Use it for nightly cleanups, daily digests, syncing data every 15 minutes, or anything you would otherwise put in a server cron. ## Quick start 1. Get an API key at https://oakstack.dev/dashboard/api-keys and set it as `OAKSTACK_API_KEY`. 2. Add a route in your app that does the work, for example `POST /api/cron/cleanup`. 3. Create the job: ```ts import { Oakstack } from "oakstack"; const oakstack = new Oakstack(); const job = await oakstack.clock.jobs.create({ name: "Nightly cleanup", schedule: "0 3 * * *", timezone: "America/Chicago", url: "https://yourapp.com/api/cron/cleanup", body: JSON.stringify({ task: "cleanup" }), }); // Save job.signingSecret as OAKSTACK_SIGNING_SECRET in your app. await oakstack.clock.jobs.run(job.id); // test it now instead of waiting for 3am const [run] = await oakstack.clock.jobs.runs(job.id); // status, responseStatus, error ``` 4. In the route, verify the request came from Oakstack (see [Verifying requests](#verifying-requests)). Or with an agent: the MCP tools `clock_create_job`, `clock_run_job`, and `clock_list_runs` do the same. With curl: ```sh curl https://oakstack.dev/api/v1/clock/jobs \ -H "Authorization: Bearer $OAKSTACK_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: create-cleanup-job" \ -d '{"name":"Nightly cleanup","schedule":"0 3 * * *","timezone":"America/Chicago","url":"https://yourapp.com/api/cron/cleanup"}' ``` ## Schedules `schedule` is a standard 5-field cron expression: `minute hour day-of-month month day-of-week`. It is evaluated in `timezone` (any IANA name, default `UTC`), including daylight saving changes. | Schedule | Meaning | | ------------- | --------------------------------- | | `*/5 * * * *` | every 5 minutes | | `0 * * * *` | every hour, on the hour | | `0 9 * * *` | every day at 9:00 | | `0 9 * * 1-5` | weekdays at 9:00 | | `0 0 1 * *` | midnight on the 1st of each month | The finest granularity is one minute. Six-field expressions (with seconds) are rejected. If Oakstack was unable to run for a while, it fires the job once for the most recent missed time and then continues on schedule. It never fires a backlog of missed runs. ## What your app receives ``` POST https://yourapp.com/api/cron/cleanup Content-Type: application/json (when a body is set and you didn't set one) User-Agent: Oakstack-Clock/1.0 (+https://oakstack.dev) Oakstack-Job-Id: 6f1c... the job Oakstack-Run-Id: 91ab... this run (same across its retries) Oakstack-Scheduled-For: 2026-10-05T08:00:00.000Z Oakstack-Attempt: 1 1-4 Oakstack-Signature: t=1791158400,v1=5f2b... ...plus any headers you set on the job {"task":"cleanup"} ``` Answer with any `2xx` status within 15 seconds to mark the run succeeded. Anything else is a failure: a non-2xx status, a timeout, or a connection error. Redirects are not followed, so give the final URL. If the work takes longer than 15 seconds, answer `202` right away and do the work in the background. Because a run can be retried, make your handler safe to call twice. Use `Oakstack-Run-Id` to skip work you already did. ## Retries and failures - A failed attempt is retried after about 30 seconds, then 2 minutes, then 10 minutes: 4 attempts in all. After the last attempt the run is marked `failed`. - After 10 failed runs in a row, the job pauses itself with a `pausedReason`. Fix the URL, then resume it (`PATCH` with `{"status":"active"}`). - Every attempt's status code, error, the first 2 KB of your response, and the duration are in the run log, both in the dashboard and through `GET /api/v1/clock/jobs/:id/runs`. ## Verifying requests ```ts import { verifySignature } from "oakstack"; export async function POST(req: Request) { const body = await req.text(); if ( !(await verifySignature( process.env.OAKSTACK_SIGNING_SECRET!, req.headers.get("oakstack-signature"), body, )) ) { return new Response("bad signature", { status: 401 }); } // ... do the work. Make it safe to run twice: use Oakstack-Run-Id to skip repeats. return Response.json({ ok: true }); } ``` Each job has its own `signingSecret`. The scheme, and versions without the SDK (Node, Python), are in [signatures](https://oakstack.dev/docs/signatures.md). ## API reference SDK methods: `oakstack.clock.jobs.create`, `.list`, `.get`, `.update`, `.pause`, `.resume`, `.delete`, `.run`, `.runs`. All REST endpoints take `Authorization: Bearer `. Write endpoints accept an `Idempotency-Key` header: retrying with the same key returns the original response instead of creating a second job. | Method | Path | What it does | | -------- | ----------------------------- | ------------------------------------------------------------------- | | `POST` | `/api/v1/clock/jobs` | Create a job. Returns `201` with the job. | | `GET` | `/api/v1/clock/jobs` | List jobs, newest first. `?limit=` up to 100. | | `GET` | `/api/v1/clock/jobs/:id` | Get one job. | | `PATCH` | `/api/v1/clock/jobs/:id` | Change any field, or `{"status":"paused"}` / `{"status":"active"}`. | | `DELETE` | `/api/v1/clock/jobs/:id` | Delete a job and its run history. | | `POST` | `/api/v1/clock/jobs/:id/run` | Run now. Returns `202` with the queued run. | | `GET` | `/api/v1/clock/jobs/:id/runs` | Recent runs, newest first. | Create fields: | Field | Required | Notes | | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | `name` | yes | 1-100 characters | | `schedule` | yes | 5-field cron | | `url` | yes | Public `https://` URL, at most 2048 characters. No credentials in the URL; put them in `headers`. | | `timezone` | no | IANA name, default `UTC` | | `method` | no | `POST` (default), `GET`, `PUT`, `PATCH`, or `DELETE` | | `headers` | no | Up to 20 extra headers, for example `{"Authorization":"Bearer ..."}`. You can't set `Oakstack-*`, `Host`, or `Content-Length`. | | `body` | no | String up to 64 KB, sent as-is | Job fields: `id`, `name`, `schedule`, `timezone`, `url`, `method`, `headers`, `body`, `status` (`active` or `paused`), `pausedReason`, `nextRunAt`, `lastRunAt`, `lastRunStatus`, `consecutiveFailures`, `signingSecret`, `createdAt`, `updatedAt`. Run fields: `id`, `jobId`, `trigger` (`schedule` or `manual`), `scheduledFor`, `status` (`pending`, `running`, `succeeded`, or `failed`), `attempt`, `maxAttempts`, `nextAttemptAt` (while waiting to retry), `responseStatus`, `responseSnippet`, `error`, `durationMs`, `createdAt`, `finishedAt`. ## Errors Errors are JSON: `{ "statusCode": 400, "name": "invalid_request", "message": "schedule: must be a 5-field cron expression ..." }`. Each message says what to fix. | Status | `name` | When | | ------ | --------------------- | ----------------------------------------------------------------------- | | 400 | `invalid_request` | Bad field, invalid schedule or time zone, or a private or `http://` URL | | 401 | `invalid_api_key` | Missing, wrong, or revoked key | | 404 | `not_found` | No job with that id in your workspace | | 429 | `usage_limit_reached` | Your plan's job limit is full. Delete or pause a job, or upgrade. | | 429 | `rate_limited` | Too many API calls per minute. Wait for `Retry-After`. | ## Limits | Plan | Jobs | | ------- | ---- | | Free | 3 | | Builder | 25 | | Studio | 100 | Paused jobs count toward the limit, because the limit is on jobs you keep. Resuming a job is refused when the plan's limit of active jobs is already full. If you downgrade, the newest jobs above the new limit are paused, never deleted. Run logs are kept for 7 days (30 on Studio). --- # 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/`. 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 `. 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/`. 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. --- # Verifying Oakstack signatures Every request Oakstack sends to your app, whether a cron job call (clock) or a forwarded webhook (hook), carries an `Oakstack-Signature` header. Verify it to be sure the request really came from Oakstack and wasn't altered. Each job and each webhook endpoint has its own `signingSecret` (it starts with `oks_`). You get it when you create the job or endpoint, and can view it in the dashboard. Store it as an environment variable, such as `OAKSTACK_SIGNING_SECRET`. ## With the SDK (recommended) ```ts import { verifySignature } from "oakstack"; export async function POST(req: Request) { const body = await req.text(); // read the raw body BEFORE parsing it const ok = await verifySignature( process.env.OAKSTACK_SIGNING_SECRET!, req.headers.get("oakstack-signature"), body, ); if (!ok) return new Response("bad signature", { status: 401 }); const data = JSON.parse(body); // ... do the work return Response.json({ ok: true }); } ``` `verifySignature(secret, header, body, { toleranceSeconds })` accepts the body as a string, `Uint8Array`, or `ArrayBuffer`. It returns `false` (never throws) for a missing, malformed, wrong, or stale signature. The default tolerance is 5 minutes. For binary bodies, pass the bytes: `await verifySignature(secret, header, new Uint8Array(await req.arrayBuffer()))`. ## The scheme ``` Oakstack-Signature: t=1791208931,v1=5f2b0c... ``` - `t`: Unix time in seconds when Oakstack signed the request. - `v1`: lowercase hex HMAC-SHA256, keyed with your signing secret, of `t`, then a `.`, then the raw body bytes. It's the same scheme Stripe uses. Reject the request if `t` is more than 5 minutes from now, which stops old requests from being replayed. ## Without the SDK **Node.js:** ```js import { createHmac, timingSafeEqual } from "node:crypto"; function verify(secret, header, rawBody /* string or Buffer */) { if (!header) return false; const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest(); const given = Buffer.from(parts.v1 ?? "", "hex"); return given.length === expected.length && timingSafeEqual(expected, given); } ``` **Python:** ```python import hashlib, hmac, time def verify(secret: str, header: str | None, raw_body: bytes) -> bool: if not header: return False parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts.get("t", "0")) if abs(time.time() - t) > 300: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` ## Common mistakes - **Verifying a parsed body.** `JSON.stringify(JSON.parse(body))` can differ from the original bytes. Always verify the raw body. - **Framework body parsing.** In Express, use `express.raw({ type: "*/*" })` on the webhook route. In Next.js route handlers, call `req.text()` or `req.arrayBuffer()` once and reuse the result. - **Wrong secret.** Each job and endpoint has its own secret. A webhook forwarded through Oakhook is signed with the endpoint's secret, not the original sender's. - **Stripe, GitHub, and similar senders.** Oakhook forwards their original signature headers unchanged, so keep verifying those with the sender's own secret as well. Note that a _replayed_ event carries a fresh `Oakstack-Signature` but the sender's original, older signature.