API, tokens & webhooks
Everything the apps do goes through the same JSON API at https://api.peekr.dev. It's plain REST, cursor-paginated, and every response is scoped by the visibility rule for whoever's calling.
Authentication
curl https://api.peekr.dev/v1/me \ -H "Authorization: Bearer pk_mcp_…"
- Personal tokens (
pk_mcp_…) from Connect an agent or your account page act as you. Choose all accessible workspaces or a restriction, and whether the connection can write. Group restrictions are also available through the token API. - Bot tokens (
pk_bot_…) from Settings → Bots act as the bot. - Browser sessions and OAuth access tokens work too; you'll rarely need them directly.
Manage settings and invitations is a separate permission for personal connections. It requires write access and unrestricted groups within the approved workspaces. Your role still controls what you can change. Account preferences require all-workspace access. Existing board tokens do not acquire this permission automatically. Credentials, account deletion, automatic retention and ownership transfer remain app operations.
Optionally send X-Peekr-Client: my-tool/1.2 and the board will show that name next to yours on actions.
Endpoints you'll actually use
| Route | Notes |
|---|---|
GET /v1/me | You, your workspaces and roles, and which features the server has on. |
GET /v1/captures?workspace=… | List. Filters: q, status (repeatable), priority, group (id or none), assignee (me, none, id), author, tag, has_images, sort, cursor, limit ≤ 200. |
GET /v1/captures/:id | One capture with images (short-lived signed URLs), comments and activity. |
POST /v1/captures | Create. Body: workspace_id, group_id?, note, status?, priority?, tags?, images? (sha256 from the upload flow), source? { app, window, url }. |
PATCH /v1/captures/:id | Edit fields. Send base_version to get field-level merge instead of last-write-wins. |
POST /v1/captures/:id/claim | assign | resolve | reopen | confirm-resolution | restore | Workflow actions. claim returns a claim receipt; resolve takes { reference, claim_token }; assign takes { assignee_id: id | null }. |
POST /v1/captures/:id/comments | Comment. @mentions by user id in the body's mentions array. |
POST /v1/captures/:id/images | Attach an uploaded image (by sha256) to a capture. |
POST /v1/uploads → POST /v1/uploads/complete | Ask for a presigned PUT for a sha256 you're about to upload; then confirm. Identical bytes are deduplicated per workspace. |
GET /v1/workspaces/:id/groups, POST …/groups, PATCH /v1/groups/:id | Groups, membership under /v1/groups/:id/members. |
GET | PATCH /v1/workspaces/:id | Read settings or change the workspace name and supported defaults. Updates require owner/admin authority and management permission for personal tokens. |
GET | POST /v1/workspaces/:id/invites | List or create invitations. Body: role, email? (omit for a link), groupIds? (required for guests), maxUses? (use 1 for a one-person link). Management permission and invitation authority required. |
DELETE /v1/invites/:id | Revoke a pending invitation. Existing memberships are unchanged. |
PATCH /v1/me/profile; GET | PATCH /v1/notifications/prefs | Your display name or notification preferences. Personal tokens require account-wide management permission. |
GET /v1/events?workspace=… | Server-sent events: a heartbeat plus a `changed` event when anything you can see changes. Cheap to hold open. |
POST /v1/sync, POST /v1/sync/reconcile | What the desktop apps use. Documented in the client source; stable but verbose. |
Errors are { "error": { "code": "…", "message": "…", "details": {…} } } with a fitting HTTP status; details are present when relevant. Capture edits use base_version for per-field merging. Group edits use baseVersion and reject a stale version with 409; read the latest group before reconsidering a sharing change. Do not assume general write requests are idempotent: after an uncertain invitation response, inspect the invitation list before creating another.
Inviting someone to several groups
Send { "role": "guest", "email": "person@example.com", "groupIds": ["group-uuid-1", "group-uuid-2"] } to the workspace invitation endpoint. All groups must belong to that workspace and be groups you can invite to. Acceptance grants the selected groups together; an unavailable group makes the invitation fail without partial admission. Older single-group clients can still send groupId.
Teammates inherit current and future workspace-shared groups. Inviting them does not publish private groups. Email invitations expire after seven days; links after thirty days. A response of queued means the email was scheduled, and sent means the provider accepted it, not that the recipient read it. Use the invitation list for delivery status.
Uploading a screenshot
# 1. hash the bytes
sha=$(shasum -a 256 shot.png | cut -d' ' -f1)
bytes=$(wc -c < shot.png | tr -d ' ')
# 2. ask for an upload slot (returns a presigned PUT, or status: "exists")
curl -X POST https://api.peekr.dev/v1/uploads -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"workspace_id":"…","sha256":"'$sha'","bytes":'$bytes',"content_type":"image/png"}'
# 3. PUT the bytes to the URL you got back, then confirm
curl -X POST https://api.peekr.dev/v1/uploads/complete -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"workspace_id":"…","sha256":"'$sha'","bytes":'$bytes',"content_type":"image/png"}'
# 4. reference it when creating a capture: "images":[{"sha256":"'$sha'","width":…,"height":…}]Webhooks
Settings → Webhooks (admins) or a bot's webhook URL. Events: capture.created, capture.assigned, capture.updated, capture.moved, capture.resolved, capture.proposed, comment.created. Each delivery is a POST with these headers:
| Header | Value |
|---|---|
X-Peekr-Event | The event name |
X-Peekr-Delivery | Unique id; the same id is reused on retries |
X-Peekr-Timestamp | Unix seconds when this attempt was signed |
X-Peekr-Signature | sha256= + hex HMAC-SHA256 of {timestamp}.{raw body} with your signing secret |
// Node — verify before you trust the body
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, headers, rawBody) {
const ts = headers["x-peekr-timestamp"];
if (typeof ts !== "string" || !/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const given = headers["x-peekr-signature"] ?? "";
const received = Buffer.from(given);
const wanted = Buffer.from(expected);
return received.length === wanted.length && timingSafeEqual(received, wanted);
}The body is { "event_id", "event", "occurred_at", "workspace_id", "capture_id", "group_id", "actor": {…}, "data": {…} } — the same objects the API returns. Deduplicate using event_id; delivery is at least once. Respond with any 2xx within 5 seconds; otherwise we retry with backoff for about a day and show every attempt under the webhook in Settings, where you can redeliver by hand. Destinations must be public https URLs.