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 |
| 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
- Create an account and an API key at https://oakstack.dev/dashboard/api-keys.
- Put the key in your app's environment as
OAKSTACK_API_KEY. Keys start withok_live_orok_test_; never commit them. - Pick one or more ways in:
SDK (TypeScript/JavaScript):
npm install oakstack
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):
# 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 <key>.
Quick examples
// 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).
Errors
Every error response is JSON with a machine-readable name and a message that says what to fix:
{
"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:
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:
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)
- Webhook relay (hook)
- Verifying signatures
- Everything in one file: https://oakstack.dev/llms-full.txt