API Reference
Every endpoint, its payload, and the key that authenticates it.
Base URL: https://app.getbutters.com.
All responses are JSON with Content-Type: application/json. Errors are { "error": "..." } with the status below.
The wire format is unchanged from upstream Events Logger, so existing integrations keep working. What changed is the tenancy model: this is a multi-tenant server, so every endpoint authenticates, including the reads that were open upstream.
Authentication
Every endpoint requires an API key on the Authorization header:
Authorization: Bearer ev_a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6 Keys are created per organization from the API page (/app/api ā New key). The format is ev_ followed by five lowercase-alphanumeric segments of 8-4-4-4-12.
The plaintext is shown exactly once, at creation. The database stores only sha256(key) plus the first 12 characters for display, so a lost key is revoked and replaced, never recovered. A revoked key (revoked_at set) fails authentication like an unknown one. Each successful call stamps last_used_at.
A key resolves to an organization, and that organization scopes everything the request can see. A project belonging to another tenant returns 404, never 403 ā a 403 would confirm the id exists.
| Status | Meaning |
|---|---|
| 401 | Missing, malformed, unknown, or revoked key |
| 404 | Project/event/insight does not exist in your organization |
Projects
List Projects
GET /api/projects Returns every project in the caller's organization.
Response 200:
[
{
"id": "q4q8nb18qc2i",
"organizationId": "org_2f8cā¦",
"name": "quickshop",
"createdAt": "2026-09-10T08:00:00.000Z"
}
] Create Project
POST /api/projects Body:
| Field | Type | Required | Description |
|---|---|---|---|
|
| string | yes | Project name (trimmed; must be non-empty) |
Response 201:
{ "id": "q4q8nb18qc2i", "name": "my-store" } Ids are 12 characters of [a-z0-9], minted server-side.
Errors:
| Status | Meaning |
|---|---|
| 400 | Missing |
| 401 | Invalid or missing API key |
Events
Push Event
POST /api/events Body:
| Field | Type | Required | Description |
|---|---|---|---|
|
| string | yes | Project ID |
|
| string | yes | Category name, max 100 chars (auto-created if new) |
|
| string | yes | Event title |
|
| string | no | Supports |
|
| string | no | Emoji icon, max 16 characters |
|
| object | no | Flat key-value labels, stored as |
|
| object | array | no | Arbitrary structured context, stored as |
|
| string | no | Link the event title opens |
|
| string | no | External user identifier from your app |
|
| boolean | no | Highlight in the feed and fan out to ntfy (default |
|
| number | no | Unix seconds; defaults to now |
Character limits are counted the way Postgres counts them, so a multi-code-unit emoji costs one character, not two.
Request:
{
"project": "q4q8nb18qc2i",
"category": "orders",
"title": "Order Placed",
"description": "Order **#1234** placed by **Maria**",
"icon": "šļø",
"url": "https://shop.example.com/admin/orders/1234",
"user_id": "user-456",
"tags": { "email": "maria@example.com", "amount": "$89.99" },
"metadata": {
"cart": { "items": 3, "coupon": "SPRING" },
"request": { "ip": "203.0.113.4", "ua": "Mozilla/5.0" }
}
} Response 201:
{
"id": 1,
"projectId": "q4q8nb18qc2i",
"category": "orders",
"title": "Order Placed",
"description": "Order **#1234** placed by **Maria**",
"icon": "šļø",
"tags": { "email": "maria@example.com", "amount": "$89.99" },
"metadata": {
"cart": { "items": 3, "coupon": "SPRING" },
"request": { "ip": "203.0.113.4", "ua": "Mozilla/5.0" }
},
"url": "https://shop.example.com/admin/orders/1234",
"user_id": "user-456",
"notify": false,
"favorited": false,
"createdAt": "2026-09-10T08:12:03.000Z"
} tags comes back as an object, not the JSON string upstream returned ā the column is jsonb now.
tags vs metadata
Both are jsonb, and they are not interchangeable.
tags is a flat map of short labels. Every pair renders as a badge on the event, values are stringified to get there, and a nested value renders as [object Object]. It is part of the search index.
metadata is arbitrary structured context ā a stack trace, a request envelope, a webhook payload. Nesting is preserved, nothing is rendered inline, and the event instead grows a button that opens the blob in a dialog. This is the field to use for error reporting.
metadata must be an object or an array; a bare string or number is a 400. It must serialize to at most 65536 bytes (64 KB), and a larger one is a 400 ā the feed reads events in pages of 50 and re-reads them on every live update, so uncapped blobs would be paid for by every reader.
metadata is deliberately not searched. search matches tags::text, and putting stack traces in the search index would make common words match nearly every event that carries one.
A successful write announces itself on a Postgres channel, so any dashboard feed open on that project refreshes without waiting for its poll. Fan-out is best-effort and never fails the write.
Errors:
| Status | Meaning |
|---|---|
| 400 | Missing |
| 401 | Invalid or missing API key |
| 404 | Project not found in this organization |
Query Events
GET /api/events Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
|
| string | yes | Project ID |
|
| string | no | Filter by category |
|
| string | no | Case-insensitive match on title, description, and tags. Not metadata |
|
| string | no | Opaque cursor from the previous page |
|
| integer | no | Clamped to 1ā100 (default 50) |
Response 200:
{
"events": [ ... ],
"nextCursor": "2026-09-10T08:12:03.041233_87"
} nextCursor is null on the last page.
The cursor is keyset, not an id: <created_at>_<id>, matching the feed's ORDER BY created_at DESC, id DESC. Because created_at can be supplied by the caller, insertion order and event time disagree in general, and paginating on id alone would both skip and repeat rows. The timestamp is rendered by Postgres at microsecond precision ā a JS Date would truncate it and silently drop rows. Treat the value as opaque; an unreadable cursor is ignored and yields the first page.
search matches tags::text, so it matches against the JSON serialization including its punctuation. It does not match metadata ā see tags vs metadata .
Delete Event
POST /api/events/:eventId/delete Response 200: { "id": 42, "deleted": true }
Errors:
| Status | Meaning |
|---|---|
| 400 | Event id is not a positive int4 |
| 401 | Invalid or missing API key |
| 404 | Event not found in this organization |
Toggle Event Favorite
POST /api/events/:eventId/favorite Flips the favorited flag and returns the new value.
Response 200: { "id": 42, "favorited": true }
Errors:
| Status | Meaning |
|---|---|
| 400 | Event id is not a positive int4 |
| 401 | Invalid or missing API key |
| 404 | Event not found in this organization |
Insights
Upsert Insight
POST /api/insight Creates an insight card, or updates the value of an existing one matched on (project, title).
Body:
| Field | Type | Required | Description |
|---|---|---|---|
|
| string | yes | Project ID |
|
| string | yes | Insight name ā the unique key within the project |
|
| string/number | yes | Display value (stored as text) |
|
| string | no | Emoji icon, max 16 characters |
value is checked for undefined/null, not falsiness: 0 and "" are valid values. Omitting icon on an update leaves the existing icon alone.
Response 200: { "ok": true }
Errors:
| Status | Meaning |
|---|---|
| 400 | Missing |
| 401 | Invalid or missing API key |
| 404 | Project not found in this organization |
Delete Insight
POST /api/insight/:insightId/delete Response 200: { "id": 7, "deleted": true }
Upstream answered this with a 303 to an HTMX partial. Here it is a plain JSON endpoint; the dashboard's own delete button goes through /app/events/actions?action=delete-insight instead.
Errors:
| Status | Meaning |
|---|---|
| 400 | Insight id is not a positive int4 |
| 401 | Invalid or missing API key |
| 404 | Insight not found in this organization |
Charts
Get Chart Data
GET /api/charts Event counts grouped by category and day.
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
|
| string | yes | Project ID |
|
| integer | no | Look-back window, 1ā36500 (default 30) |
|
| string | no | Restrict to one category |
Days are bucketed with Postgres date_trunc('day', ā¦) in the server's time zone.
Response 200:
{
"orders": [
{ "day": "2026-09-08", "count": 5 },
{ "day": "2026-09-09", "count": 8 }
],
"signups": [
{ "day": "2026-09-09", "count": 2 }
]
} Errors:
| Status | Meaning |
|---|---|
| 400 | Missing |
| 401 | Invalid or missing API key |
| 404 | Project not found in this organization |
Export / Import
These two routes exist because the CLI is a remote HTTP client and has no database access. They are the transport for backups and for the demo scenarios.
Export
GET /api/export Every project in the caller's organization, with its categories, events, and insights.
Response 200:
{
"version": 1,
"projects": [
{
"id": "q4q8nb18qc2i",
"name": "QuickShop",
"categories": ["orders", "signups"],
"events": [
{
"category": "orders",
"title": "Order Placed",
"description": null,
"icon": "šļø",
"tags": { "amount": "$89.99" },
"metadata": null,
"url": null,
"user_id": null,
"notify": false,
"favorited": false,
"created_at": 1757491923
}
],
"insights": [{ "title": "Revenue (30d)", "value": "$48,210", "icon": "š°" }]
}
]
} created_at is unix seconds here, which is what import reads back.
Import
POST /api/import Takes an export document ā the same shape GET /api/export emits, and the shape demos/*.json are generated in.
Body:
| Field | Type | Required | Description |
|---|---|---|---|
|
| array | yes | Projects to create. Each needs |
Response 200:
{
"imported": { "projects": 1, "events": 530, "insights": 4 },
"idMap": { "quickshop": "q4q8nb18qc2i" }
} Three things worth knowing:
- Ids in the file are labels. Import always mints fresh project ids and returns
idMapso you can find what became what. Loading the same file twice gives two independent projects rather than a collision or an overwrite. - Nothing is written until everything validates. Every row is checked first, so a bad row 4,000 pages in is a
400naming it, not a half-imported project. - The whole document is one transaction, and inserts are chunked at 1,000 rows (Postgres binds at most 65535 parameters per statement, and an event costs 11).
Counts come from the rows Postgres returned, not from what the file offered.
Errors:
| Status | Meaning |
|---|---|
| 400 |
|
| 401 | Invalid or missing API key |