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

RouteNotes
GET /v1/meYou, 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/:idOne capture with images (short-lived signed URLs), comments and activity.
POST /v1/capturesCreate. Body: workspace_id, group_id?, note, status?, priority?, tags?, images? (sha256 from the upload flow), source? { app, window, url }.
PATCH /v1/captures/:idEdit fields. Send base_version to get field-level merge instead of last-write-wins.
POST /v1/captures/:id/claim | assign | resolve | reopen | confirm-resolution | restoreWorkflow actions. claim returns a claim receipt; resolve takes { reference, claim_token }; assign takes { assignee_id: id | null }.
POST /v1/captures/:id/commentsComment. @mentions by user id in the body's mentions array.
POST /v1/captures/:id/imagesAttach an uploaded image (by sha256) to a capture.
POST /v1/uploads → POST /v1/uploads/completeAsk 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/:idGroups, membership under /v1/groups/:id/members.
GET | PATCH /v1/workspaces/:idRead 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/invitesList 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/:idRevoke a pending invitation. Existing memberships are unchanged.
PATCH /v1/me/profile; GET | PATCH /v1/notifications/prefsYour 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/reconcileWhat 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:

HeaderValue
X-Peekr-EventThe event name
X-Peekr-DeliveryUnique id; the same id is reused on retries
X-Peekr-TimestampUnix seconds when this attempt was signed
X-Peekr-Signaturesha256= + 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.