p.enthalabs

GitHub - chrisgreg/boop: A tiny, self-hosted notification inbox for developers. Something happened in one of your apps; Boop tells you on your phone.

![Image 1: Boop logo](https://github.com/chrisgreg/boop/raw/main/docs/boop.png)

![Image 2: CI](https://camo.githubusercontent.com/bb275a868698ea2539d7ca4c32d6a230dc34396485d9a6f2869042cb7b30f3b0/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6368726973677265672f626f6f702f63692e796d6c3f6272616e63683d6d61696e)![Image 3: Go version](https://camo.githubusercontent.com/ac94b3072fae2e5e3345d857f377edc6b9c1b7aa149c966e1949c8d9f8489707/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f676f2d6d6f642f676f2d76657273696f6e2f6368726973677265672f626f6f703f66696c656e616d653d736572766572253246676f2e6d6f64)![Image 4: License](https://camo.githubusercontent.com/ea9aa62d3dfc72d8465f0b332c649b480973e8c2199c2d81b3704063f109ab6a/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f6368726973677265672f626f6f70)

A tiny, self-hosted notification inbox for developers. Something happened in one of your apps; Boop tells you on your phone.

One Go binary, one SQLite file, one Docker container. Pushes go straight from your server to Apple's APNs. There is no hosted relay, account system, or telemetry.

curl https://boop.example.com/api/v1/events \ -H "Authorization: Bearer $BOOP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Backup complete", "level": "success"}'

Architecture

[](https://github.com/chrisgreg/boop#architecture)

[![Image 5: How Boop works: apps POST events to the Go server, which stores them in SQLite and pushes to APNs; the iOS app fetches full detail from the server; the web UI manages projects and shows the pairing QR.](https://github.com/chrisgreg/boop/raw/main/docs/architecture.png)](https://github.com/chrisgreg/boop/blob/main/docs/architecture.png)

Your apps POST events with a project API key. The Go server redacts and stores them in SQLite, then pushes straight to Apple's APNs using your `.p8` key. The push carries only the title, body and event id; the iOS app fetches the full event from your server with its own device credential. The embedded web UI manages projects and devices and shows the pairing QR the phone scans. An interactive version lives in `docs/architecture/index.html` (open it locally; the source is `boop.architecture.json`).

What is in the box

[](https://github.com/chrisgreg/boop#what-is-in-the-box) | Part | Where | | --- | --- | | Go server (API, SQLite, APNs, embedded web UI) | `server/` | | Web UI (Svelte, built into the binary) | `server/web/` | | iOS app (SwiftUI, iOS 26, you build and sign it) | `ios/` — see ios/README.md | | Client libraries | separate repos — see Integrations | | Native desktop client | planned |

Quick start (Docker)

[](https://github.com/chrisgreg/boop#quick-start-docker)

git clone https://github.com/chrisgreg/boop && cd boop cp .env.example .env # optional: BOOP_BASE_URL and APNS_* values mkdir -p data && chown 1000:1000 data # Linux hosts only; the container runs as uid 1000 docker compose up -d --build open http://localhost:8080

The first visit opens a setup wizard: server check, APNs, pairing, first project, test notification. APNs credentials are optional; without them events are stored and shown in the UI but pushes are skipped, and the settings page says so.

Data lives in `./data/boop.db`. Back up by copying that file (use `sqlite3 data/boop.db ".backup backup.db"` for a consistent copy while running). Back up your `.p8` key separately.

Send an event

[](https://github.com/chrisgreg/boop#send-an-event) Create a project in the web UI and copy its API key (shown once). Then:

minimum

curl http://localhost:8080/api/v1/events \ -H "Authorization: Bearer boop_proj_..." -H "Content-Type: application/json" \ -d '{"title": "Deploy complete"}'

rich

curl http://localhost:8080/api/v1/events \ -H "Authorization: Bearer boop_proj_..." -H "Content-Type: application/json" \ -d '{ "title": "KeyError", "body": "key :can_palette? not found", "level": "error", "source": "error_tracker", "fingerprint": "uini-keyerror", "data": { "exception": {"type": "KeyError", "message": "key :can_palette? not found"}, "stacktrace": [{"file": "lib/uini_web/live/widget_settings_live.ex", "line": 49, "function": "handle_event/3", "in_app": true}], "tags": {"environment": "production"}, "context": {"user_id": "123"} } }'

Levels: `info`, `success`, `warning`, `error`, `critical`. Anything in `data` is kept as-is (recognised sections such as `exception`, `stacktrace`, `tags`, `context` and `breadcrumbs` get a nicer rendering) after sensitive keys are redacted.

From a shell script:

boop() { curl -fsS "$BOOP_URL/api/v1/events" -H "Authorization: Bearer $BOOP_API_KEY" \ -H "Content-Type: application/json" -d "{\"title\": \"$1\", \"body\": \"${2:-}\", \"level\": \"${3:-info}\"}"; } pg_dump mydb > backup.sql && boop "Backup complete" "" success || boop "Backup failed" "$(tail -1 backup.log)" error

From GitHub Actions:

- name: Boop

if: always() run: | curl -fsS "${{ secrets.BOOP_URL }}/api/v1/events" \ -H "Authorization: Bearer ${{ secrets.BOOP_API_KEY }}" \ -H "Content-Type: application/json" \ -d '{"title": "${{ github.workflow }} ${{ job.status }}", "body": "${{ github.repository }}@${{ github.ref_name }}", "level": "${{ job.status == 'success' && 'success' || 'error' }}", "source": "github_actions"}'

Integrations

[](https://github.com/chrisgreg/boop#integrations)

Official clients live in their own repos. They all speak the same one endpoint (`POST /api/v1/events`), redact sensitive keys before sending, truncate rather than reject, retry only network errors and 5xx, and never crash the host application.

Elixir — `boop_ex`

[](https://github.com/chrisgreg/boop#elixir--boop_ex)

{:boop_ex, "~> 1.0"}

config :boop_ex, url: System.fetch_env!("BOOP_URL"), api_key: System.fetch_env!("BOOP_API_KEY"), source: "my_app"

Boop.send("Deploy complete") Boop.send(title: "Payment received", body: "£19.99", level: :success, data: %{customer_id: id}) Boop.send_async(title: "Cron finished") # :ok immediately, never raises Boop.Event.exception(e, __STACKTRACE__ , tags: %{env: "prod"}) # rich error data

`send/2` returns `{:ok, %{id, created_at}}` or `{:error, %Boop.Error{code: …}}`; `send_async/2` runs on a supervised task. Ships a `usage-rules.md` for AI agents.

Elixir + ErrorTracker — `boop_error_tracker`

[](https://github.com/chrisgreg/boop#elixir--errortracker--boop_error_tracker)

{:error_tracker, "~> 0.9"}, {:boop_ex, "~> 1.0"}, {:boop_error_tracker, "~> 1.0"}

config :boop_error_tracker, environment: config_env(), source: "my_app"

Attaches to ErrorTracker's telemetry events and pushes new errors and resolved-errors-that-came-back to your phone, with the exception, stacktrace (your frames highlighted), context and breadcrumbs. Optional per-occurrence pushes with per-error throttling; muted errors are never sent. It installs _next to_ ErrorTracker and never touches its database or config.

Node.js — `@boop/node`

[](https://github.com/chrisgreg/boop#nodejs--boopnode)

pnpm add @boop/node

import boop, { Boop } from '@boop/node' // default client reads BOOP_URL / BOOP_API_KEY

await boop.send('Deploy complete') const client = new Boop({ url, apiKey, source: 'my_app' }) const result = await client.send({ title: 'Payment received', level: 'success', data: { customerId } }) if (!result.ok) console.warn(result.error.code) client.sendAsync({ title: 'Cron finished' }) // fire and forget, never throws client.exception(err, { tags: { env: 'prod' } }) // rich error data

TypeScript, ESM + CJS, zero runtime dependencies, Node 18+.

Anything else

[](https://github.com/chrisgreg/boop#anything-else)

`curl` is a first-class client (see Send an event), and `integration-llms.md` is a prompt you can hand to an LLM to generate a client for any other language that behaves like the ones above.

API

[](https://github.com/chrisgreg/boop#api) All endpoints are under `/api/v1`. Errors are JSON: `{"error": "code", "message": "..."}`.

| Method | Path | Auth | Purpose | | --- | --- | --- | --- | | GET | `/health` | none | `{"status":"ok"}` | | POST | `/api/v1/events` | project key | Create event, returns `{id, created_at}` | | GET | `/api/v1/events?project=&level=&source=&silenced=&before=&limit=` | device or none | List, newest first; `next_cursor` feeds `before`; `silenced=true|false` filters | | GET | `/api/v1/events/:id` | device or none | Full event | | GET | `/api/v1/events/:id/deliveries` | device or none | Push attempts for an event | | GET/POST | `/api/v1/projects` | admin | List / create (returns `api_key` once) | | GET/PATCH/DELETE | `/api/v1/projects/:id` | admin | Manage | | POST | `/api/v1/projects/:id/rotate-key` | admin | New key, old one stops working | | POST | `/api/v1/pairing` | admin | One-time pairing token + QR payload (10 min, single use) | | DELETE | `/api/v1/pairing/:id` | admin | Revoke | | POST | `/api/v1/pairing/exchange` | none | `{token, name, platform}` → `{device, credential}` | | POST | `/api/v1/devices` | device | Register APNs token `{device_token, name, app_bundle_id}` | | PATCH/DELETE | `/api/v1/devices/:id` | device (self) or admin | Update / remove | | GET | `/api/v1/devices` | admin | List paired devices | | GET | `/api/v1/status` | admin | Health, APNs state, counts, last push | | GET/PATCH | `/api/v1/settings` | admin | `retention_days`, `redact_keys`, `setup_completed` | | GET/POST | `/api/v1/silences` | admin | Rules that stop matching events from being pushed: `{field: fingerprint|title|source, value, project_id?, note?}` | | GET | `/api/v1/silences/:id` | admin | One rule | | DELETE | `/api/v1/silences/:id` | admin | Remove a rule (already-silenced events keep their flag) | | POST | `/api/v1/events/:id/unsilence` | admin | Clear the flag and push the event now | | POST | `/api/v1/test` | admin | Create a test event and push it |

Credentials: project keys (`boop_proj_...`) can only create events; device credentials (`boop_dev_...`) can only read events and manage their own device. Only SHA-256 hashes are stored.

**Admin auth.** Set `BOOP_ADMIN_USER` and `BOOP_ADMIN_PASSWORD` and the web UI shows a sign-in screen; admin endpoints then need the session cookie it sets (`POST /api/v1/auth/login`) or HTTP Basic credentials (`curl -u user:pass …`). Sessions last 30 days and live in memory, so a restart signs everyone out. Leave both unset and everything is open — only do that behind your own proxy, Tailscale, or VPN. Either way, project and device credentials are refused on admin endpoints, so a leaked client secret never grants admin rights.

Configuration

[](https://github.com/chrisgreg/boop#configuration) | Variable | Default | Notes | | --- | --- | --- | | `BOOP_PORT` | `8080` | | | `BOOP_BASE_URL` | request origin | Public URL your phone can reach; used in the pairing QR | | `BOOP_DATABASE_PATH` | `/data/boop.db` | WAL mode, migrations applied on start | | `BOOP_RETENTION_DAYS` | `90` | Days of history to keep; `0` = forever. When set it overrides the value saved in the web UI on every start; leave unset to manage it from Settings | | `BOOP_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | | `BOOP_ADMIN_USER` | | Web UI / admin API username; set together with the password | | `BOOP_ADMIN_PASSWORD` | | 8+ characters. Unset = no login | | `APNS_TEAM_ID` | | Apple Developer team id | | `APNS_KEY_ID` | | Id of the APNs auth key | | `APNS_BUNDLE_ID` | | Bundle id of your Boop iOS build | | `APNS_PRIVATE_KEY_PATH` | | Path to the mounted `.p8` (preferred) | | `APNS_PRIVATE_KEY` | | Alternative: the `.p8` contents, as PEM text or base64 (`base64 -i key.p8 | tr -d '\n'`) | | `APNS_ENVIRONMENT` | `production` | `sandbox` for Xcode debug builds |

Apple setup

[](https://github.com/chrisgreg/boop#apple-setup) 1. In the Apple Developer portal, create an App identifier for your Boop iOS build and enable Push Notifications. 2. Under Keys, create an APNs authentication key. Download the `.p8` (only possible once). Note the Key id. 3. Note your Team id (top right of the portal). 4. Put the `.p8` at `./secrets/apns.p8`, uncomment the secrets volume in `docker-compose.yml`, set `APNS_PRIVATE_KEY_PATH=/run/secrets/apns.p8`, and fill the other `APNS_*` values in `.env`. 5. Restart: `docker compose up -d`. Settings should show APNs as configured.

6. Build the iOS app (`open ios/Boop.xcodeproj`, set your team and the same bundle id — see ios/README.md), install it on your phone, open Devices → Pair iPhone, and scan the QR. 7. Settings → Send test notification.

Deploying with Dokploy (or any compose host)

[](https://github.com/chrisgreg/boop#deploying-with-dokploy-or-any-compose-host)

Use `docker-compose.dokploy.yml`, not `docker-compose.yml`. It swaps the `./data` bind mount for a named volume (the bind mount is created root-owned on the host, and the image runs as uid 1000, so SQLite cannot write `/data/boop.db`), joins the external `dokploy-network` so Traefik can route to it, and drops the published port so the server is reachable only through your HTTPS proxy.

1. New **Compose** application → your repo, compose path `docker-compose.dokploy.yml`.

2. **Environment** tab: `BOOP_BASE_URL`, `BOOP_ADMIN_USER`, `BOOP_ADMIN_PASSWORD`, `APNS_TEAM_ID`, `APNS_KEY_ID`, `APNS_BUNDLE_ID`, `APNS_ENVIRONMENT`. Dokploy writes these to a `.env` beside the compose file, which is what `env_file` picks up. 3. The `.p8` key, either:

- `APNS_PRIVATE_KEY` = `base64 -i AuthKey_XXXXXX.p8 | tr -d '\n'` (one line, easiest). **Leave `APNS_PRIVATE_KEY_PATH` unset** — if it has a value it overrides the inline key, and the resulting missing-file error is the usual reason APNs looks unconfigured after a deploy. Copying `.env.example` wholesale is how it ends up set.

- Or Dokploy **Mounts → File mount**: paste the `.p8` contents, mount path `/run/secrets/apns.p8`, and set `APNS_PRIVATE_KEY_PATH=/run/secrets/apns.p8`.

4. Add a domain with HTTPS in Dokploy pointing at port `8080`; deploy. Settings in the web UI should show **APNs · Configured**.

Editing the compose file in Dokploy's UI only works for **Raw** compose apps; when the source is Git, commit changes and redeploy.

Pairing

[](https://github.com/chrisgreg/boop#pairing) The web UI generates a one-time token (10 minutes, single use, revocable) and shows it as a QR code containing:

{"version": 1, "server": "https://boop.example.com", "token": "pair_..."}

The app posts the token to `/api/v1/pairing/exchange`, stores the returned device credential, registers for APNs, and posts its token to `/api/v1/devices`. Registering the same APNs token twice updates the existing device instead of creating a duplicate.

Silences

[](https://github.com/chrisgreg/boop#silences)

Some events you want stored but not on your phone: a known flaky job, a noisy warning. On any event's page click **Silence events like this** and pick what to match — its fingerprint, its title, or its source — for that project or every project. Matching events still arrive in the inbox (marked _silenced_) and in the iOS app, but no push is sent. Filter the inbox to **Silenced only** to review them; a silenced event's page shows the rule that caught it with **Remove rule** and **Unsilence and push now**. Manage all rules under **Settings → Silences**. Fingerprint and source match exactly; title ignores case.

Redaction

[](https://github.com/chrisgreg/boop#redaction)

Values under these keys are replaced with `[REDACTED]` anywhere in `data` before storage: `password`, `password_confirmation`, `secret`, `token`, `access_token`, `refresh_token`, `api_key`, `authorization`, `cookie`, `set-cookie`, `private_key`. Matching is case-insensitive and treats `-` and `_` alike. Add your own keys in Settings.

Development

[](https://github.com/chrisgreg/boop#development)

cd server && BOOP_DATABASE_PATH=./data/boop.db go run ./cmd/boop # API on :8080 cd server/web && npm install && npm run dev # UI on :5173, proxies /api make test # Go + web tests make build # bin/boop with the UI embedded

Requires Go 1.27 and Node 24 (see `.tool-versions`). The SQLite driver is pure Go, so `CGO_ENABLED=0` builds work everywhere.

Licence

[](https://github.com/chrisgreg/boop#licence) MIT.