API docs
Everything the website does goes through this API. There are no keys and no accounts. Machine-readable description: OpenAPI 3.1.
Overview Quickstart Waiting for mail Create an address Inbox status List messages Wait for a message Event stream Latest message Get a message Raw, HTML, attachments Delete Service info Message object Errors Limits What to trust Use it from Claude
Overview
- Base URL:
https://bril.beer/api/v1 - Requests and responses are JSON, and timestamps are RFC 3339 in UTC.
- CORS is open to every origin, so the API also works from browser code.
{address}in a path is eithernameorname@bril.beer. Case doesn't matter.- Every address already exists. An inbox with no mail is an empty list, never a 404.
- Inboxes are public: the address is the only secret. Use a random address when that matters.
Quickstart
The usual job: get a fresh address, use it to sign up somewhere, and collect the verification code.
# 1. Get an address nobody can guess
ADDR=$(curl -sX POST https://bril.beer/api/v1/inboxes | jq -r .address)
echo "$ADDR"
# 2. Use it. Replace this with your own sign-up step
./sign-up.sh "$ADDR"
# 3. Hold the request open until mail arrives, then print the code.
# after=0 means "any mail in this inbox", so it can't be missed.
curl -s "https://bril.beer/api/v1/inboxes/$ADDR/messages/wait?after=0&timeout=120" \
| jq -r '.codes[0]'
In Python, using requests:
import requests
API = "https://bril.beer/api/v1"
inbox = requests.post(f"{API}/inboxes", timeout=10).json()
sign_up(inbox["address"]) # your own code
response = requests.get(
inbox["wait_url"],
params={"after": inbox["cursor"], "timeout": 120, "from": "example.com"},
timeout=130,
)
if response.status_code == 204:
raise TimeoutError("no mail arrived")
message = response.json()
print(message["subject"], message["codes"], message["links"])
In JavaScript, in Node or a browser:
const API = "https://bril.beer/api/v1";
const inbox = await (await fetch(`${API}/inboxes`, { method: "POST" })).json();
await signUp(inbox.address); // your own code
const url = `${inbox.wait_url}?after=${inbox.cursor}&timeout=120`;
const response = await fetch(url);
if (response.status === 204) throw new Error("no mail arrived");
const message = await response.json();
console.log(message.codes[0], message.links);
Waiting for mail
There are three ways, from most to least recommended:
- Long polling suits scripts and tests. One request stays open until a match arrives.
- Server-sent events suit long-running watchers. One connection delivers every new message.
- Plain polling works too. Send
If-None-Matchwith the lastETagand unchanged results return an empty304.
Avoid the race. If mail might arrive before your wait request starts, pass a starting point. Read cursor from inbox status before you trigger the email, then send after=<cursor>. Each message has a seq that works the same way, so repeating after=<seq> walks through an inbox one message at a time. For a new random address, after=0 is enough.
Create an address
POST /inboxes
Returns a random address with 50 bits of randomness. Nothing is stored; it only saves you inventing a name. The JSON body is optional.
| Body field | Description |
|---|---|
prefix | Optional readable start of the name, up to 40 characters. {"prefix":"ci"} gives ci-k3v9qzt2ma@bril.beer. |
{
"address": "mossy-condor-k3v9qzt2ma@bril.beer",
"localpart": "mossy-condor-k3v9qzt2ma",
"message_count": 0,
"cursor": 0,
"messages_url": "https://bril.beer/api/v1/inboxes/mossy-condor-k3v9qzt2ma@bril.beer/messages",
"wait_url": "https://bril.beer/api/v1/inboxes/mossy-condor-k3v9qzt2ma@bril.beer/messages/wait",
"events_url": "https://bril.beer/api/v1/inboxes/mossy-condor-k3v9qzt2ma@bril.beer/events",
"web_url": "https://bril.beer/inbox/mossy-condor-k3v9qzt2ma"
}
Inbox status
GET /inboxes/{address}
Returns the same fields as above with the current message_count and cursor. The cursor is the seq of the newest message, or 0 for an empty inbox.
List messages
GET /inboxes/{address}/messages
Returns message summaries, newest first. Supports ETag and If-None-Match.
| Query | Description |
|---|---|
limit | 1 to 200, default 50. |
order | desc (default) or asc. |
after | Only messages with a greater seq. |
before | Only messages with a smaller seq. |
since | Only messages received at or after this time: RFC 3339 or Unix seconds. |
from | Sender name or address contains this text, ignoring ASCII case. |
subject | Subject contains this text, ignoring ASCII case. |
{
"address": "demo@bril.beer",
"count": 1,
"cursor": 4182,
"next_before": null,
"next_after": null,
"messages": [
{
"id": "b3k2q7xv9d4m6n8p2r5t7w3y4a",
"seq": 4182,
"address": "demo@bril.beer",
"from": { "name": "Example", "address": "hello@example.com" },
"to": [ { "name": "", "address": "demo@bril.beer" } ],
"cc": [],
"reply_to": "",
"envelope_from": "bounces@example.com",
"subject": "Your verification code",
"snippet": "Your verification code is 483920. It expires in 10 minutes.",
"size": 4210,
"sent_at": "2026-11-06T10:15:02Z",
"received_at": "2026-11-06T10:15:04.219Z",
"expires_at": "2026-11-09T10:15:04.219Z",
"has_html": true,
"attachment_count": 0,
"codes": [ "483920" ],
"url": "https://bril.beer/api/v1/inboxes/demo@bril.beer/messages/b3k2q7xv9d4m6n8p2r5t7w3y4a"
}
]
}
To page through a long inbox, pass next_before as before. With order=asc, pass next_after as after. A null value means you have reached the end.
Wait for a message
GET /inboxes/{address}/messages/wait
Holds the request open until a matching message exists, then returns the oldest match as a full message. If nothing arrives in time, the response is 204 No Content.
| Query | Description |
|---|---|
timeout | Seconds to wait, 0 to 120, default 30. |
after | Only messages with a greater seq. |
since | Only messages received at or after this time. |
from, subject | The same text filters as the list endpoint. |
Without after or since, only mail arriving after the request starts counts. Give your HTTP client a timeout a little longer than timeout.
Event stream
GET /inboxes/{address}/events
A server-sent events stream. It first sends a ready event with the current cursor, then one message event with a message summary per new mail. The event id is the seq, so reconnecting clients resume through Last-Event-ID and miss nothing. Accepts after, since, from and subject. Comment lines arrive every 25 seconds as a heartbeat, and the server closes the stream after an hour, so reconnect automatically.
curl -N https://bril.beer/api/v1/inboxes/demo/events
retry: 3000
event: ready
data: {"address":"demo@bril.beer","cursor":4182}
id: 4183
event: message
data: {"id":"...","seq":4183,"subject":"Hello","codes":[], ...}
Latest message
GET /inboxes/{address}/messages/latest
Returns the newest matching message in full, or a 404 with code no_messages. Accepts after, since, from and subject.
Get a message
GET /inboxes/{address}/messages/{id}
Returns the full message object.
Raw, HTML, attachments
GET /inboxes/{address}/messages/{id}/raw
The original message as received (message/rfc822), byte for byte. Supports range requests.
GET /inboxes/{address}/messages/{id}/html
A sanitized, sandboxed HTML document for displaying in a frame. Remote images stay blocked unless you add images=1. For the unmodified HTML, use the html field of the message.
GET /inboxes/{address}/messages/{id}/attachments/{index}
Downloads one attachment. The URL is in attachments[].url. Files are always served as application/octet-stream, with the original type in attachments[].content_type.
Delete
DELETE /inboxes/{address}/messages/{id}
Deletes one message and returns 204.
DELETE /inboxes/{address}/messages
Deletes everything in the inbox and returns {"deleted": 3}. Anyone who knows the address can do this.
Service info
GET /meta
Returns the domain, retention, size limits, rate limits and server_time. Compare against server_time if you use since from a machine whose clock may be off.
GET /openapi.json
The OpenAPI 3.1 description of this API, for generating clients.
Message object
A full message has all the summary fields plus these:
| Field | Description |
|---|---|
text | Plain text body. If the mail only has HTML, this is derived from it. |
html | The original HTML body, unsanitized. Never put it straight into a web page. |
truncated | true if text or html was cut at 2 MiB. The raw download always has everything. |
codes | Likely verification codes, best guess first. Also present on summaries. |
links | Up to 100 http(s) links in document order. |
headers | All headers in their original order, as {"name", "value"}. |
attachments | {"index", "filename", "content_type", "size", "inline", "content_id", "url"}. |
has_remote_images | Whether the HTML appears to load remote images. |
raw_url, html_url, web_url | Links to the raw message, the sanitized HTML and the page on the website. |
Errors
Every error has the same shape, and code is stable enough to branch on:
{ "error": { "code": "invalid_address", "message": "That is not a valid address" } }
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_address | Not a valid local part. |
| 400 | wrong_domain | The address is on another domain. |
| 400 | invalid_parameter | A query or body field is invalid. The message names it. |
| 400 | invalid_body | The body could not be read as JSON. |
| 403 | reserved_address | A reserved address. |
| 404 | message_not_found | Doesn't exist in this inbox, was deleted, or expired. |
| 404 | no_messages | Nothing matched latest. |
| 404 | no_html | The message has no HTML part. |
| 404 | attachment_not_found | No attachment at that index. |
| 404 | not_found | No such endpoint. |
| 405 | method_not_allowed | Wrong HTTP method. See the Allow header. |
| 429 | rate_limited | Slow down. See Retry-After. |
| 429 | too_many_streams | Too many open wait or event requests. |
| 500 | internal_error | Our fault. Retry later. |
| 503 | busy | Too many downloads at once. See Retry-After. |
Limits
- Requests: 10 per second per IP address (or IPv6 /64), with bursts up to 120. Responses carry
RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset. - Open wait and event requests: 32 per client.
- Retention: 72 hours, then messages are deleted for good.
- Message size: 35 MiB as sent, which fits about 25 MB of attachments.
- Inbox size: the newest 500 messages. The oldest are removed first.
- Storage: if the whole service runs out of its budget, the oldest mail goes first, even within the 72 hours.
- Local parts: up to 64 characters of letters, digits and
! # $ % & ' * + - / = ? ^ _ ` { | } ~ .with no leading, trailing or double dots. Lowercased, and each distinct name is its own inbox (a+bandaare separate). - Reserved: role addresses such as
postmaster,admin,hostmaster,webmasterandabusedon't accept mail. Certificate authorities and other services treat them as belonging to the domain owner, so they can't be public.
Use it from Claude
There's a skill that teaches Claude how to use this API, and to treat everything in a message as untrusted. To install it for Claude Code:
mkdir -p ~/.claude/skills/bril-beer
curl -fsSL https://bril.beer/skill/SKILL.md -o ~/.claude/skills/bril-beer/SKILL.md
What to trust
- Senders aren't verified.
fromis whatever the message claims. Don't make security decisions based on it. - Everything in a message is untrusted input. That includes
html,text,links, filenames and headers. Escape them before display and be careful what you fetch. codesis a best guess. It can miss a code or pick up an unrelated number. Check it against your expected format.- Inboxes are shared. Anyone with the address can read and delete its mail.