orbotodocs
API & CLI

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 want

then 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 list

The 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

SymptomFix
401 on every callCheck 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 projectPermissions 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 415413 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: 0The 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 logYour 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 failsCompare against the raw request body, not a re-stringified copy of the parsed JSON - whitespace and key order differences change the HMAC.

On this page