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
| 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. resolve takes { resolution }; assign takes { assignee_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 /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": "…" } } 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:
| 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 (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.