All docs

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

name

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 name

401

Invalid or missing API key


Events

Push Event

POST /api/events

Body:

Field

Type

Required

Description

project

string

yes

Project ID

category

string

yes

Category name, max 100 chars (auto-created if new)

title

string

yes

Event title

description

string

no

Supports **bold** and [link](url)

icon

string

no

Emoji icon, max 16 characters

tags

object

no

Flat key-value labels, stored as jsonb, shown as badges on the event

metadata

object | array

no

Arbitrary structured context, stored as jsonb, opened in a dialog from the feed. Max 64 KB serialized

url

string

no

Link the event title opens

user_id

string

no

External user identifier from your app

notify

boolean

no

Highlight in the feed and fan out to ntfy (default false)

created_at

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 project, category, or title; over-length category/icon; unusable created_at

401

Invalid or missing API key

404

Project not found in this organization

Query Events

GET /api/events

Query parameters:

Param

Type

Required

Description

project

string

yes

Project ID

category

string

no

Filter by category

search

string

no

Case-insensitive match on title, description, and tags. Not metadata

cursor

string

no

Opaque cursor from the previous page

limit

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

project

string

yes

Project ID

title

string

yes

Insight name — the unique key within the project

value

string/number

yes

Display value (stored as text)

icon

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 project, title, or value; over-length icon

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

project

string

yes

Project ID

days

integer

no

Look-back window, 1–36500 (default 30)

category

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 project, or days outside 1–36500

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

projects

array

yes

Projects to create. Each needs name; categories, events, insights are optional arrays

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 idMap so 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 400 naming 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

projects is not an array, or a named row is invalid

401

Invalid or missing API key