API Getting Started
Go from zero to a successful POST /api/v1/inbox in under five minutes — token, request, errors, and Sync.
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
- Sign in to https://shrubbery.eu.
- Open Settings → API Tokens.
- 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. - 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/inboxand/api/v1/syncfor 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.
| Status | error | When |
|---|---|---|
| 400 | invalid_payload | Invalid JSON, or Zod rejected the body. details carries the flattened error map. |
| 400 | user_not_found | assignerEmail or assigneeEmail did not resolve to a Shrubbery user. |
| 401 | unauthorized | Missing or malformed Authorization: Bearer header. |
| 403 | forbidden | Resolved assignerEmail does not match the token owner. |
| 409 | duplicate_external_ref | (externalSystem, externalId, assigner) already mapped to a Shrubbery. |
| 500 | insert_failed | Database insert failed; message carries the Postgres error. |
Programmatic clients should:
- Treat
detailsas opaque debugging context. Do not parse field paths out of it. - Retry only on
5xx.4xxis a permanent failure for the same payload — fix the payload first. - Honour
Retry-Afteron429from/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/syncto flip the row toActive,Completed, orRefused.
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/inboxand/api/v1/sync— not rate-limited today. Per-token throttling is logged as a backlog item ("Rate limiting on/loginmagic-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_limitedwith aRetry-Afterheader./api/gatheris not part of the webhook surface anyway; programmatic clients should call/api/v1/inboxinstead.
Reference & conventions
Base URL
| Environment | URL |
|---|---|
| Production | https://shrubbery.eu |
| Local dev | http://localhost:3000 |
HTTPS is required in production; plain HTTP is accepted only on localhost.
Audit
Every state-changing API call appends a row to shrubbery_events with actor_kind = api_token and the token ID. The full audit trail is visible per Shrubbery on its Detail Sheet.
Downloading the spec
The interactive reference at /api-reference exposes a Download OpenAPI Document menu (top-right) that exports the spec as JSON or YAML:
- Postman — File → Import → Upload Files → select the JSON. Postman builds a collection mirroring the operations; add environment variables (
SHRUBBERY_TOKEN, …) manually. - Insomnia — Create → Import From → File → select the JSON.
- Typed client — feed the spec to
openapi-typescript,openapi-generator, or similar. Every public field round-trips because the spec is auto-emitted from the runtime Zod schemas.
The raw spec is also tracked at content/docs/_generated/openapi.json and regenerated on every PR via npm run docs:check.
Versioning
The current API is version 1.0.0 (info.version in the spec). Every change today is additive and backwards-compatible. When breaking changes ship, v2 will mount at /api-reference/v2 and /api-reference stays pinned to v1.
Interactive try-it (BYOK cost caveat)
/api-reference embeds Scalar's Test Request client. Firing inbox or sync there is free — just a database write. But firing POST /api/gather (#tag/capture/post-api-gather) sends a real request through your active session, which consumes a real BYOK token call against your nominated provider (OpenAI / Anthropic) — the same cost as a Smart Paste from inside the app. Check your key before firing it.
Where to go next
- POST /api/v1/inbox — full reference with embedded Scalar surface.
- POST /api/v1/sync — same.
- Inbox Webhook — narrative walk of the Inbox flow (capture → Handshake → state).
- Webhooks Overview — payload-shape reference for the receiver-side perspective.
Last updated: 17 May 2026