Tres llamadas, y nada que preparar
Toda la interfaz son tres endpoints bajo https://grabmail.io/api/v1, más una dirección para adjuntos que las demás te entregan ya construida. No existe una llamada crear un buzón, y su ausencia no es un descuido: una dirección empieza a existir cuando le llega correo, así que no hay nada que esa llamada pudiera hacer.
| Llamada | Qué responde | Qué pasas |
|---|---|---|
GET /mailbox | Todo lo que espera en una dirección, lo más reciente primero. | address, y opcionalmente limit y before |
GET /message/{id} | Un mensaje completo: la parte de texto plano, la parte HTML, y cada adjunto con una URL ya construida. | mailbox |
DELETE /message/{id} | Lo elimina ahora en lugar de esperar a que termine la ventana de retención. | mailbox |
GET /attachment/{id} | Los bytes de un archivo, exactamente como llegaron. | mailbox |
Toda respuesta es JSON, incluido cada error. Toda fecha está en UTC y en formato RFC 3339. Los ids de mensaje son opacos: devuélvelos tal cual, nunca los descompongas.
La primera llamada, y qué responde una dirección vacía
Elige un nombre, ponle detrás uno de los dominios públicos, y léelo. No hace falta que nada exista antes, y preguntar no crea nada.
$ 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, y dos de ellos son más interesantes de lo que parecen:
count- Cuántos mensajes hay en esta respuesta — no cuántos contiene el buzón. En cuanto pasas
limit, son dos números distintos. next- El cursor para la página siguiente a esta, o
nullcuando no hay nada después. Es el id del último mensaje que acabas de recibir, por lo que paginar no cuesta ninguna llamada adicional para averiguarlo. messages- La lista en sí, lo más reciente primero. Cada entrada ya lleva
subject,from,date,seen, un brevepreviewdel texto, si hay una parte HTML, y cuántos adjuntos tiene. alias- Una segunda dirección que entrega aquí y no revela nada sobre esta. Dásela a un formulario en lugar de la dirección real; quien acabe con ella, al escribirla en este servicio, encuentra un buzón vacío.
address- La dirección tal como se interpretó, en minúsculas y sin espacios sobrantes. Compárala con lo que enviaste si estás construyendo la dirección a partir de partes.
Leer más allá de los primeros cincuenta
Una llamada responde por defecto con un máximo de cincuenta mensajes, y doscientos como tope absoluto. Un catch-all con tráfico intenso supera ambas cifras en una tarde, y la parte que los lectores suelen adivinar mal es lo que viene después — porque no es un número de página.
limit- Cuántos devolver en esta llamada, de 1 a 200. Los valores fuera de rango se recortan en lugar de rechazarse, así que
limit=5000te da 200 sin más. before- El id del mensaje más antiguo que ya tienes. Recibes los que vienen después de él. Devuelve lo que la respuesta anterior puso en
next. nextnullsignifica que has llegado al final del buzón. Es la única señal fiable de fin de lista: una página corta no lo es, porque una página solo es corta cuando el servidor decide que lo sea.
limit limita una respuesta, next señala dónde se detuvo esa respuesta, y before pide lo que queda después de ese punto.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
doneRepite mientras next no sea null y tendrás el buzón completo, por grande que haya crecido. Cada llamada es una lectura por rango sobre un índice y no sobre un desplazamiento, así que la página mil cuesta lo mismo que la primera.
Un cursor de otro buzón, o uno que ya ha caducado, no es un error: obtienes una página vacía y next: null. Es la respuesta correcta — repetir la página más reciente en su lugar le daría a un script correo que ya había procesado — pero sí significa que un cursor obsoleto tiene exactamente el mismo aspecto que el final de la lista.
Abrir un mensaje, y cuándo no hace falta
El id del listado más el buzón al que se entregó te dan el mensaje en sí. Los dos son obligatorios: un id que se ha filtrado de un buzón no sirve para leer otro, porque toda consulta también está limitada a la dirección.
$ 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- La parte de texto plano. Analiza esta cuando esté presente: es estable, no lleva marcado, y un código de seis dígitos ahí dentro es un código de seis dígitos.
html- La parte HTML, o
nullcuando el remitente no envió ninguna. Los enlaces de confirmación a menudo solo existen aquí. attachments- Una entrada por archivo, cada una con la URL para descargarlo ya construida. Una lista vacía, no
null, cuando no hay ninguno. expires_at- Cuándo se elimina este mensaje, en el mismo formato RFC 3339 que
date. Léelo en lugar de calcularlo — la ventana de retención no es un ajuste del que puedas estar seguro desde fuera.
Con mucha frecuencia puedes saltarte esta llamada por completo. El listado ya devuelve el asunto, el remitente, la fecha y una breve vista previa del texto, lo cual basta para decidir que un mensaje no es el que estás esperando. Descargar cada mensaje de un buzón para descubrir que no querías ninguno de ellos es la forma más común en que un script se vuelve lento.
Sacar un archivo
Cada adjunto lleva su propia url, y el detalle que conviene saber antes de escribir el bucle es que es una ruta en este origen y no una dirección absoluta — con el parámetro mailbox ya incluido en ella. Antepón el origen, descárgala, y no hay nada más que pasar ni nada que 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"
doneSiempre responde con application/octet-stream y Content-Disposition: attachment, sea cual sea la etiqueta que le puso el remitente al archivo. Es intencionado — devolver el text/html de un desconocido dejaría que un adjunto se ejecutara como una página en este origen — así que un script al que le importe el tipo lo lee del JSON del mensaje, donde es un dato y no una instrucción.
El mensaje completo, archivos incluidos, tiene un límite de 5 MB. Lo que significa ese techo una vez que base64 ha hecho lo suyo con un binario es un tema aparte, y hay una guía sobre adjuntos que lo trata.
Dos cuotas de solicitudes, no una
Esta es la parte que conviene conocer y que es fácil pasar por alto: listar una dirección y leer de ella se miden por separado, porque no son el mismo riesgo. Cualquiera que conozca una dirección puede sondear su listado; leer un mensaje exige un id, y ahí no hay nada que adivinar.
| Qué estás llamando | La cuota | Qué significa en la práctica |
|---|---|---|
GET /mailbox | Una solicitud por segundo, por dirección | El ritmo de sondeo previsto, y nunca se limita a ese ritmo. Ir más rápido se rechaza, y tampoco habría ayudado. |
GET /message/{id}, GET /attachment/{id}, DELETE | Mucho más generosa, por dirección | Vacía una página de mensajes de golpe sin pausas entre ellos. Por eso una interfaz puede abrir un mensaje en el mismo segundo en que se ejecutó un sondeo. |
| Todo, sumado | 1200 solicitudes por minuto, por cliente | Veinte direcciones sondeadas una vez por segundo — muy por encima de cualquier automatización real, y un freno para un host que recorra diez mil direcciones. |
Al superar cualquiera de ellas obtienes 429 con la espera, en segundos, en la cabecera Retry-After. Respétala en lugar de esperar un número que te has inventado: es el servidor diciéndote exactamente cuándo va a decir que sí.
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
}Cómo esperar un mensaje que todavía no ha llegado — un plazo límite en lugar de un número de reintentos, y qué hacer cuando se cumple — es el tema de la guía sobre cómo probar flujos de verificación. El bucle que hay ahí es el mismo bucle que necesita un trabajo programado.
Eliminar, y el suelo que hay bajo todo esto
Un mensaje con el que ya has terminado puede desaparecer de inmediato en lugar de agotar su ventana de retención. La llamada es idempotente: eliminar el mismo id dos veces responde 200 las dos veces, así que una solicitud repetida nunca parece un fallo.
$ curl -s -X DELETE -G https://grabmail.io/api/v1/message/3QK7ZB5M9WVXR2HD4TNFJ0PC6A \
--data-urlencode "mailbox=k7fq2m@grabmail.io"- Elimina en cuanto tengas lo que buscabas
- Un script que procesa un mensaje y lo deja ahí lo procesará otra vez en la siguiente ejecución, a menos que mantenga su propia lista de lo que ya ha visto. Eliminar es la contabilidad más barata.
- No cuentes con ello para la privacidad
- Entre la llegada y la eliminación, cualquiera que conociera la dirección podría haberlo leído. Eliminar cierra la ventana; no deshace lo ya ocurrido.
- Todo desaparece a los 5 días, pase lo que pase
- Leído o sin leer, eliminado o no, un mensaje desaparece 5 días después de haber llegado. Es un límite estricto y no un ajuste, y ningún parámetro lo amplía.
Los slugs sobre los que ramificar, y el campo que nunca hay que leer
Todo fallo es JSON con los mismos dos campos. error es un slug estable y legible por máquina; message es para personas y puede reformularse en cualquier momento. Ramificar sobre el segundo es cómo un script se rompe un día en que nada cambió.
| Estado y slug | Qué pasó | Qué debería hacer un script |
|---|---|---|
400 invalid_address | Falta la dirección, o no tiene la forma de una. | Falla de inmediato. Ninguna cantidad de reintentos arregla una errata. |
400 bad_cursor | before no es un id de mensaje. | Falla de inmediato, y comprueba que estás devolviendo next y no algo que hayas construido tú mismo. |
404 unknown_domain | Ese dominio no está alojado aquí. | Falla de inmediato. En tu propio dominio, esto es el registro MX — consulta cómo conectar un dominio. |
404 not_found | No existe ese mensaje en ese buzón, o ya ha pasado su ventana de retención. | Trátalo como desaparecido. Es también lo que obtienes al leer un id válido contra el buzón equivocado. |
429 rate_limited | Una de las cuotas anteriores. | Duerme durante los segundos de Retry-After y continúa. Nunca lo cuentes como una ejecución fallida. |
Un trabajo que vacía una dirección cada hora
Junta las piezas y un trabajo programado resulta corto. Este toma todos los mensajes que esperan en una dirección, los escribe en disco como JSON, y los elimina — así la siguiente ejecución empieza desde un buzón vacío y nunca puede procesar el mismo mensaje dos veces.
#!/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.shMerece la pena nombrar cuatro propiedades, porque son las que separan un trabajo que puedes dejar corriendo solo de uno que tienes que vigilar:
- Es seguro ejecutarlo dos veces. Dos copias iniciadas a la vez hacen el mismo trabajo en un orden distinto y eliminan los mismos mensajes; la segunda encuentra un buzón vacío y se detiene.
- Escribe antes de eliminar. Si el disco está lleno o el proceso se mata, el mensaje sigue en el buzón en la siguiente ejecución. El orden contrario pierde correo justo el día que importa.
- Vacía en lugar de leer. Como cada mensaje desaparece en cuanto está a salvo en disco, el siguiente listado devuelve los doscientos siguientes — así que un buzón que recibió cuatrocientos mensajes entre ejecuciones queda completamente vacío, no reducido a los cincuenta más recientes.
- Falla de forma ruidosa. Una salida distinta de cero es lo que hace que cron te envíe la salida. Un trabajo que se traga sus propios errores es un trabajo que lleva un mes roto.
Lo que esta API no hará por ti
Cuatro cosas que no hace, cada una a propósito y ninguna de ellas pendiente de llegar más adelante. Mejor diseñar en torno a ellas ahora que descubrirlas a través de un script que llevaba tiempo funcionando a medias sin que nadie lo notara:
- Nunca envía
- Solo recibe. No hay ningún endpoint que ponga un mensaje en la red, por lo que nada de esto puede usarse para enviar desde una dirección que no te pertenece.
- Nunca empuja
- Sin webhooks y sin callbacks: tú preguntas, ella responde. Un agente de IA que prefiera bloquearse hasta que llegue el correo tiene en su lugar
wait_for_messagepor MCP — consulta la guía para agentes. - Nunca busca
- No hay ningún parámetro de consulta para un remitente o un asunto. El filtrado ocurre de tu lado, sobre el listado — una de las razones por las que el listado lleva una vista previa.
- Nunca autentica, en un dominio público
- Cualquiera que conozca la dirección lee el buzón. La dirección es todo el secreto que hay, así que trátala como tal: nunca la derives del nombre de un cliente, y nunca dirijas a un dominio compartido nada que te importaría ver leído en voz alta.
La respuesta a esta última es un dominio propio. Apunta su MX a smtp.grabmail.io y cada dirección de ese dominio responde en estos mismos tres endpoints, sin una segunda API que aprender ni una clave que rotar — y, si lo pides, cerrado para que solo una clave bearer lo abra. Conectar un dominio lleva un solo registro DNS.
10 smtp.grabmail.io
Antes de dejarlo funcionando solo
Seis cosas que merece la pena comprobar en un trabajo que va a funcionar sin que tú lo vigiles:
- No sondees más rápido de una vez por segundo por dirección, y respeta
Retry-Aftercuando te digan que esperes. - Sigue
nexthasta el final, en lugar de asumir que una sola llamada es todo el buzón. - Ramifica según el código de estado y
error, nunca segúnmessage. - Escribe todo lo que necesites conservar antes de eliminarlo, y recuerda que 5 días es un suelo que no puedes mover.
- Ancla lo que extraigas a tu propia plantilla. Un patrón desnudo de seis dígitos coincidirá encantado con un año, un precio, o un número de pedido que haya llegado antes.
- Da por hecho que la dirección es pública a menos que esté en un dominio que controlas, y pon todo lo que importe en uno que lo sea.
Nada de esto necesita una cuenta. Si te quedas corto con los dominios públicos, lo que cambia es el dominio en la dirección — las tres llamadas de arriba siguen exactamente igual.
Preguntas
¿Necesito una clave de API?
No. En los dominios públicos no hay cuenta, ni token, ni nada que registrar, y un dominio que apuntes aquí responde en los mismos endpoints tampoco con clave. La única excepción es un dominio que hayamos cerrado a petición, que se lee con una cabecera Authorization: Bearer.
¿Con qué rapidez puedo sondear?
Una vez por segundo por dirección para el listado, que es el ritmo previsto y nunca se limita. Leer un mensaje o un adjunto se mide por separado y de forma mucho más generosa, así que puedes vaciar una página de mensajes de golpe. Todo junto tiene un tope de 1200 solicitudes por minuto por cliente.
¿Cómo sé cuándo he leído todo el buzón?
Cuando next vuelve como null. No lo deduzcas de una página corta: el servidor decide qué es una página, y una página más corta que limit no es por sí sola el final.
¿Puedo llamar a esto desde un navegador?
Sí. Las respuestas llevan Access-Control-Allow-Origin: *, así que una página de cualquier origen puede llamar a los endpoints directamente sin ningún proxy propio de por medio. Aquí la autorización nunca es una cookie, así que abrirlo tanto no cuesta nada.
¿Qué pasa si pido un mensaje que ha caducado?
404 con not_found, exactamente igual que para un id que nunca existió. Todo se elimina 5 días después de llegar, leído o no, y ningún parámetro lo amplía.
¿Puedo recibir un webhook cuando llega correo?
No — la API REST es de pregunta y respuesta, sin callbacks. Si lo que quieres es código que se bloquee hasta que llegue el mensaje, el servidor MCP tiene wait_for_message, que hace exactamente eso y está pensado para agentes.
¿Es seguro usar una dirección pública en producción?
Solo para cosas que no te importaría que leyera un desconocido. Cualquiera que conozca la dirección puede leer su buzón, a través de la API igual que a través del sitio. Para cualquier otra cosa, apunta aquí un dominio propio — las llamadas no cambian.
¿Por qué aparece como leído un mensaje que nunca abrí?
Porque algo lo abrió. Leer un mensaje a través de la API activa su indicador seen, y ese indicador se comparte con cualquiera que mire esa dirección. Un script y una persona que vigilan el mismo buzón se seguirán sorprendiendo el uno al otro, así que filtra por ids que ya hayas procesado en lugar de por seen.
¿Tengo que eliminar los mensajes?
No — todo caduca solo después de 5 días. Aun así, merece la pena eliminar en un trabajo programado, porque un buzón vacío es el registro más sencillo posible de lo que ya has procesado.


