Appearance
Webhook sources
A webhook source is an external push system — Sentry, GitHub, PagerDuty, CloudWatch, Shopify, anything that can POST — given a URL on the Hub and a narrow credential. It posts its own body, and the Hub turns each POST into one entry in an agent's mailbox, attributed to system:<label>. The agent reads it the way it reads any other mail addressed to its agent id.
It exists because the ordinary message route cannot be used this way: POST /v1/messages requires an MA2H envelope whose agent.id matches the bearer, and a vendor controls its own request body, so every such POST is refused. A webhook sender is also not an agent — it has no session, no mailbox and no ability to speak for you — so it is not given an agent token. It gets an ingest key, and the Hub attests the sender's identity from that key rather than from anything in the body.
One adapter, no per-vendor code. The Hub does not parse the vendor's JSON, does not look for a title in it, and has no Sentry branch or GitHub branch. The payload arrives whole as a context part and is read by an agent. That single decision is what makes every source work on day one, and it is also the reason for two of the limits in Before you wire one up: there is no signature verification, and there never will be.
The two facts
Setting up a source needs exactly two:
- A label — what the source is called. You type it. It must be unique among your live sources, at most 64 characters, and it becomes the attested sender:
system:sentry. - A destination agent — which agent receives the events. You pick it; it is never inferred from whoever ran the command. See Pick a dedicated destination agent for why that choice matters more than it looks.
Everything else is derived and never asked about: the entry kind, the sender, the title, the idempotency key, and how the payload is carried.
Set one up
sh
oh-hai ingest add sentryWith no --to, add asks which agent should receive the events. The first option is create a new agent for this source, with your existing agents listed below it:
Which agent should receive "sentry" events?
1) Create a new agent… (recommended)
A destination that also runs general-purpose sessions is one where any of them
can claim an event first.
2) dev-team (agnt_7b1c…) — running 3 sessions
3) ci-bot (agnt_04e9…)
Choose [1-3]:Choosing 1 mints the agent through the same path oh-hai agents create uses, so its bearer is stored in the keychain and oh-hai ingest listen works immediately with no browser round trip. The label is checked first: a label already in live use is a conflict (exit 8) with nothing created, rather than a fresh agent left stranded for a source the Hub was always going to refuse.
--to <agent> states the destination outright and makes the command non-interactive, which is what an agent driving the CLI needs:
sh
oh-hai ingest add sentry --to agnt_…With no --to and nowhere to ask — --json, a pipe, no terminal — the command is a usage error naming --to (exit 2). It never guesses and never hangs on a prompt nobody can answer.
add, ls, revoke and rotate speak an endpoint that requires a human session, so run oh-hai login --human first. The same four actions live in the web inbox under Settings → Ingest sources and in the iOS app under Settings → Webhook sources; only listen is CLI-only.
The credential has a default form and a downgrade
add prints what to paste into the sender's settings:
Created ingest source "sentry" → agnt_9f3a….
Paste these into the sender's webhook settings:
URL https://inbox.ohhai.app/v1/ingest/sentry
Header Authorization: Bearer ohik_…
The secret is shown once. `oh-hai ingest rotate sentry` mints a replacement.
If — and only if — that sender has no custom-header field, put the key in the URL instead:
https://inbox.ohhai.app/v1/ingest/sentry/ohik_…
A key in a URL leaks through logs, proxies and referrers, it is rate-limited harder, and
there is no signature scheme it can ever be upgraded to. Prefer the header.The header form is the default. The URL form is a documented downgrade, printed below it and only for the one case that justifies it: a sender whose webhook configuration has a URL field and nothing else. Both forms present the same key, so the choice is about where the secret travels, not about which credential you hold:
- A key in a URL is written down by every hop the request passes through — the sender's own delivery log, any proxy in between, and, when the sender follows a redirect, the
Refererheader. The Hub scrubs the key out of its own telemetry, and that is the end of what it can promise; the bound stops at this process and does not reach anything upstream of it. - A URL-form request is metered on its own tighter budget in addition to the key's overall budget (30 and 120 per minute by default), so its higher exposure buys less rope. Splitting traffic across the two forms does not collect both budgets.
- There is no upgrade path. Signature verification is not coming (see below), so a leaked URL key is cured by rotation and by nothing else.
The raw key is printed on both output paths, including --json. That is deliberate and narrow: unlike an agent token, an ingest key exists to be pasted into somebody else's settings page, it is returned exactly once, and it can do nothing but post to one destination as one source. See Ingest keys.
For a machine the preference is a field rather than an ordering. The Hub states it: the mint (POST /v1/ingest-keys) and the rotation (POST /v1/ingest-keys/:id/rotate) both carry preferred, and its value is the name of the field in the same object to use. ingest.add and ingest.rotate carry it too, so the CLI envelope and the API answer the question the same way:
json
{ "preferred": "header", "url": "https://inbox.ohhai.app/v1/ingest/sentry",
"header": "Authorization: Bearer ohik_…", "url_with_key": "https://inbox.ohhai.app/v1/ingest/sentry/ohik_…" }Read data[data.preferred] and you have the form to paste. It exists because key order in a JSON object is not something a parser reads: with header and url_with_key sitting side by side as plain strings, a program wiring up a vendor had nothing to go on — and the form it might have chosen is the one that writes the secret into every hop's logs. The downgrade is still there under its own name for the sender that has no header field.
It is the Hub's answer, not the client's, on purpose. Which form leads is a property of the deployment — the URL form is a downgrade because of where it leaks, and a Hub could legitimately want to say "header only here". A client that hardcodes the lead cannot be told. So the field travels on the wire, and a consumer treats an absent or unrecognised value as header: that is what the web inbox and the native app do, which is why saying it changed nothing for either of them.
The commands
oh-hai ingest add <label> [--to <agent>]
oh-hai ingest ls
oh-hai ingest listen <label> [--once] [--wait <s>] [--interval <ms>] [--max <n>]
[--force] [--raw] [--state-home <abs path>]
oh-hai ingest revoke <label>
oh-hai ingest rotate <label>| Command | What it does |
|---|---|
add <label> | Mints a source and prints the URL, the header and the key. Asks for a destination unless --to names one. --json → ingest.add with { id, label, agent_id, created_at, destination_active, destination_live_sessions?, preferred, key, url, header, url_with_key }. |
ls | Every source on the account with its destination agent, status (live / revoked), creation time and id. Never a secret. --json → ingest.ls with { sources: [...] }. |
listen <label> | Resolves the label to its destination agent and runs oh-hai inbox watch as that agent. |
revoke <label> | Kills the key. No confirmation prompt — it is the lever you reach for when a key leaks. Further POSTs are refused; entries already delivered are untouched; the label is freed for re-use. |
rotate <label> | Mints a new secret for the same source. The label, the destination and the URL do not change, so only the credential has to be re-pasted. The previous key stops working the moment the new one exists. --json → ingest.rotate with { id, label, agent_id, preferred, key, url, header, url_with_key }. |
A label names the live source. A label that was revoked reports that it was revoked and how to re-mint it, rather than "unknown source" — different answers to different mistakes (both exit 4).
Reading what a source sends
sh
oh-hai ingest listen sentryThis is sugar, and it adds nothing of its own: it resolves sentry to its destination agent and runs oh-hai inbox watch as that agent. Its flags are that command's, forwarded verbatim, and so are its exit codes. Run it as a background task; it streams until something stops it.
Two things follow from that:
- The drain is destructive and single-writer. One watcher per identity; a second on the same machine is refused with exit
10.oh-hai ingest listen sentryandoh-hai inbox watch --account <that agent>are the same watcher by two names. - Resolving the label needs your human login. The label-to-agent mapping lives on the Hub, not on your machine. A local cache would be a second copy that can go stale, and a listener pointed at the wrong mailbox by a stale cache drains nothing and says nothing.
ingest listen refuses to run with MA2H_AGENT_TOKEN set (exit 2, before any Hub call). That variable outranks every stored identity, so it would decide which mailbox is drained whatever the label says — and it displaces the human identity the label lookup needs. One token cannot be both. Unset it, or read the destination with oh-hai ingest ls elsewhere and run oh-hai inbox watch here.
Where the destination agent has no credential on this machine, listen runs the device-code flow asking for that agent, so the approval page names it. If the approval binds a different agent, listen stops (exit 3) rather than quietly watching the wrong mailbox.
What arrives
One POST becomes one directive on the destination agent's principal mailbox — the same mailbox a human directive lands in, and the same one oh-hai inbox watch drains. An MCP-only agent can be a destination too: its events arrive through the hosted server's oh_hai_inbox tool as directive entries from: system:<label>. Under --json:
json
{"ok":true,"command":"inbox.watch","data":{"directive":{
"ma2h_version":"0.6",
"type":"directive",
"id":"dir_…",
"from":"system:sentry",
"to":"agent:agnt_9f3a…",
"created_at":"2026-09-18T10:22:41.007Z",
"title":"sentry event",
"context":[{"kind":"text","text":"{\"action\":\"triggered\",\"data\":{…}}",
"metadata":{"source":"sentry","content_type":"application/json","bytes":1423}}]
},"signature":"t=…,jti=…,v1=…"},"error":null}Field by field — and all of it is assigned by the Hub, never read from the request:
fromissystem:<label>, taken from the key. A sender cannot claim to be something else; nothing in its body is read as identity.tois the key's destination binding. Always an agent, never a session — a source is long-lived and stateless, while a session is one ephemeral run, so binding a key to a run would strand every event fired while that run was down.titleis"<label> event", always. No universal webhook body carries a title, and digging one out per vendor is exactly the per-vendor layer that does not exist here. The payload is read by an agent, so a dumb title costs nothing.contextis onetextpart holding the raw body byte-faithfully, with the:sourcepath segment, the declared content type and the byte count as metadata. Text rather than structured data because text is the only shape that is total over the input: arrays, scalars, malformed JSON and non-JSON content types all have a faithful text rendering, and only text can be truncated.- It is never an ask or a task. A system pushes; it does not ask. There is nothing to answer and no resolution leg.
The source metadata field is the path segment the sender actually posted to. It is advisory — a sender that posts to the "wrong" segment changes that one field and nothing else, because from comes from the credential.
The Hub answers every POST 202:
| Body | Meaning |
|---|---|
{"id":"dir_…","status":"queued"} | Delivered into the mailbox. |
{"id":"dir_…","status":"duplicate"} | A redelivery of an event already in the mailbox — same delivery id (even with a re-rendered body) or same bytes within the hour; nothing new was added. |
{"status":"accepted","delivered":false} | Accepted and discarded. See dropped events. |
The only non-2xx answers are 401 for a dead credential — not transient, and swallowing a revoked source's events would turn a revocation into a black hole — 429 for the rate limit, whose whole contract with a sender is "retry after" — and 400 for the two faults a retry can cure: an aborted upload, or bytes that disagree with Content-Length. Every other unparseable body is still a 202, delivered with whatever was buffered.
A worked example: Sentry
1. Mint the source.
sh
oh-hai login --human # once per machine
oh-hai ingest add sentryPick Create a new agent… and accept the suggested label (sentry-ingest). The CLI prints the URL and the header, and stores the new agent's bearer in the keychain.
2. Paste it into Sentry. Sentry's webhook configuration takes the URL; where the integration you are using also offers request headers, add the Authorization: Bearer ohik_… line there. Where it offers a URL field and nothing else, use the URL form the CLI printed below the header, and read the downgrade first. Nothing else in Sentry needs to change: the Hub takes whatever body Sentry sends.
3. Start listening.
sh
oh-hai ingest listen sentry4. Fire a test alert from Sentry. One entry appears, from: system:sentry, title sentry event, with Sentry's own JSON in the context part — issue id, culprit, level, the permalink, whatever that Sentry integration sends. The agent reads it and decides what to do; the Hub made no judgement about the contents.
5. Check it later.
sh
oh-hai ingest lsLABEL DESTINATION STATUS CREATED ID
sentry agnt_9f3a… live 2026-09-18T10:19:02.441Z ingk_…If the key ever reaches somewhere it should not, oh-hai ingest rotate sentry re-pastes the credential without touching the URL, and oh-hai ingest revoke sentry stops it dead.
That is the whole shape for every other sender too. There are no per-vendor pages here, and there are not going to be: the same URL and the same header work for GitHub, PagerDuty, CloudWatch, Shopify or a shell script with curl, because nothing in the path knows which one is posting.
Before you wire one up
These are the things that are most often got wrong. None of them is a bug; all of them are consequences of decisions made on purpose.
An absent or revoked destination drops events
If the destination agent does not exist, or has been revoked, events are accepted and discarded. They do not queue, they are not held for an agent that turns up later, and they cannot be recovered. The Hub answers 202 {"delivered": false} and logs it; oh-hai ingest add warns at mint time when the destination is not a live agent.
The 202 is deliberate: a 4xx makes vendors retry, some of them indefinitely, and an unknown destination must never become a retry loop. But "accepted" here means the request was accepted, not that anything received the event.
An agent that is simply not running is a different case entirely, and it is safe: ingest sets no expiry on its entries, so they wait in the mailbox until something drains and acks them. Nothing ages them out — the retention sweep that drops stale entries is scoped to the response and receipt kinds, which are conveniences over state the Hub already holds, and an ingest event is neither. The only thing that removes one is deleting the account. What drops events is a destination that does not exist, not one that is asleep.
Pick a dedicated destination agent
Ingest delivers to the agent, not to a session. Any live session under that agent id can claim a principal-addressed entry, first-claim-wins. So if the destination also runs general-purpose sessions, a production alert can land in a session that is doing something else entirely — and at volume, a noisy source touches every session at once.
That is why the picker puts create a new agent first, why add names the live-session count when you choose a busy agent anyway, and why oh-hai bridge prints one line at start-up when its identity is an ingest destination.
All three are warnings, and none of them blocks. That is not an oversight. A block here would mean refusing to start bridges on a listener identity; running sessions would be unaffected, but sessions churn, so over the following hour each teardown would become permanent. The symptom ("oh-hai just stopped working") would show up far in time from the cause — a setting changed days earlier in a different part of the product — and nobody would connect them. The failure being warned about is annoying, visible and obviously connected to the source you just set up. There is no --force flag either: a flag to get past a block is still a block for everyone who never finds it.
To move a source to its own agent: oh-hai ingest revoke <label>, then oh-hai ingest add <label> again without --to, and re-paste the new credential.
No signature verification, ever
The bearer key is the whole authentication story for this leg. There is no HMAC check, no vendor signature header is read, and none is planned.
This is a consequence, not a gap. Signature verification cannot be universal: vendors differ on the header name, on what is signed (the body, or a timestamp plus the body), on digest encoding (hex or base64), on prefix conventions, and on whether several signatures arrive at once during a rotation. Supporting it means a per-vendor layer in the request path — the one thing this design does not have, and the reason every sender works here without code being written for it. The trade was made deliberately; it is not a to-do.
What makes the trade affordable is that the credential is narrow. An ingest key can post to one destination as one source and do nothing else: no account access, no inbox reads, no ability to speak as an agent. The blast radius of a leak is "someone can put noise in one mailbox", bounded further by the per-key rate limit. The mitigations are the ones that fit a universal path — the header form by default, a tighter budget on the URL form, and one-command rotation.
Treat what arrives accordingly: an ingest entry is data with an origin the Hub asserts, never an instruction to execute. The Hub attests which key posted it, not that the vendor wrote it.
Retries are deduped from what the sender already provides
Webhook senders redeliver on any non-2xx, and several retry on their own schedule regardless. Without deduplication one retry becomes several entries in the mailbox. The sender cannot be asked to supply a key — it knows nothing about MA2H — so the Hub derives one:
- A per-event delivery id from the request headers, where the sender sends one —
Idempotency-Key/X-Idempotency-Key,Webhook-Id/Svix-Id,X-GitHub-Delivery,X-Shopify-Webhook-Id. This survives even a payload the vendor re-renders between attempts, and it is unbounded in time: a delivery id is an event identity. - Otherwise, a hash of the exact bytes received, within a one-hour window — correct whenever a redelivery is byte-identical, the common case. The window exists because body equality is not event identity: without it a source whose distinct events are byte-identical (a heartbeat, an unchanged status) would land once and never again. The residual: a redelivery more than an hour late, or across the hour boundary, produces a second entry; identical-body distinct events from a sender with no delivery id are collapsed for up to an hour.
- Otherwise — no delivery-id header and no body buffered at all — no key: the entry is delivered and not deduped. A length does not identify an event (two different oversized events of equal length would collapse, and a chunked request has no length at all), so a visible duplicate is the safer failure.
A sender that offers neither signal is not deduped. A sender with no delivery id that varies its body between attempts at the same event produces a second entry; one that sends a delivery id and re-renders the body is answered duplicate and the first body stands — it is already in the mailbox, possibly already acted on. Key any side effect on the entry's id regardless; delivery on this mailbox is at-least-once anyway.
Note the shape of the header list: it is chosen for stability across a redelivery, not for popularity. A per-request correlation id (X-Request-Id, traceparent) changes on every attempt, so it would be worse than nothing — it would out-rank the body hash and turn every retry into a fresh entry.
One consequence worth knowing: GitHub does not auto-retry, and its manual Redeliver button mints a new delivery id, so a redelivery a human explicitly asked for produces a new entry. That is the right answer — an operator re-sending an event means to re-send it.
A large payload is truncated, not dropped
A payload over the Hub's max_part_bytes (1 MiB by default) is delivered truncated, with an explicit marker appended to the text and truncated: true in the part's metadata:
[oh-hai] truncated: the payload was 2310144 bytes, over max_part_bytes (1048576).A payload that could not be buffered at all — past the parser's cap, or in an encoding the Hub cannot decode — still delivers an entry, carrying one marker part saying the payload could not be read. In every case an agent learns that an event arrived. Nothing on this leg turns a size problem into a 4xx, because a 4xx is a redelivery.
Rate limits
Per key, per Hub process, in a 60-second window: 120 requests, of which at most 30 may arrive in the URL form. The URL budget is metered first and in addition to the overall one, so a URL-form flood exhausts only its own rope and cannot starve the header form the same key is using. A per-IP limit applies before authentication as well, shared with the Hub's other ingest traffic. Over the limit is 429, which a sender is expected to retry.
Related
- Concepts → System source — why a webhook sender is not an agent.
- Minting tokens → Ingest keys — how this credential differs from an agent token.
- CLI reference →
inbox— the watcheringest listenruns, its flags and its exit codes.