hubbub

v0.1.0
Endpoint https://notify.rlew.io

One JSON API in, delivery to the operator's channels out.

Callers are machines, so every outcome is explicit enough to branch on: the HTTP status carries the verdict and result repeats it in the body. A 202 is a promise, not a receipt — the message is durably queued and its outcome is written to the hub's delivery log later, keyed by the requestId this call returned.

Authentication is a bearer key. The key's channel permission list is its delivery set: omit channels and the notification goes to every channel the key permits. Supplying channels narrows that set and can never widen it — naming a channel the key lacks is a 403, not a silent intersection.

Delivery is at-least-once: a caller that times out and retries may double-send.

POST /v1/notify bearer key required

Send a notification to the caller's channels.

Enqueues the notification for every target channel, waits out the server's response window, and reports what is known by then: attempts that finished are reported concretely, anything still in flight as queued.

Request body

FieldTypeRules
title* string ≤ 256 bytes Required. Max 256 bytes after sanitisation. All control characters are stripped — titles can land in HTTP headers and email subjects.
message* string ≤ 4096 bytes Required. Max 4096 bytes after sanitisation. Newlines and tabs survive (multi-line log excerpts are content); other control characters are stripped. Adapters truncate further to fit the channel. Required even alongside html — it is the plain-text alternative, and the only thing a text-only channel can show.
channels string[] email | ntfy Optional subset of the key's permitted channels. Omit it to use the key's full set. An explicit empty array is rejected with 400 rather than treated as absent. The enum on items lists the channel ids this deployment has configured; a key still only reaches the ones it is permitted.
html string ≤ 131072 bytes Optional rich body for channels that can render one — email today, and ignored by every text-only channel. Send a fragment (<p>done</p>) to get it wrapped in hubbub's styled mail shell, or a whole document (starting <!doctype or <html) to own the markup completely. Passed through as written: control characters are stripped and nothing else is, so treat the ability to post here as the ability to put arbitrary markup in the operator's inbox.
priority string low | default | high | urgent · default: default Display only — never changes which channels fire. Omitted or empty means default; parsing is case-insensitive.
tags string[] ≤ 16 items · each ≤ 64 bytes Short labels passed to adapters. Control characters are stripped and tags left empty are dropped.

Try it

Sending delivers a real notification to this hub's channels, writes a line to the delivery log, and spends one of the hourly rate cap.

Responses

  • 200 Delivered: every target channel completed successfully inside the response window.
    example body
    {
      "channels": {
        "ntfy": "ok"
      },
      "requestId": "r_cc519c9a3ee79f270c4e9823cd56f2f3",
      "result": "delivered"
    }
  • 202 Queued: nothing finished inside the response window. The message is durably spooled and will be retried; the outcome lands in the delivery log under this requestId.
    example body
    {
      "channels": {
        "ntfy": "queued"
      },
      "requestId": "r_cc519c9a3ee79f270c4e9823cd56f2f3",
      "result": "queued"
    }
  • 207 Partial: channels disagreed — some combination of delivered, queued, failed or disabled. Inspect channels per id.
    example body
    {
      "channels": {
        "ntfy": "ok",
        "parked": "disabled"
      },
      "requestId": "r_cc519c9a3ee79f270c4e9823cd56f2f3",
      "result": "partial"
    }
  • 400 Malformed request: bad JSON, an unknown field, an unparseable priority, an over-length body, or "channels": [] (omit the field instead — an empty list is a caller that computed no targets, and must not fan out to everything).
  • 401 Missing or unknown bearer key.
  • 403 The request named a channel this key is not permitted to use. A channel the key is permitted but which is currently disabled is not a 403 — it comes back as a per-channel disabled result.
  • 429 Global rate cap reached. Not a 202: a cap has to look like an error so standard retry machinery honours it rather than reading it as success.
  • 502 Every target channel failed permanently.

Response body

FieldTypeRules
result* string delivered | queued | partial | failed The verdict, repeating what the status code already said. Branch on the status code first and this second — never infer either from the channel map.
requestId* string Correlation id, also written to the hub's delivery log. Quote it when reporting a problem, and use it to find the settled outcome of anything reported as queued.
channels* object Per-channel outcome, keyed by channel id.

GET /docs no auth

Browsable API reference.

This document rendered as a page, with a panel for firing a real request against this hub. Generated from the spec on every load rather than maintained beside it, so it cannot describe an endpoint that isn't here.

Try it

Responses

  • 200 The reference page.

GET /health no auth

Liveness.

200 if the process is serving. Liveness only — no outbound probes and no channel state, so a healthy response says nothing about whether deliveries are succeeding.

Try it

Responses

  • 200 The process is up.
    example body
    {
      "status": "ok",
      "uptime": "3h14m0s",
      "version": "0.1.0"
    }

Response body

FieldTypeRules
status* string always ok
version* string
uptime* string Go duration string, e.g. "3h14m0s".

GET /llms.txt no auth

This contract, condensed, as markdown.

The llms.txt convention (llmstxt.org): what this hub is and how to call it, written for an agent that was handed the host and nothing else. Unauthenticated, and a strict subset of this document — read the spec instead if you can parse it.

Try it

Responses

  • 200 Markdown, served as text/plain so it renders wherever it is fetched.

GET /openapi.json no auth

This document.

Unauthenticated by design: it exposes the API's shape, which is not a secret, so an agent can be pointed at the base URL and discover the contract before it holds a key. The channels enum and the server URL reflect this deployment.

Try it

Responses

  • 200 The OpenAPI document.

GET /v1/recent bearer key required

Recent deliveries, newest first.

A read of the hub's delivery log: what came in, which channel it went to and how it ended up. One item per notification per channel — a fan-out to two channels is two items sharing a requestId. Outcomes are reported as settled as the log knows them, so a notification that answered 202 queued appears here with whatever its delivery eventually was. *Admin-scoped, and holding a valid key is not enough.* The log carries every caller's titles and caller ids, so reading it takes a key granted recent = true in keys.toml — or, from a browser, an operator identity this hub's admin allowlist permits. A key that can only send gets a 403. Every read, refused or served, is recorded in the log it reads. *To poll: send since set to the newest ts you already hold, and check truncated — when it is true the answer was cut to limit and the oldest* matching rows were dropped, so raise limit and ask again before advancing your cursor. An item resurfaces if its outcome settles after you last saw it, keeping its original ts and gaining a settledAt. The read covers the tail of the log, not its whole history; older entries stay in the file for grep. Unknown query parameters are rejected rather than ignored, so a misspelled filter cannot silently return everything.

Query parameters

ParameterTypeRules
limit integer 1–500 · default: 50 How many items to return, newest first. Out-of-range values are a 400 rather than clamped: an answer silently cut to the maximum is indistinguishable from the log holding only that many.
since string RFC3339 timestamp. Keeps only items that have changed since this instant — accepted after it, or settled after it. Pass the ts of the newest item you already hold: timestamps are published at millisecond precision and compared at the same precision, so the row you name is excluded rather than returned again on every poll.
channel string Keep only this channel's deliveries. Not checked against the configured channels: a channel deleted this morning still has history worth reading.

Try it

Responses

  • 200 The matching deliveries. An empty log is a 200 with items: [], never a 404.
    example body
    {
      "count": 2,
      "items": [
        {
          "caller": "cron",
          "channel": "ntfy",
          "outcome": "ok",
          "priority": "high",
          "requestId": "r_cc519c9a3ee79f270c4e9823cd56f2f3",
          "settledAt": "",
          "title": "Backup failed",
          "ts": "2026-08-08T09:31:02.418Z"
        },
        {
          "caller": "cron",
          "channel": "email",
          "outcome": "failed: smtp: 550 mailbox unavailable",
          "priority": "high",
          "requestId": "r_cc519c9a3ee79f270c4e9823cd56f2f3",
          "settledAt": "2026-08-08T09:33:44.902Z",
          "title": "Backup failed",
          "ts": "2026-08-08T09:31:02.418Z"
        }
      ],
      "truncated": false
    }
  • 400 An unknown query parameter, an unparseable since, or a limit outside 1–500.
  • 401 Missing or unknown bearer key, and no permitted admin identity on the request.
  • 403 The key is valid but not granted delivery-log access. Sending and reading are separate permissions on purpose: a send key that leaks must not also expose every other caller's notifications.
  • 500 The delivery log could not be read.

Response body

FieldTypeRules
items* object[] Deliveries, newest first. Always present, [] when nothing matched.
count* integer How many items this response carries — the length of items, not the size of the log.
truncated* boolean True when more rows matched than limit allowed. The rows dropped are the oldest of the match, so a caller advancing a since cursor past this answer would skip them for good: raise limit and repeat the request first.

GET / no auth

Human-readable landing page.

A short HTML page for a person who opened the base URL: what this is, one copy-pasteable request, and the response contract. Also served at /index.html. It documents the API and never the deployment behind it, so it names no channel, caller or ops endpoint.

Try it

Responses

  • 200 The landing page.