poofy.email

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 the code and link fields over the body whenever that is all you need.
  • They clean up after themselves. Inboxes delete themselves when their ttl_minutes is 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_key on 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.7 or 10.0.0.0/8). From any other address the key answers 403 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.