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 Agents or your account page act as you. Scope them read-only or to specific groups when you create them.
  • 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.

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. resolve takes { resolution }; assign takes { assignee_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 /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": "…" } } with a fitting status; 409 carries a conflict body with the server's current field versions when you sent a stale base_version. Write requests accept an Idempotency-Key header and replay the original response for 24 hours.

Uploading a screenshot

# 1. hash the bytes
sha=$(shasum -a 256 shot.png | cut -d' ' -f1)
# 2. ask for an upload slot (returns a presigned PUT, or "exists": true)
curl -X POST https://api.peekr.dev/v1/uploads -H "Authorization: Bearer $TOKEN" \
  -d '{"workspace_id":"…","sha256":"'$sha'","bytes":123456,"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" \
  -d '{"workspace_id":"…","sha256":"'$sha'"}'
# 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 (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5-minute window
  const expected = "sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  const given = headers["x-peekr-signature"] ?? "";
  return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

The body is { "event", "occurred_at", "workspace": {…}, "capture": {…}, "actor": {…}, … } — the same objects the API returns. 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.