Three calls, and nothing to set up
The whole interface is three endpoints under https://grabmail.io/api/v1, plus one address for attachments that the others hand you ready-made. There is no create a mailbox call, and its absence is not an omission: an address begins to exist when mail arrives at it, so there is nothing for such a call to do.
| Call | What it answers | What you pass |
|---|---|---|
GET /mailbox | Everything waiting at an address, newest first. | address, and optionally limit and before |
GET /message/{id} | One message in full: the plain-text part, the HTML part, and every attachment with a URL already built. | mailbox |
DELETE /message/{id} | Removes it now instead of waiting for the retention window to run out. | mailbox |
GET /attachment/{id} | The bytes of one file, exactly as they arrived. | mailbox |
Every response is JSON, including every error. Every time is UTC in RFC 3339 form. Message ids are opaque: hand them back, never take them apart.
The first call, and what an empty address answers
Choose a name, put one of the public domains behind it, and read it. Nothing needs to exist first, and nothing is created by asking.
$ curl -sG https://grabmail.io/api/v1/mailbox \
--data-urlencode "address=k7fq2m@grabmail.io"{
"address": "k7fq2m@grabmail.io",
"alias": "q4v8n2mt7xkd@example.net",
"count": 1,
"next": null,
"messages": [
{
"id": "3QK7ZB5M9WVXR2HD4TNFJ0PC6A",
"from": "no-reply@example.com",
"from_name": "Example",
"subject": "Your verification code",
"preview": "Your code is 481920. It expires in 10 minutes.",
"has_html": false,
"date": "2026-08-29T09:14:02Z",
"seen": false,
"attachments": 0,
"expires_at": "2026-09-03T09:14:02Z"
}
]
}Five fields, and two of them are more interesting than they look:
count- How many messages are in this answer — not how many the mailbox holds. The moment you pass
limit, those are two different numbers. next- The cursor for the page after this one, or
nullwhen there is nothing after it. It is the id of the last message you were just given, which is why paging costs no extra call to discover. messages- The list itself, newest first. Each entry already carries
subject,from,date,seen, a shortpreviewof the text, whether there is an HTML part, and how many attachments there are. alias- A second address that delivers here and gives nothing away about this one. Hand it to a form instead of the real address; whoever ends up with it, typing it into this service, finds an empty mailbox.
address- The address as it was understood, lower-cased and trimmed. Compare it against what you sent if you are building the address out of parts.
Reading past the first fifty
One call answers with at most fifty messages by default and two hundred at the outside. A busy catch-all passes both in an afternoon, and the part readers guess wrong is what comes next — because it is not a page number.
limit- How many to return in this call, 1 to 200. Out-of-range values are clamped rather than refused, so
limit=5000quietly gives you 200. before- The id of the oldest message you already hold. You get the ones after it. Pass back whatever the previous answer put in
next. nextnullmeans you have reached the end of the mailbox. It is the only reliable end-of-list signal: a short page is not one, because a page is only short when the server says so.
limit caps one answer, next names where that answer stopped, and before asks for what lies after it.ADDR="k7fq2m@grabmail.io"
CURSOR=""
while :; do
PAGE=$(curl -fsG https://grabmail.io/api/v1/mailbox \
--data-urlencode "address=$ADDR" \
--data-urlencode "limit=200" \
${CURSOR:+--data-urlencode "before=$CURSOR"})
printf '%s' "$PAGE" | jq -c '.messages[]'
CURSOR=$(printf '%s' "$PAGE" | jq -r '.next // empty')
[ -n "$CURSOR" ] || break
sleep 1
doneLoop while next is not null and you have the whole mailbox, however large it grew. Each call is a range read on an index rather than an offset, so the thousandth page costs what the first one did.
A cursor from another mailbox, or one that has since expired, is not an error: you get an empty page and next: null. That is the right answer — repeating the newest page instead would hand a script mail it had already processed — but it does mean a stale cursor looks exactly like the end of the list.
Opening one message, and when you need not
The id from the listing plus the mailbox it was delivered to gets you the message itself. Both are required: an id that leaked out of one inbox cannot be used to read another, because every lookup is scoped to the address as well.
$ curl -sG https://grabmail.io/api/v1/message/3QK7ZB5M9WVXR2HD4TNFJ0PC6A \
--data-urlencode "mailbox=k7fq2m@grabmail.io"{
"id": "3QK7ZB5M9WVXR2HD4TNFJ0PC6A",
"from": "no-reply@example.com",
"to": "k7fq2m@grabmail.io",
"subject": "Your verification code",
"date": "2026-08-29T09:14:02Z",
"expires_at": "2026-09-03T09:14:02Z",
"text": "Your code is 481920. It expires in 10 minutes.",
"html": null,
"attachments": []
}text- The plain-text part. Parse this one when it is there: it is stable, it carries no markup, and a six-digit code in it is a six-digit code.
html- The HTML part, or
nullwhen the sender did not send one. Confirmation links frequently exist only here. attachments- One entry per file, each with the URL to fetch it already built. An empty list, not
null, when there are none. expires_at- When this message is deleted, in the same RFC 3339 form as
date. Read it rather than computing it — the retention window is not a setting you can be sure of from the outside.
Very often you can skip this call entirely. The listing already returns the subject, the sender, the date and a short preview of the text, which is enough to decide that a message is not the one you are waiting for. Fetching every message in a mailbox to find out you wanted none of them is the most common way a script becomes slow.
Getting a file out
Every attachment carries its own url, and the detail worth knowing before you write the loop is that it is a path on this origin rather than an absolute address — with the mailbox parameter already in it. Put the origin in front, fetch it, and there is nothing else to pass and nothing to authorise.
ADDR="k7fq2m@grabmail.io"
ID="3QK7ZB5M9WVXR2HD4TNFJ0PC6A"
curl -fsG https://grabmail.io/api/v1/message/$ID \
--data-urlencode "mailbox=$ADDR" \
| jq -r '.attachments[] | "\(.url)\t\(.filename)"' \
| while IFS=$'\t' read -r path name; do
curl -fs "https://grabmail.io$path" -o "$name"
doneIt always answers application/octet-stream with Content-Disposition: attachment, whatever the sender labelled the file. That is deliberate — echoing a stranger’s text/html back would let an attachment run as a page on this origin — so a script that cares about the type reads it from the message JSON, where it is data rather than an instruction.
The whole message, files and all, is capped at 5 MB. What that ceiling means once base64 has had its way with a binary is a subject of its own, and there is a guide about attachments for it.
Two rate budgets, not one
This is the part that is worth knowing and is easy to miss: listing an address and reading from it are metered separately, because they are not the same risk. Anyone who knows an address can poll its listing; reading a message requires an id, and there is nothing to guess.
| What you are calling | The budget | What it means in practice |
|---|---|---|
GET /mailbox | One request per second, per address | The intended polling rhythm, and never throttled at that pace. Faster is refused, and would not have helped. |
GET /message/{id}, GET /attachment/{id}, DELETE | Far more generous, per address | Drain a page of messages in a burst without pausing between them. This is why an interface can open a message in the same second a poll ran. |
| Everything, added up | 1200 requests a minute, per client | Twenty addresses polled once a second — comfortably past any real automation, and a stop on one host walking ten thousand addresses. |
Over any of them you get 429 with the wait, in seconds, in the Retry-After header. Honour it rather than backing off by a number you invented: it is the server telling you exactly when it will say yes.
read_box() {
local wait
while :; do
BODY=$(curl -s -D /tmp/gm.h -G https://grabmail.io/api/v1/mailbox \
--data-urlencode "address=$1")
grep -qi '^HTTP/[0-9.]* 429' /tmp/gm.h || { printf '%s' "$BODY"; return 0; }
wait=$(awk 'tolower($1) == "retry-after:" { print $2 + 0 }' /tmp/gm.h)
sleep "${wait:-1}"
done
}How to wait for a message that has not arrived yet — a deadline rather than a retry count, and what to do when it passes — is the subject of the guide on testing verification flows. The loop there is the same loop a scheduled job wants.
Deleting, and the floor under all of it
A message you are finished with can go immediately rather than sitting out its retention window. The call is idempotent: deleting the same id twice answers 200 both times, so a retried request never looks like a failure.
$ curl -s -X DELETE -G https://grabmail.io/api/v1/message/3QK7ZB5M9WVXR2HD4TNFJ0PC6A \
--data-urlencode "mailbox=k7fq2m@grabmail.io"- Delete when you have what you came for
- A script that processes a message and leaves it there will process it again on the next run unless it keeps its own list of what it has seen. Deleting is the cheaper bookkeeping.
- Do not rely on it for privacy
- Between arrival and deletion, anyone who knows the address could have read it. Deleting closes the window; it does not undo it.
- Everything goes at 5 days regardless
- Read or unread, deleted or not, a message is gone 5 days after it arrived. It is a hard limit rather than a setting, and no parameter extends it.
The slugs to branch on, and the field never to read
Every failure is JSON with the same two fields. error is a stable machine-readable slug; message is for humans and may be reworded at any time. Branching on the second is how a script breaks on a day nothing changed.
| Status and slug | What happened | What a script should do |
|---|---|---|
400 invalid_address | The address is missing, or is not shaped like one. | Fail at once. No amount of retrying fixes a typo. |
400 bad_cursor | before is not a message id. | Fail at once, and check that you are passing back next rather than something you built yourself. |
404 unknown_domain | That domain is not hosted here. | Fail at once. On your own domain, this is the MX record — see connecting a domain. |
404 not_found | No such message in that mailbox, or it has passed its retention window. | Treat it as gone. It is also what you get for a valid id read against the wrong mailbox. |
429 rate_limited | One of the budgets above. | Sleep for Retry-After seconds and carry on. Never count it as a failed run. |
A job that drains an address every hour
Put the pieces together and a scheduled job is short. This one takes every message waiting at an address, writes it to disk as JSON, and deletes it — so the next run starts from an empty mailbox and can never process the same message twice.
#!/usr/bin/env bash
set -euo pipefail
ADDR="orders@example.com"
OUT="/var/lib/mailsink"
API="https://grabmail.io/api/v1"
mkdir -p "$OUT"
while :; do
page=$(curl -fsG "$API/mailbox" \
--data-urlencode "address=$ADDR" \
--data-urlencode "limit=200")
ids=$(printf '%s' "$page" | jq -r '.messages[].id')
[ -n "$ids" ] || break
for id in $ids; do
curl -fsG "$API/message/$id" \
--data-urlencode "mailbox=$ADDR" > "$OUT/$id.json"
curl -fs -X DELETE -G "$API/message/$id" \
--data-urlencode "mailbox=$ADDR" > /dev/null
done
sleep 1
done17 * * * * /usr/local/bin/drain.shFour properties are worth naming, because they are what separate a job you can leave running from one you have to watch:
- It is safe to run twice. Two copies started at once do the same work in a different order and delete the same messages; the second one finds an empty mailbox and stops.
- It writes before it deletes. If the disk is full or the process is killed, the message is still in the mailbox on the next run. The other order loses mail on the one day it matters.
- It drains rather than reads. Because each message goes as soon as it is safely on disk, the next listing returns the next two hundred — so a mailbox that took four hundred messages between runs is emptied completely, not down to the newest fifty.
- It fails loudly. A non-zero exit is what makes cron send you the output. A job that swallows its own errors is a job that has been broken for a month.
What this API will not do for you
Four things it does not do, each on purpose and none of them coming later. Better to design around them now than to discover them from a script that has been quietly half-working:
- It never sends
- Receive only. There is no endpoint that puts a message on the wire, which is why nothing here can be used to send from an address you do not own.
- It never pushes
- No webhooks and no callbacks: you ask, it answers. An AI agent that would rather block until the mail lands has
wait_for_messageover MCP instead — see the guide for agents. - It never searches
- There is no query parameter for a sender or a subject. Filtering happens on your side, over the listing — which is one reason the listing carries a preview.
- It never authenticates, on a public domain
- Anyone who knows the address reads the mailbox. The address is the whole of the secret, so treat it like one: never derive it from a customer’s name, and never point anything you would mind reading aloud at a shared domain.
The answer to the last one is a domain of your own. Point its MX at smtp.grabmail.io and every address on it answers on these same three endpoints, with no second API to learn and no key to rotate — and, on request, closed so that only a bearer key opens it. Connecting a domain takes one DNS record.
10 smtp.grabmail.io
Before you leave it running
Six things worth checking on a job that will run without you watching it:
- Poll no faster than once a second per address, and honour
Retry-Afterwhen you are told to wait. - Follow
nextto the end, rather than assuming one call is the whole mailbox. - Branch on the status code and
error, never onmessage. - Write anything you need to keep before you delete it, and remember that 5 days is a floor you cannot move.
- Anchor whatever you extract to your own template. A bare six-digit pattern will happily match a year, a price, or an order number that arrived first.
- Assume the address is public unless it is on a domain you control, and put anything that matters on one that is.
None of it needs an account. If you outgrow the public domains, what changes is the domain in the address — the three calls above stay exactly as they are.
Questions
Do I need an API key?
No. On the public domains there is no account, no token and nothing to register, and a domain you point here answers on the same endpoints with no key either. The single exception is a domain we have closed on request, which is read with an Authorization: Bearer header.
How fast can I poll?
Once a second per address for the listing, which is the intended rhythm and is never throttled. Reading a message or an attachment is metered separately and much more generously, so you can drain a page of messages in a burst. Everything together is capped at 1200 requests a minute per client.
How do I know when I have read the whole mailbox?
When next comes back null. Do not infer it from a short page: the server decides what a page is, and a page shorter than limit is not by itself the end.
Can I call this from a browser?
Yes. Responses carry Access-Control-Allow-Origin: *, so a page on any origin can call the endpoints directly without a proxy of yours in the middle. Authorisation here is never a cookie, so opening it that far costs nothing.
What happens if I ask for a message that has expired?
404 with not_found, exactly as for an id that never existed. Everything is deleted 5 days after it arrives, read or not, and no parameter extends that.
Can I get a webhook when mail arrives?
No — the REST API is ask-and-answer, with no callbacks. If what you want is code that blocks until the message lands, the MCP server has wait_for_message, which does exactly that and is meant for agents.
Is it safe to use a public address in production?
Only for things you would not mind a stranger reading. Anyone who knows the address can read its mailbox, through the API just as through the site. For anything else, point a domain you own here — the calls do not change.
Why does a message I never opened show up as read?
Because something opened it. Reading a message through the API sets its seen flag, and the flag is shared with everyone looking at that address. A script and a person watching the same mailbox will keep surprising each other, so filter on ids you have handled rather than on seen.
Do I have to delete messages?
No — everything expires on its own after 5 days. Deleting is worth doing anyway in a scheduled job, because an emptied mailbox is the simplest possible record of what you have already processed.


