API & automation

Disposable email API: automate an inbox from a script

Three endpoints, no key and no account: enough for a script to open an address, read what lands on it and tidy up behind itself. Here is the whole loop — the two rate budgets that decide how fast you may go, and the one deadline you cannot move.

  • Intermediate
  • 22 min read
A grey conveyor belt driven by a blue cog, carrying two blue envelopes towards an open grey tray

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.

CallWhat it answersWhat you pass
GET /mailboxEverything 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.

shell
$ curl -sG https://grabmail.io/api/v1/mailbox \
  --data-urlencode "address=k7fq2m@grabmail.io"
response
{
  "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 null when 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 short preview of 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=5000 quietly 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.
next
null means 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.
what one call returnedwhat before= brings backnextnewestoldestNot a page number — a position in an ordered list.
limit caps one answer, next names where that answer stopped, and before asks for what lies after it.
walk a whole mailbox, oldest page last
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
done

Loop 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.

shell
$ curl -sG https://grabmail.io/api/v1/message/3QK7ZB5M9WVXR2HD4TNFJ0PC6A \
  --data-urlencode "mailbox=k7fq2m@grabmail.io"
response
{
  "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 null when 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.

shell
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"
  done

It 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 callingThe budgetWhat it means in practice
GET /mailboxOne request per second, per addressThe intended polling rhythm, and never throttled at that pace. Faster is refused, and would not have helped.
GET /message/{id}, GET /attachment/{id}, DELETEFar more generous, per addressDrain 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 up1200 requests a minute, per clientTwenty 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.

sleep for as long as you were asked to, and no longer
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.

shell
$ 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 slugWhat happenedWhat a script should do
400 invalid_addressThe address is missing, or is not shaped like one.Fail at once. No amount of retrying fixes a typo.
400 bad_cursorbefore is not a message id.Fail at once, and check that you are passing back next rather than something you built yourself.
404 unknown_domainThat domain is not hosted here.Fail at once. On your own domain, this is the MX record — see connecting a domain.
404 not_foundNo 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_limitedOne 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.

drain.sh
#!/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
done
crontab
17 * * * * /usr/local/bin/drain.sh

Four properties are worth naming, because they are what separate a job you can leave running from one you have to watch:

  1. 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.
  2. 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.
  3. 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.
  4. 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_message over 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:

  1. Poll no faster than once a second per address, and honour Retry-After when you are told to wait.
  2. Follow next to the end, rather than assuming one call is the whole mailbox.
  3. Branch on the status code and error, never on message.
  4. Write anything you need to keep before you delete it, and remember that 5 days is a floor you cannot move.
  5. 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.
  6. 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.

Try it while it is fresh

An address takes one click, no account and no card. Everything in this guide works on it straight away.

Welcome back

Your inboxes and your domains, in one place.