Developers
Poofy API
Give your scripts and AI agents their own disposable inboxes. Create an address, sign up somewhere, and get the verification code back, without a human in the loop.
Last updated
Quick start
You need an API key, and it's free: open your inbox page and choose API for AI agents in the sidebar. A key looks like pfy_sk_… and is shown once. Free keys have small, hard limits (below); Premium raises them.
export POOFY_KEY=pfy_sk_xxxxxxxx
# 1. Make an inbox that deletes itself in an hour
curl -s -X POST https://poofy.email/api/v1/inboxes \
-H "Authorization: Bearer $POOFY_KEY" -H "Content-Type: application/json" \
-d '{"ttl_minutes": 60, "label": "signup test"}'
# 2. Use the address to sign up somewhere, then wait for the code
curl -s "https://poofy.email/api/v1/inboxes/INBOX_ID/wait?timeout=30&code=1" \
-H "Authorization: Bearer $POOFY_KEY"
A typical agent run: create an inbox, fill in a sign-up form with the address, call wait with code=1, enter the code. Machine-readable: openapi.json.
Safe by design for agents
- Receive-only. These inboxes cannot send mail, so an agent can't be tricked into spamming anyone.
- Email is untrusted input. Mail text is the easiest way to attack an agent (prompt injection). Every message is marked
"untrusted": true. If you pass mail to a language model, wrap it so the model knows it is data, and prefer thecodeandlinkfields over the body whenever that is all you need. - They clean up after themselves. Inboxes delete themselves when their
ttl_minutesis up, with their mail. On the Free plan that's always within 24 hours. - Scoped keys. A key sees only its own inboxes. Make it read-only, give it an expiry, or lock it to your server's IP addresses. Revoke it any time and it stops at once.
Keys
Create keys in the app (sidebar, API for AI agents). Each key has:
- Access: Full (create and delete inboxes, manage webhooks) or Read-only (list, read and wait only). Read-only keys get
403 read_only_keyon anything that changes things. - Expiry: never, or 7, 30 or 90 days. An expired key answers
401. - Allowed IPs: up to 10 addresses or ranges (for example
203.0.113.7or10.0.0.0/8). From any other address the key answers403 ip_not_allowed.
Send the key on every request as Authorization: Bearer pfy_sk_…. Keys are stored only as hashes, so a lost key can't be recovered: revoke it and make a new one.
Endpoints
Base URL: https://poofy.email/api/v1. Responses are JSON.
| Method and path | Purpose |
|---|---|
GET /me |
Your plan, inbox count and the limits that apply to this key. |
GET /domains |
Domains you can use: free, premium and own (your connected domains). |
GET /inboxes |
List inboxes. |
POST /inboxes |
Create one. Body (all optional): name, domain, label, style (name, words, random), ttl_minutes. Returns 201 with inbox and password. |
DELETE /inboxes/{id} |
Delete an inbox and its mail. |
GET /inboxes/{id}/messages?limit=20 |
List messages (newest first). |
GET /inboxes/{id}/wait |
Long-poll for the next unread message. Query: timeout (1 to 55 seconds), from, subject_contains, code=1 (only messages with a code or link). |
GET /messages/{id} |
One message in full: text, links, authentication results. |
GET /webhooks, POST /webhooks, DELETE /webhooks/{id} |
Manage webhooks (below). |
POST /webhooks/{id}/test, GET /webhooks/{id}/deliveries |
Send a test event; see recent deliveries. |
Waiting for mail
wait works like a queue. It returns the oldest unread matching message and marks it read, so each message is handed out once and nothing that arrived before you asked is missed. If nothing arrives in time you get {"message": null, "timed_out": true}. Use wait (one request) rather than polling messages in a loop, or use a webhook.
A message
{
"id": "…", "inbox_id": "…",
"from": { "name": "Acme", "address": "[email protected]" },
"subject": "Your verification code",
"received_at": "2026-10-10T12:00:00.000Z",
"code": "482913",
"link": "https://acme.example/verify?t=…",
"links": ["https://acme.example/verify?t=…"],
"text": "Your code is 482913 …",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },
"untrusted": true
}
Webhooks
Instead of waiting or polling, give Poofy a URL and it calls you when mail arrives.
curl -s -X POST https://poofy.email/api/v1/webhooks \
-H "Authorization: Bearer $POOFY_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://example.com/poofy-hook"}'
The response contains a secret (starts with whsec_), shown once. A webhook belongs to the key that created it and fires for the inboxes that key can reach. The URL must be https, and addresses on private networks are refused.
What we send
A POST with a JSON body. It carries the sender, subject, preview, code and link, but never the body: fetch that with GET /messages/{id} if you need it.
{
"id": "evt-id",
"type": "message.received",
"created_at": "2026-10-10T12:00:00.000Z",
"data": {
"inbox": { "id": "…", "address": "[email protected]" },
"message": {
"id": "…", "from": { "name": "Acme", "address": "[email protected]" },
"subject": "Your verification code", "snippet": "Your code is 482913",
"code": "482913", "link": "https://acme.example/verify?t=…",
"received_at": "2026-10-10T12:00:00.000Z", "untrusted": true
}
}
}
Headers: Poofy-Event, Poofy-Delivery (a unique id, useful to ignore duplicates) and Poofy-Signature.
Verify the signature
Poofy-Signature looks like t=1760097600,v1=…. The v1 value is the hex HMAC-SHA256, using your webhook secret, of the timestamp, a dot, and the raw request body. Reject anything that doesn't match, or whose timestamp is more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
const expected = createHmac("sha256", secret).update(parts.t + "." + rawBody).digest("hex")
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
return fresh && expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}
import hmac, hashlib, time
def verify(secret, header, raw_body):
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), (parts["t"] + "." + raw_body).encode(), hashlib.sha256).hexdigest()
return abs(time.time() - int(parts["t"])) < 300 and hmac.compare_digest(expected, parts["v1"])
Delivery and retries
Answer with any 2xx within 8 seconds. Otherwise Poofy retries after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then gives up on that event. After 15 failures in a row the webhook switches itself off; send a test event (POST /webhooks/{id}/test) to turn it back on. Redirects are not followed. GET /webhooks/{id}/deliveries shows what happened to recent events.
Your own domains
With Premium you can connect your own domain in the app (sidebar, Your domains), then create inboxes on it with any name, including ones reserved on shared domains such as admin or support. Pass it as domain when creating an inbox, and list yours with GET /domains. Use a subdomain nobody else uses, like inbox.yourdomain.com: pointing a name's MX records at Poofy replaces any mail service on that exact name. Your own domain keeps your reputation separate from everyone else's, which matters if your agents create a lot of inboxes.
Errors
Errors look like {"error": {"code": "not_found", "message": "…"}} with a matching HTTP status: 400 bad request, 401 missing, invalid or expired key, 403 not allowed (including read_only_key and ip_not_allowed), 404 not found, 409 address taken, 429 rate limited.
Limits
| Free | Premium | |
|---|---|---|
| API keys | 1 | 5 |
| New inboxes (the daily limit) | 20 a day | 200 a day |
| Other calls (read, wait, list, webhooks) | 20 a minute | 60 a minute |
| Safety ceiling on all calls | 1,000 a day | 10,000 a day |
| An account's keys together | n/a | 2x one key's limit |
| Live inboxes at once | 10 | up to 5,000 |
| Inbox lifetime | Always self-delete within 24 hours | Up to 30 days, or keep until deleted |
| Waits at the same time | 1 | 3 |
| Webhooks per key, deliveries a day | 1, 100 | 5, 5,000 |
| Storage | 250 MB, mail kept 30 days | 5 GB |
A 429 response includes Retry-After in seconds. Wrong keys are limited per IP address too. Free keys are for trying things out and small projects, and their limits are hard. Limits can change as Poofy grows; GET /me always shows what applies to your key.
Privacy and fair use
Poofy doesn't read your agents' mail. See the privacy policy for what is stored and for how long, and the acceptable use policy: using inboxes for spam, fraud, or mass account creation to abuse other services ends your access. Service status is on the status page.