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
- 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.
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.
- API Reference Overview — auth schemes, conventions, error model in one place.
Last updated: 17 May 2026