ShrubberyDocs
Sign in

API Getting Started

A walk from zero to a successful POST /api/v1/inbox in under five minutes. Covers token minting, request shape, error handling, and where the rest of the API surface lives.

Prerequisites

  • A Shrubbery account at https://shrubbery.eu (sign up via magic link).
  • An external system (Jira, Linear, GitHub, custom) whose commitments you want to ingest as Shrubberies. The integration is not interactive — you mint a token in Shrubbery and call the endpoints from your own backend.

Step 1 — Mint an API Token

  1. Sign in to https://shrubbery.eu.
  2. Open Settings → API Tokens.
  3. Click Mint token. A new row appears with a one-time plaintext value of the form prefix.opaqueRest. Copy it now — Shrubbery hashes the plaintext immediately and only the prefix + SHA-256 hash persist server-side.
  4. Store the plaintext in your integration's secret manager. Treat it like a database password.

Behaviour properties worth knowing:

  • Token = user. A token can only mint or update rows where the token owner is the assigner. There is no organisation-wide or delegate token in v1.
  • No expiry. Tokens live forever until you revoke them. Revoke from the same Settings page; revocation is immediate (the bearer check fails on the next request).
  • No scopes. Every active token can hit /api/v1/inbox and /api/v1/sync for its owner. Per-route scopes (inbox-only, read-only, etc.) are tracked in backlog.md under "Deferred Ideas — Backend / Infrastructure".

Step 2 — Send your first request

The hello-world call is an inbox ingest. The token owner (you) becomes the assigner; the assignee email must already exist in Shrubbery.

export SHRUBBERY_TOKEN='paste-the-plaintext-here'
 
curl -X POST https://shrubbery.eu/api/v1/inbox \
  -H "Authorization: Bearer $SHRUBBERY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "assignerEmail": "you@example.com",
    "assigneeEmail": "teammate@example.com",
    "description": "Ship the Q3 report",
    "deadline": "2026-09-30T17:00:00.000Z",
    "externalSystem": "jira",
    "externalId": "PROJ-123"
  }'

A success looks like:

HTTP/1.1 201 Created
Content-Type: application/json
 
{ "id": "9c9ab2d7-0e7e-4cf8-86df-7d6b9bfab2d1" }

The same call in TypeScript / Node.js:

const res = await fetch('https://shrubbery.eu/api/v1/inbox', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SHRUBBERY_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    assignerEmail: 'you@example.com',
    assigneeEmail: 'teammate@example.com',
    description: 'Ship the Q3 report',
    deadline: '2026-09-30T17:00:00.000Z',
    externalSystem: 'jira',
    externalId: 'PROJ-123',
  }),
});
 
if (!res.ok) {
  const body = await res.json();
  throw new Error(`Inbox ingest failed: ${res.status} ${body.error}`);
}
 
const { id } = await res.json();

The newly-created Shrubbery appears in the assignee's Accord immediately. Inngest fires a Nudge through the assignee's preferred channel (email by default).

Step 3 — Handle errors

Every failure returns a JSON body with a stable error discriminator plus an optional human-readable message. The HTTP status is the load-bearing signal; error lets you branch programmatically.

StatuserrorWhen
400invalid_payloadInvalid JSON, or Zod rejected the body. details carries the flattened error map.
400user_not_foundassignerEmail or assigneeEmail did not resolve to a Shrubbery user.
401unauthorizedMissing or malformed Authorization: Bearer header.
403forbiddenResolved assignerEmail does not match the token owner.
409duplicate_external_ref(externalSystem, externalId, assigner) already mapped to a Shrubbery.
500insert_failedDatabase insert failed; message carries the Postgres error.

Programmatic clients should:

  • Treat details as opaque debugging context. Do not parse field paths out of it.
  • Retry only on 5xx. 4xx is a permanent failure for the same payload — fix the payload first.
  • Honour Retry-After on 429 from /api/gather (not applicable to webhook routes today; see Rate limits below).

Step 4 — Drive state changes via Sync

Once an inbox row is Pending_Handshake, status transitions happen one of two ways:

  • A user (the Knight) Handshakes in the Accord UI.
  • The external system POSTs to /api/v1/sync to flip the row to Active, Completed, or Refused.

The sync payload is keyed on the same (externalSystem, externalId) pair you used at ingest, scoped silently to the token owner:

curl -X POST https://shrubbery.eu/api/v1/sync \
  -H "Authorization: Bearer $SHRUBBERY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "externalSystem": "jira",
    "externalId": "PROJ-123",
    "newStatus": "Completed"
  }'

A 404 means the (externalSystem, externalId) pair was never minted by this token. The error message intentionally does not leak whether the pair exists under a different owner.

Rate limits

  • /api/v1/inbox and /api/v1/sync — not rate-limited today. Per-token throttling is logged as a backlog item ("Rate limiting on /login magic-link request and /api/gather" 2026-05-07 entry covers the broader work). Until that lands, keep your call volume reasonable; abuse will get the token revoked.
  • /api/gather — per-user limits via Upstash Ratelimit. On exceed, 429 rate_limited with a Retry-After header. /api/gather is not part of the webhook surface anyway; programmatic clients should call /api/v1/inbox instead.

Where to go next

Last updated: 17 May 2026