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 | 60 requisições por minuto e 1.000 por dia por endereço IP sem chave; sem limite com uma chave paga | Um endereço consultado uma vez por segundo cabe na cota gratuita por minuto. Muitos endereços ao mesmo tempo, ou um pipeline rodando o dia inteiro: é para isso que existe a chave paga. |
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 previsto. Ler uma mensagem ou um anexo é medido separadamente, e de forma mais generosa, então você pode esvaziar uma página de mensagens em uma rajada. Sem chave, tudo somado tem um teto de 60 requisições por minuto e 1.000 por dia por endereço IP; uma chave paga remove os dois tetos.
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.


