Weekly Planner
API

REST API

Token-authenticated REST endpoints for boards, events, and todos — with copy-paste examples.

REST API

Automate Weekly Planner with a personal access token. Create tokens on the account page (or via /api/tokens), then call /api/v1 with Authorization: Bearer wp_….

Secrets are shown once

The plaintext wp_… value appears only when you create or rotate a token. Store it immediately — the server keeps a SHA-256 hash only (src/server/api-tokens.js).

Base URL

EnvironmentBase
Productionhttps://plan.ij5.dev/api/v1
Local (Vite + Vercel dev)http://localhost:3000/api/v1

Every request needs a bearer header:

Authorization: Bearer wp_…
Content-Type: application/json

Quick start

Create a token

Sign in at plan.ij5.dev/account, open API 토큰, name the token (e.g. automation), and copy the wp_… secret when it appears.

Verify the token

curl https://plan.ij5.dev/api/v1/me \
  -H "Authorization: Bearer wp_YOUR_TOKEN"

Expected response:

{
  "id": "user-id",
  "email": "you@example.com"
}

List boards

curl https://plan.ij5.dev/api/v1/boards \
  -H "Authorization: Bearer wp_YOUR_TOKEN"

How authorization works

The API never widens access beyond what the token owner can do in the app. After resolving the token, the handler impersonates the owner with the admin SDK (db.asUser({ email })), so Instant permission rules — board ownership, membership, share links, and rate limits — evaluate exactly as they do for the signed-in client.

Endpoints

MethodPathBodyNotes
GET/meToken owner (id, email)
GET/boardsOwned + member boards, with role
POST/boardsname?, from?, to?, repeatEvery?201 with the created board
GET/boards/:idBoard + its events
PATCH/boards/:idany board fieldsOwner only (perms)
DELETE/boards/:idCascades to events
GET/boards/:id/eventsEvents for one board
POST/boards/:id/eventsday, title, start, dur, color?, memo?Normalized via eventFields()
GET/events/:idSingle event
PATCH/events/:idpartial event fieldsMerged then re-normalized
DELETE/events/:id
GET/todos?day=YYYY-MM-DDChecked-off marks
POST/todosday, eventId
DELETE/todos/:id
POST/token/refreshRotates the calling token

Planner day grid

Dates are YYYY-MM-DD. Event start and dur are minutes on the 06:00-origin grid (not midnight). Values are snapped and clamped like in-app edits (src/board/models.js). Colors must be one of: coral, amber, green, teal, sky, violet, pink, graphite.

Examples

Replace BOARD_ID, EVENT_ID, and wp_YOUR_TOKEN with real values from your account.

Create a board

curl -X POST https://plan.ij5.dev/api/v1/boards \
  -H "Authorization: Bearer wp_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"API test","from":"2026-07-14","to":"2026-07-20"}'
201 Created
{
  "board": {
    "id": "board-id",
    "name": "API test",
    "from": "2026-07-14",
    "to": "2026-07-20",
    "repeatEvery": 0,
    "colorLabels": "",
    "createdAt": 1721188800000,
    "role": "owner"
  }
}

Add an event

day is the weekday index (0 = Sunday … 6 = Saturday). start is minutes from 06:00 on that planner day; dur is duration in minutes.

curl -X POST "https://plan.ij5.dev/api/v1/boards/BOARD_ID/events" \
  -H "Authorization: Bearer wp_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "day": 1,
    "title": "Standup",
    "start": 60,
    "dur": 30,
    "color": "sky",
    "memo": "Daily sync"
  }'
201 Created
{
  "event": {
    "id": "event-id",
    "boardId": "board-id",
    "day": 1,
    "title": "Standup",
    "start": 60,
    "dur": 30,
    "color": "sky",
    "memo": "Daily sync",
    "createdAt": 1721188800000
  }
}

Patch an event

Send only the fields you want to change. The server merges onto the stored row and re-normalizes.

curl -X PATCH "https://plan.ij5.dev/api/v1/events/EVENT_ID" \
  -H "Authorization: Bearer wp_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Standup (async)","dur": 15}'

Mark a todo done

Todos are per-user checkmarks on schedule events for a planner day.

curl -X POST https://plan.ij5.dev/api/v1/todos \
  -H "Authorization: Bearer wp_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"day":"2026-07-17","eventId":"EVENT_ID"}'

Rotate the calling token

No session required — the bearer token refreshes itself. The old value stops working immediately.

curl -X POST https://plan.ij5.dev/api/v1/token/refresh \
  -H "Authorization: Bearer wp_YOUR_TOKEN"
200 OK
{
  "id": "token-row-id",
  "token": "wp_NEW_SECRET",
  "prefix": "wp_abcd"
}

Session-based token management

While signed in, you can also manage tokens through /api/tokens with the Instant refresh token in the token header — the same convention as /api/invite. The account page uses this path for create, rotate, and revoke.

Token lifecycle

/api/tokens manages tokens with the signed-in session (header token = Instant refresh token):

MethodAction
GETList (id, name, prefix, createdAt, lastUsedAt; never the secret)
POST { name? }Create (≤ 10 per account; guests rejected)
POST { rotate: id }New secret for an existing token
DELETE { id }Revoke

Clients may list/revoke their own rows directly through Instant (apiTokens perms), but the hash field is unreadable and create/update are server-only.

Rate limits

Two layers, both token buckets (Instant rate limits):

  1. API layer — 120 requests/minute per token (src/server/rest.js createRateLimiter); exceeding it returns 429 with Retry-After.
  2. Instant $rateLimits (instant.perms.ts) — enforced inside permission rules for every writer, app or API: eventWrites (120/min burst, 2000/day sustained, keyed by auth.id, or by share secret for guests) and todoWrites (300/hour). Rule order matters: rateLimit.limit() sits last in the && chain so denied writes never consume tokens.

Handle 429s

Read the Retry-After header (seconds) and back off before retrying. Burst automation against event writes can hit both layers.

Errors

JSON { "error": "…" } with conventional statuses:

StatusMeaning
400Invalid payload or validation
401Missing or unknown bearer token
403Owner lacks permission for this row
404No such row or route
429Rate-limited (API or Instant)
500Server misconfiguration or unexpected failure

Instant permission failures are mapped in instantErrorStatus() inside src/server/rest-api.js (served via api/v1.js after a Vercel rewrite of /api/v1/* — Vite on Vercel has no catch-all API routes).

401 Unauthorized
{ "error": "Invalid token" }
403 Forbidden
{ "error": "Not allowed" }
429 Too Many Requests
{ "error": "Rate limit exceeded" }

Response headers on 429:

Retry-After: 42

On this page