Três chamadas, e nada para configurar
A interface inteira são três endpoints sob https://grabmail.io/api/v1, mais um endereço para anexos que os outros já entregam pronto. Não existe uma chamada de criar caixa de entrada, e essa ausência não é uma omissão: um endereço passa a existir quando um e-mail chega nele, então não sobra nada para uma chamada dessas fazer.
| Chamada | O que ela responde | O que você passa |
|---|---|---|
GET /mailbox | Tudo o que está esperando em um endereço, do mais novo para o mais antigo. | address, e opcionalmente limit e before |
GET /message/{id} | Uma mensagem completa: a parte em texto simples, a parte em HTML, e cada anexo com uma URL já pronta. | mailbox |
DELETE /message/{id} | Remove agora, em vez de esperar a janela de retenção acabar. | mailbox |
GET /attachment/{id} | Os bytes de um arquivo, exatamente como chegaram. | mailbox |
Toda resposta é JSON, inclusive todo erro. Todo horário está em UTC, no formato RFC 3339. Os ids das mensagens são opacos: devolva-os como vieram, nunca tente decifrá-los.
A primeira chamada, e o que um endereço vazio responde
Escolha um nome, coloque um dos domínios públicos atrás dele, e leia. Nada precisa existir antes, e nada é criado só por perguntar.
$ 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"
}
]
}Cinco campos, e dois deles são mais interessantes do que parecem:
count- Quantas mensagens estão nesta resposta — não quantas a caixa de entrada guarda. No momento em que você passa
limit, esses são dois números diferentes. next- O cursor para a página depois desta, ou
nullquando não há nada depois dela. É o id da última mensagem que você acabou de receber, e é por isso que paginar não custa nenhuma chamada extra para descobrir. messages- Cada entrada já traz
subject,from,date,seen, umpreviewcurto do texto, se há uma parte em HTML, e quantos anexos existem. alias- Um segundo endereço que entrega aqui e não revela nada sobre este. Passe-o a um formulário em vez do endereço real; quem quer que acabe com ele e o digite neste serviço encontra uma caixa de entrada vazia.
address- O endereço tal como foi entendido, em minúsculas e sem espaços nas pontas. Compare com o que você enviou se estiver montando o endereço a partir de partes separadas.
Lendo além das primeiras cinquenta
Por padrão, uma chamada responde com no máximo cinquenta mensagens, e duzentas no limite máximo. Um catch-all movimentado ultrapassa os dois em uma tarde, e a parte que costuma ser mal interpretada é o que vem a seguir — porque não é um número de página.
limit- Quantas retornar nesta chamada, de 1 a 200. Valores fora do intervalo são ajustados em vez de recusados, então
limit=5000silenciosamente te dá 200. before- O id da mensagem mais antiga que você já tem. Você recebe as que vêm depois dela. Devolva o que quer que a resposta anterior tenha colocado em
next. nextnullsignifica que você chegou ao fim da caixa de entrada. É o único sinal confiável de fim de lista: uma página curta não é um desses sinais, porque uma página só é curta quando o servidor decide que é.
limit limita uma resposta, next indica onde essa resposta parou, e before pede o que vem depois dali.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
doneRepita enquanto next não for nulo e você terá a caixa de entrada inteira, não importa o quanto ela cresceu. Cada chamada é uma leitura de intervalo sobre um índice, não um offset, então a milésima página custa o mesmo que a primeira.
Um cursor de outra caixa de entrada, ou um que já expirou, não é um erro: você recebe uma página vazia e next: null. Essa é a resposta certa — repetir a página mais nova, em vez disso, entregaria a um script uma mensagem que ele já tinha processado — mas isso também significa que um cursor obsoleto parece exatamente com o fim da lista.
Abrindo uma mensagem, e quando você não precisa
O id da listagem mais a caixa de entrada para a qual a mensagem foi entregue te dão a mensagem em si. Os dois são obrigatórios: um id que vazou de uma caixa de entrada não pode ser usado para ler outra, porque toda busca também é restrita ao endereço.
$ 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- A parte em texto simples. Faça o parsing dela quando ela existir: é estável, não carrega marcação nenhuma, e um código de seis dígitos ali dentro é um código de seis dígitos.
html- A parte em HTML, ou
nullquando o remetente não enviou uma. Links de confirmação frequentemente existem só aqui. attachments- Uma entrada por arquivo, cada uma com a URL para buscá-lo já pronta. Uma lista vazia, não
null, quando não há nenhum. expires_at- Quando esta mensagem será apagada, no mesmo formato RFC 3339 que
date. Leia esse valor em vez de calculá-lo — a janela de retenção não é uma configuração da qual você possa ter certeza de fora.
Muitas vezes você pode pular esta chamada por completo. A listagem já retorna o assunto, o remetente, a data e um preview curto do texto, o que é suficiente para decidir que uma mensagem não é a que você está esperando. Buscar cada mensagem de uma caixa de entrada só para descobrir que você não queria nenhuma delas é o jeito mais comum de um script ficar lento.
Baixando um arquivo
Todo anexo carrega sua própria url, e o detalhe que vale saber antes de escrever o loop é que ela é um caminho nesta origem, e não um endereço absoluto — já com o parâmetro mailbox incluído. Coloque a origem na frente, busque, e não há mais nada para passar nem para autorizar.
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"
doneEla sempre responde application/octet-stream com Content-Disposition: attachment, seja qual for o rótulo que o remetente deu ao arquivo. Isso é proposital — repetir de volta o text/html de um desconhecido deixaria um anexo rodar como página nesta origem — então um script que se importa com o tipo o lê a partir do JSON da mensagem, onde ele é dado, não instrução.
A mensagem inteira, arquivos incluídos, tem um teto de 5 MB. O que esse limite significa depois que o base64 faz a dele com um binário é assunto para outro guia — um guia sobre anexos cobre isso.
Duas cotas de requisições, não uma
Esta é a parte que vale a pena conhecer e que é fácil deixar passar: listar um endereço e ler a partir dele são medidos separadamente, porque não são o mesmo risco. Qualquer um que souber um endereço pode fazer polling da sua listagem; ler uma mensagem exige um id, e não há nada para adivinhar.
| O que você está chamando | A cota | O que isso significa na prática |
|---|---|---|
GET /mailbox | Uma requisição por segundo, por endereço | É o ritmo de polling pretendido, e nunca é reduzido nesse ritmo. Mais rápido do que isso é recusado, e não ajudaria de qualquer forma. |
GET /message/{id}, GET /attachment/{id}, DELETE | Bem mais generosa, por endereço | Esvazie uma página de mensagens em uma rajada, sem pausar entre elas. É por isso que uma interface consegue abrir uma mensagem no mesmo segundo em que um polling rodou. |
| Tudo somado | 1200 requisições por minuto, por cliente | Vinte endereços com polling uma vez por segundo — bem acima de qualquer automação real, e um freio para um único host varrendo dez mil endereços. |
Ao ultrapassar qualquer uma delas você recebe 429 com a espera, em segundos, no cabeçalho Retry-After. Respeite esse valor em vez de recuar por um número inventado por você: é o servidor te dizendo exatamente quando ele vai dizer sim.
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
}Como esperar por uma mensagem que ainda não chegou — um prazo em vez de uma contagem de tentativas, e o que fazer quando ele passa — é o assunto de o guia sobre como testar fluxos de verificação. O loop de lá é o mesmo loop que um job agendado precisa.
Apagando, e o piso sob tudo isso
Uma mensagem com a qual você já terminou pode ir embora na hora, em vez de esperar o fim da sua janela de retenção. A chamada é idempotente: apagar o mesmo id duas vezes responde 200 nas duas vezes, então uma requisição repetida nunca parece uma falha.
$ curl -s -X DELETE -G https://grabmail.io/api/v1/message/3QK7ZB5M9WVXR2HD4TNFJ0PC6A \
--data-urlencode "mailbox=k7fq2m@grabmail.io"- Apague quando já tiver o que veio buscar
- Um script que processa uma mensagem e a deixa lá vai processá-la de novo na próxima execução, a não ser que mantenha sua própria lista do que já viu. Apagar é o controle mais barato.
- Não conte com isso para privacidade
- Entre a chegada e a exclusão, qualquer um que soubesse o endereço poderia tê-la lido. Apagar fecha a janela; não desfaz o que já aconteceu nela.
- Tudo desaparece aos 5 dias de qualquer forma
- Lida ou não, apagada ou não, uma mensagem desaparece 5 dias depois de chegar. É um limite rígido, não uma configuração, e nenhum parâmetro o estende.
Os slugs para decidir o próximo passo, e o campo que você nunca deve ler
Toda falha é JSON com os mesmos dois campos. error é um slug estável e legível por máquina; message é para humanos e pode ser reformulado a qualquer momento. Decidir sua lógica com base no segundo é como um script quebra num dia em que nada mudou.
| Status e slug | O que aconteceu | O que um script deveria fazer |
|---|---|---|
400 invalid_address | O endereço está faltando, ou não tem o formato de um. | Falhe na hora. Nenhuma quantidade de tentativas conserta um erro de digitação. |
400 bad_cursor | before não é um id de mensagem. | Falhe na hora, e confira se você está devolvendo next em vez de algo que você mesmo construiu. |
404 unknown_domain | Esse domínio não é hospedado aqui. | Falhe na hora. No seu próprio domínio, isso é o registro MX — veja conectando um domínio. |
404 not_found | Não existe essa mensagem naquela caixa de entrada, ou ela já passou da janela de retenção. | Trate como algo que já foi embora. É também o que você recebe ao ler um id válido contra a caixa de entrada errada. |
429 rate_limited | Uma das cotas acima. | Durma por Retry-After segundos e continue. Nunca conte isso como uma execução falha. |
Um job que esvazia um endereço a cada hora
Junte as peças e um job agendado fica curto. Este aqui pega cada mensagem esperando em um endereço, grava em disco como JSON, e apaga — então a próxima execução começa de uma caixa de entrada vazia e nunca pode processar a mesma mensagem duas vezes.
#!/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.shQuatro propriedades valem a pena nomear, porque são elas que separam um job que você pode deixar rodando sozinho de um que você precisa ficar vigiando:
- É seguro rodar duas vezes. Duas cópias iniciadas ao mesmo tempo fazem o mesmo trabalho em uma ordem diferente e apagam as mesmas mensagens; a segunda encontra uma caixa de entrada vazia e para.
- Ele grava antes de apagar. Se o disco estiver cheio ou o processo for morto, a mensagem ainda está na caixa de entrada na próxima execução. A ordem contrária perde e-mail justamente no dia em que isso importa.
- Ele esvazia, em vez de só ler. Como cada mensagem vai embora assim que está gravada com segurança em disco, a próxima listagem retorna as duzentas seguintes — então uma caixa de entrada que recebeu quatrocentas mensagens entre uma execução e outra é esvaziada por completo, não só até as cinquenta mais novas.
- Ele falha de forma barulhenta. Um exit diferente de zero é o que faz o cron te enviar a saída. Um job que engole os próprios erros é um job que está quebrado há um mês.
O que esta API não vai fazer por você
Quatro coisas que ela não faz, cada uma de propósito, e nenhuma delas chegando depois. Melhor projetar em volta disso agora do que descobrir por meio de um script que estava funcionando pela metade em silêncio:
- Ela nunca envia
- Só recebe. Não existe endpoint que coloque uma mensagem na rede, e é por isso que nada aqui pode ser usado para enviar a partir de um endereço que você não possui.
- Ela nunca empurra
- Sem webhooks e sem callbacks: você pergunta, ela responde. Um agente de IA que preferisse bloquear até o e-mail chegar tem
wait_for_messagevia MCP para isso — veja o guia para agentes. - Ela nunca busca
- Não existe parâmetro de consulta para um remetente ou um assunto. A filtragem acontece do seu lado, sobre a listagem — uma das razões pelas quais a listagem traz um preview.
- Ela nunca autentica, em um domínio público
- Qualquer um que souber o endereço lê a caixa de entrada. O endereço é o segredo inteiro, então trate-o como um: nunca o derive do nome de um cliente, e nunca aponte para um domínio compartilhado nada que você se importasse de ver lido em voz alta.
A resposta para o último ponto é um domínio seu. Aponte o MX dele para smtp.grabmail.io e todo endereço nele responde nesses mesmos três endpoints, sem uma segunda API para aprender e sem chave para rotacionar — e, sob pedido, fechado, de forma que só uma chave bearer o abre. Conectar um domínio leva um único registro DNS.
10 smtp.grabmail.io
Antes de deixar isso rodando
Seis coisas que vale a pena conferir em um job que vai rodar sem que você fique de olho:
- Não faça polling mais rápido do que uma vez por segundo por endereço, e respeite
Retry-Afterquando disserem para você esperar. - Siga
nextaté o fim, em vez de presumir que uma chamada é a caixa de entrada inteira. - Decida com base no código de status e em
error, nunca emmessage. - Grave tudo que você precisa manter antes de apagar, e lembre-se de que 5 dias é um piso que você não pode mover.
- Ancore o que você extrai ao seu próprio modelo. Um padrão nu de seis dígitos vai combinar alegremente com um ano, um preço, ou um número de pedido que chegou antes.
- Presuma que o endereço é público, a não ser que esteja em um domínio que você controla, e coloque tudo o que importa em um que esteja.
Nada disso precisa de conta. Se você crescer além dos domínios públicos, o que muda é o domínio no endereço — as três chamadas acima continuam exatamente como estão.
Perguntas
Preciso de uma chave de API?
Não. Nos domínios públicos não há conta, não há token e nada para cadastrar, e um domínio que você aponta para cá responde nos mesmos endpoints, também sem chave. A única exceção é um domínio que fechamos a pedido, que é lido com um cabeçalho Authorization: Bearer.
Com que velocidade posso fazer polling?
Uma vez por segundo por endereço para a listagem, que é o ritmo pretendido e nunca é reduzido. Ler uma mensagem ou um anexo é medido separadamente, e de forma bem mais generosa, então você pode esvaziar uma página de mensagens em uma rajada. Tudo somado tem um teto de 1200 requisições por minuto por cliente.
Como sei quando li a caixa de entrada inteira?
Quando next volta null. Não deduza isso a partir de uma página curta: o servidor decide o que é uma página, e uma página menor do que limit não é, por si só, o fim.
Posso chamar isso a partir de um navegador?
Sim. As respostas trazem Access-Control-Allow-Origin: *, então uma página em qualquer origem pode chamar os endpoints diretamente, sem nenhum proxy seu no meio do caminho. A autorização aqui nunca é um cookie, então abrir até esse ponto não custa nada.
O que acontece se eu pedir uma mensagem que já expirou?
404 com not_found, exatamente como para um id que nunca existiu. Tudo é apagado 5 dias depois de chegar, lido ou não, e nenhum parâmetro estende esse prazo.
Posso receber um webhook quando um e-mail chegar?
Não — a API REST é de pergunta e resposta, sem callbacks. Se o que você quer é um código que bloqueia até a mensagem chegar, o servidor MCP tem wait_for_message, que faz exatamente isso e é pensado para agentes.
É seguro usar um endereço público em produção?
Só para coisas que não te incomodariam se um desconhecido lesse. Qualquer um que souber o endereço pode ler sua caixa de entrada, pela API assim como pelo site. Para qualquer outra coisa, aponte para cá um domínio que você possui — as chamadas não mudam.
Por que uma mensagem que eu nunca abri aparece como lida?
Porque alguma coisa a abriu. Ler uma mensagem pela API define sua flag seen, e essa flag é compartilhada com todo mundo que está olhando aquele endereço. Um script e uma pessoa observando a mesma caixa de entrada vão continuar se surpreendendo um ao outro, então filtre pelos ids que você já tratou, não por seen.
Eu preciso apagar as mensagens?
Não — tudo expira sozinho depois de 5 dias. Ainda assim vale a pena apagar em um job agendado, porque uma caixa de entrada vazia é o registro mais simples possível do que você já processou.


