API cookbook
Common API flows end to end - create, query, comment, attach, bulk.
All examples use an API key: Authorization: Bearer orb_.... The
interactive OpenAPI reference at https://<your-instance>/docs documents
every route and schema.
Create a ticket
The project is a path parameter, not a body field - title is the only
required field in the body, everything else is optional:
curl -X POST https://acme.example.com/api/projects/<projectId>/tickets \
-H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \
-d '{"title":"fix: flux capacitor drifts","type":"bug","priority":"high"}'The response is the full ticket object; a trimmed example of what comes back:
{
"id": "3fa0c2e1-...",
"projectId": "b1a9...",
"ticketKey": "ACME-51",
"ticketNumber": 51,
"title": "fix: flux capacitor drifts",
"type": "bug",
"priority": "high",
"status": "TODO",
"statusCategory": "todo",
"statusName": "To Do",
"createdAt": "2026-08-30T09:12:04.000Z",
"similarWarnings": []
}similarWarnings is a non-empty array (not an error) when the new
ticket's title/description closely matches an existing one - the create
still succeeds, but you get the near-duplicate's key and similarity
score to review, the same signal the CLI's create-ticket surfaces as a
warning.
Don't have the project's UUID? Resolve it once from the key and
reuse it - ticket reads also accept the key form directly within a
project (GET /projects/<projectId>/tickets/by-key/ACME-42), so you
typically only need the project UUID, not every ticket's UUID:
curl "https://acme.example.com/api/projects/by-key/ACME" -H "Authorization: Bearer $ORBOTO_TOKEN"
# → { "id": "<project-uuid>", "key": "ACME", "name": "...", ... }Query with OQL
curl -X POST https://acme.example.com/api/query \
-H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \
-d '{"oql":"project = ACME AND status = TODO AND priority = high","limit":25}'Response shape:
{
"items": [ { "ticketKey": "ACME-51", "title": "...", "..." : "..." } ],
"nextCursor": null
}nextCursor is null when you've reached the end; otherwise pass it
back as "cursor": "<value>" in the next request body to get the
following page. See OQL for the full grammar and
field list - status (legacy enum, uppercase values like TODO) and
priority (lowercase values like high) intentionally use different
casing conventions, so copy the exact case from the
field reference rather than
guessing.
Comment and close
Post a comment:
curl -X POST https://acme.example.com/api/tickets/<id>/comments \
-H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \
-d '{"content":"Fixed and verified."}'Moving status is a two-step lookup-then-patch, because status ids are per-project (a project can rename or reorder its workflow statuses), so you resolve the target status id by name first:
curl "https://acme.example.com/api/projects/<projectId>/ticket-statuses" \
-H "Authorization: Bearer $ORBOTO_TOKEN"
# → [{ "id": "...", "name": "Done", "category": "done" }, ...] - find the row you wantthen PATCH the ticket with that id:
curl -X PATCH https://acme.example.com/api/projects/<projectId>/tickets/<id> \
-H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \
-d '{"statusId":"<the-status-id-from-above>"}'The workflow's configured transitions apply exactly as in the UI - a
400 comes back if the target status isn't reachable from the ticket's
current one, the same guardrail that stops a drag-and-drop move to an
invalid column on the board.
Walk a paginated list
Every list endpoint uses the same cursor shape - request a page, read
nextCursor, and keep going until it's null:
curl "https://acme.example.com/api/projects?limit=50" -H "Authorization: Bearer $ORBOTO_TOKEN"
# → { "items": [...50 rows...], "nextCursor": "eyJrIjoi..." }
curl "https://acme.example.com/api/projects?limit=50&cursor=eyJrIjoi..." -H "Authorization: Bearer $ORBOTO_TOKEN"
# → { "items": [...next 50 rows...], "nextCursor": null } ← end of the listThe cursor is an opaque, signed token - never construct one by hand or
try to decode it for meaning; always pass back exactly what the previous
response gave you. A page always includes nextCursor, even on the very
last page (as null), so "keep paging while nextCursor is truthy" is
a correct loop condition without a separate "am I done" check.
Upload an attachment
Attachments are multipart uploads to the ticket's attachment route:
curl -X POST https://acme.example.com/api/tickets/<id>/attachments \
-H "Authorization: Bearer $ORBOTO_TOKEN" \
-F "file=@screenshot.png"The response returns the stored file's metadata - id, filename,
contentType, sizeBytes, and a downloadUrl. Store the attachment
id, not the download URL - download links are short-lived; re-fetch
GET /attachments/<id> (or .../base64 for inline bytes) whenever you
need the file again.
Bulk update tickets
Apply one field change to many tickets in a single call instead of looping over individual updates:
curl -X POST https://acme.example.com/api/projects/<projectId>/tickets/bulk \
-H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \
-d '{"ids":["<ticket-id-1>","<ticket-id-2>"],"action":"status","value":"<statusId>"}'action is one of status, milestone, assignee, due_date,
version, priority; value is the new value for that field (a status
id, milestone id, user id, ISO date, version id, or priority string -
null clears an optional field, e.g. unassigning). The response is
{"updated": N} - the count of tickets actually changed. This needs
project:edit on the target project, same as any other ticket mutation.
For heterogeneous per-ticket changes (different fields per ticket), loop
individual PATCH /tickets/<id> calls instead - the bulk endpoint
applies one field/value pair across the whole id list.
Register and consume a webhook
Webhooks deliver workspace events (ticket created, comment posted, and more) to a URL you control, HMAC-signed so you can verify each delivery.
1. Register the webhook on a project:
curl -X POST https://acme.example.com/api/projects/<projectId>/webhooks \
-H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "ticket-sync",
"url": "https://your-service.example.com/hooks/orboto",
"events": ["ticket.created", "ticket.updated", "comment.created"]
}'This needs project:edit on the target project. The response includes
secret - a signing secret shown once, at creation. Store it
alongside the webhook id; there's no route to retrieve it again later
(only rotate it, which invalidates the old one).
2. Verify each delivery. Every request carries X-Orboto-Event
(the event name) and X-Orboto-Signature: sha256=<hex-hmac>, computed
as an HMAC-SHA256 of the raw request body using your webhook's secret.
Verify before trusting the payload:
const crypto = require("crypto");
function isValidDelivery(secret, rawBody, signatureHeader) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader),
);
}Use the raw, unparsed request body for the HMAC input - re-serializing parsed JSON can change byte-for-byte formatting and break the comparison.
3. Inspect available events. GET /admin/webhooks/events returns
every event name, a description, and an example payload - useful for
building the consumer without guessing the shape.
4. Debug and replay deliveries. Every attempt (success or failure) is logged:
curl https://acme.example.com/api/webhooks/<webhookId>/deliveries \
-H "Authorization: Bearer $ORBOTO_TOKEN"
curl -X POST https://acme.example.com/api/webhooks/<webhookId>/redeliver/<deliveryId> \
-H "Authorization: Bearer $ORBOTO_TOKEN"Redelivery re-sends the exact original payload - handy after fixing a consumer bug without waiting for the event to happen again.
Setting payloadFormat to slack, microsoft_teams, or discord
instead of the default generic reshapes the body for that chat tool's
incoming-webhook format; vendor-shaped deliveries skip the
X-Orboto-Signature / X-Orboto-Event headers since those tools don't
read them.
Errors
Errors carry a stable errorKey (machine-readable) alongside a
human-readable error message - branch on the key, display the message.
Troubleshooting
| Symptom | Fix |
|---|---|
401 on every call | Check Authorization: Bearer <key> is present and the key hasn't been revoked or expired - mint a fresh one from your profile. |
403 on a route that worked for another project | Permissions are per-project. Confirm the API key's owner is actually a member of this project with the required permission, not just any project. |
Attachment upload returns 413 or 415 | 413 is over the size limit; 415 is a content type the instance doesn't accept. Check your instance's configured limits. |
Bulk update returns updated: 0 | The ids you sent didn't resolve to tickets you can edit in this project - a bulk call is silently a no-op on ids it can't touch, it never partially fails the request, only the effect. |
| Webhook deliveries show as failing in the delivery log | Your endpoint must return a 2xx within the delivery timeout. Check the logged response status/body per delivery, fix the consumer, then use redeliver to replay the specific failed event instead of waiting for it to recur. |
| Signature verification always fails | Compare against the raw request body, not a re-stringified copy of the parsed JSON - whitespace and key order differences change the HMAC. |