{"components":{"schemas":{"Error":{"properties":{"error":{"description":"Human-readable reason. Intended for a log line or an operator, not for programmatic branching — branch on the status code.","type":"string"}},"required":["error"],"type":"object"},"Health":{"properties":{"status":{"const":"ok"},"uptime":{"description":"Go duration string, e.g. \"3h14m0s\".","type":"string"},"version":{"type":"string"}},"required":["status","version","uptime"],"type":"object"},"NotifyRequest":{"additionalProperties":false,"properties":{"channels":{"description":"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.","items":{"enum":["email","ntfy"],"type":"string"},"minItems":1,"type":"array"},"html":{"description":"Optional rich body for channels that can render one — email today, and ignored by every text-only channel. Send a fragment (`\u003cp\u003edone\u003c/p\u003e`) to get it wrapped in hubbub's styled mail shell, or a whole document (starting `\u003c!doctype` or `\u003chtml`) 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.","type":"string","x-max-bytes":131072},"message":{"description":"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.","minLength":1,"type":"string","x-max-bytes":4096},"priority":{"default":"default","description":"Display only — never changes which channels fire. Omitted or empty means `default`; parsing is case-insensitive.","enum":["low","default","high","urgent"],"type":"string"},"tags":{"description":"Short labels passed to adapters. Control characters are stripped and tags left empty are dropped.","items":{"type":"string","x-max-bytes":64},"maxItems":16,"type":"array"},"title":{"description":"Required. Max 256 bytes after sanitisation. All control characters are stripped — titles can land in HTTP headers and email subjects.","minLength":1,"type":"string","x-max-bytes":256}},"required":["title","message"],"type":"object"},"NotifyResponse":{"properties":{"channels":{"additionalProperties":{"description":"`ok`, `queued`, `disabled`, or a `failed: …` / `dropped: …` reason.","type":"string"},"description":"Per-channel outcome, keyed by channel id.","type":"object"},"requestId":{"description":"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`.","type":"string"},"result":{"description":"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.","enum":["delivered","queued","partial","failed"],"type":"string"}},"required":["result","requestId","channels"],"type":"object"}},"securitySchemes":{"bearerAuth":{"description":"A key issued by the operator, sent as `Authorization: Bearer \u003ckey\u003e`.","scheme":"bearer","type":"http"}}},"info":{"description":"One JSON API in, delivery to the operator's channels out.\n\nCallers 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.\n\nAuthentication 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.\n\nDelivery is at-least-once: a caller that times out and retries may double-send.","summary":"Authenticated notification fan-out hub.","title":"hubbub","version":"0.1.0"},"openapi":"3.1.0","paths":{"/":{"get":{"description":"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.","operationId":"index","responses":{"200":{"content":{"text/html":{"schema":{"type":"string"}}},"description":"The landing page."}},"security":[],"summary":"Human-readable landing page."}},"/docs":{"get":{"description":"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.","operationId":"docs","responses":{"200":{"content":{"text/html":{"schema":{"type":"string"}}},"description":"The reference page."}},"security":[],"summary":"Browsable API reference."}},"/health":{"get":{"description":"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.","operationId":"health","responses":{"200":{"content":{"application/json":{"example":{"status":"ok","uptime":"3h14m0s","version":"0.1.0"},"schema":{"$ref":"#/components/schemas/Health"}}},"description":"The process is up."}},"security":[],"summary":"Liveness."}},"/llms.txt":{"get":{"description":"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.","operationId":"llmsTxt","responses":{"200":{"content":{"text/plain":{"schema":{"type":"string"}}},"description":"Markdown, served as text/plain so it renders wherever it is fetched."}},"security":[],"summary":"This contract, condensed, as markdown."}},"/openapi.json":{"get":{"description":"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.","operationId":"openapi","responses":{"200":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"The OpenAPI document."}},"security":[],"summary":"This document."}},"/v1/notify":{"post":{"description":"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`.","operationId":"notify","requestBody":{"content":{"application/json":{"examples":{"minimal":{"summary":"Title and message only — goes to every channel the key permits","value":{"message":"nightly rsync exited 1","title":"Backup failed"}},"richEmail":{"summary":"html adds a rich body for channels that render one; message stays the plain-text alternative","value":{"html":"\u003cp\u003e3 jobs failed, 41 succeeded.\u003c/p\u003e\u003ctable\u003e\u003ctr\u003e\u003cth\u003ejob\u003c/th\u003e\u003cth\u003eexit\u003c/th\u003e\u003c/tr\u003e\u003ctr\u003e\u003ctd\u003eborg\u003c/td\u003e\u003ctd\u003e1\u003c/td\u003e\u003c/tr\u003e\u003c/table\u003e","message":"3 jobs failed, 41 succeeded. See the run log.","tags":["digest"],"title":"Weekly digest"}},"withPriorityAndTags":{"summary":"Priority and tags are display hints; neither changes routing","value":{"message":"/ is at 94% on tools","priority":"urgent","tags":["disk","cron"],"title":"Disk almost full"}}},"schema":{"$ref":"#/components/schemas/NotifyRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"example":{"channels":{"ntfy":"ok"},"requestId":"r_cc519c9a3ee79f270c4e9823cd56f2f3","result":"delivered"},"schema":{"$ref":"#/components/schemas/NotifyResponse"}}},"description":"Delivered: every target channel completed successfully inside the response window."},"202":{"content":{"application/json":{"example":{"channels":{"ntfy":"queued"},"requestId":"r_cc519c9a3ee79f270c4e9823cd56f2f3","result":"queued"},"schema":{"$ref":"#/components/schemas/NotifyResponse"}}},"description":"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`."},"207":{"content":{"application/json":{"example":{"channels":{"ntfy":"ok","parked":"disabled"},"requestId":"r_cc519c9a3ee79f270c4e9823cd56f2f3","result":"partial"},"schema":{"$ref":"#/components/schemas/NotifyResponse"}}},"description":"Partial: channels disagreed — some combination of delivered, queued, failed or disabled. Inspect `channels` per id."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"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":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing or unknown bearer key."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"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":{"content":{"application/json":{"schema":{"properties":{"result":{"const":"rate_capped"}},"required":["result"],"type":"object"}}},"description":"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.","headers":{"Retry-After":{"description":"Seconds until the cap window rolls over.","schema":{"type":"integer"}}}},"502":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotifyResponse"}}},"description":"Every target channel failed permanently."}},"summary":"Send a notification to the caller's channels."}}},"security":[{"bearerAuth":[]}],"servers":[{"url":"https://notify.rlew.io"}]}
