API documentation
Free Domain Mail’s REST API gives your test suite a receive-only inbox on a domain you own. Use it to automate QA, sign-up flows, and OTP/verification-link tests without touching a real mailbox. The service never sends mail — it only receives and lets you read, wait for, and delete test messages.
1. Overview
Base URL for every request:
https://freedomainmail.com/api/v1All requests and responses are JSON. There is no bulk address-generation endpoint in this API — the address generator runs only in your browser, so every address you use here must already exist on one of your domains.
Received mail is kept for 30 days by default, then expires automatically.
The first custom domain on every account is free, with exactly the same features as a paid domain (accepting_mail becomes true once its TXT and MX records are verified). Deleting a domain does not give the free slot back.
2. Authentication
Every request must carry an Authorization header with a bearer key:
Authorization: Bearer fdm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxCreate a key from Manage → API in your dashboard. The full key is shown exactly once at creation time; only its hash is stored afterwards, so save it somewhere safe. A missing or invalid key returns 401 with code unauthorized.
3. Rate limits
- 60 requests per minute per key by default. Once exceeded, you get
429with coderate_limitedand aRetry-Afterheader telling you how many seconds to wait. - 5 concurrent
waitrequests per key at most. A sixth concurrent long-poll returns429with codetoo_many_waits. - Failed authentication is counted per IP address. After 20 failed attempts in one minute, every request from that IP (valid keys included) gets
429rate_limiteduntil the one-minute window resets.
4. Filters (shared)
The listing, code, and link endpoints share the same query parameters. /code and /links always return the single latest match, so they ignore limit and cursor.
| Parameter | Meaning |
|---|---|
address | Full recipient address, e.g. qa-user@yourdomain.com. |
domain | Restrict results to one of your domains. |
from | A full address matches exactly. A bare domain matches that domain and its subdomains: from=facebookmail.com matches mail from security@facebookmail.com, but from=facebook.com does not match @facebookmail.com. |
subject | Case-insensitive substring match against the subject line. |
q | Case-insensitive substring search over the subject, sender address, sender name and recipient. |
since | ISO 8601 timestamp, or Unix time in seconds or milliseconds. Only messages received at or after this time are returned. It is compared to the server time when the message was received, so allow for clock skew on your side. |
limit | Page size, up to 100. Defaults to 50. Listing only. |
cursor | Pass back the next_cursor from a previous response to get the next page. Listing only. |
5. Endpoints
GET /api/v1/domains
Lists the domains on your account and whether each is currently accepting mail.
{
"domains": [
{
"domain": "yourdomain.com",
"dns_status": "verified",
"mx_verified": true,
"accepting_mail": true,
"created_at": "2026-08-01T09:00:00.000Z"
}
]
}GET /api/v1/messages
Lists messages across your domains, newest first, filtered with the shared parameters above.
{
"messages": [
{
"id": "8f6c9e2a-2a3b-4b7a-9b0e-2c1f3a9d7e11",
"recipient": "qa-user@yourdomain.com",
"domain": "yourdomain.com",
"from_address": "security@facebookmail.com",
"from_name": "Facebook",
"subject": "Your confirmation code is 482913",
"received_at": "2026-09-26T10:02:11.000Z",
"expires_at": "2026-10-26T10:02:11.000Z",
"otp_code": "482913",
"primary_link": null
}
],
"next_cursor": null
}GET /api/v1/messages/{id}
Fetches one message in full, including sanitized HTML, plain text, headers, attachment metadata, and extracted links. Remote images inside the HTML are blocked by default; add ?images=1 to keep them.
{
"message": {
"id": "8f6c9e2a-2a3b-4b7a-9b0e-2c1f3a9d7e11",
"recipient": "qa-user@yourdomain.com",
"domain": "yourdomain.com",
"from_address": "security@facebookmail.com",
"from_name": "Facebook",
"subject": "Your confirmation code is 482913",
"received_at": "2026-09-26T10:02:11.000Z",
"expires_at": "2026-10-26T10:02:11.000Z",
"otp_code": "482913",
"primary_link": null,
"text": "Your confirmation code is 482913.",
"html": "<p>Your confirmation code is 482913.</p>",
"blocked_images": 0,
"headers": { "message-id": "<abc@mail.facebookmail.com>" },
"attachments": [],
"links": []
}
}DELETE /api/v1/messages/{id}
Deletes a single message. Returns 204 No Content on success.
DELETE /api/v1/messages?address=
Deletes every message for one address, e.g. to reset an inbox between test runs.
{ "deleted": 3 }GET /api/v1/code
Waits for and returns the latest OTP code sent to an address. address is required. An address outside your domains always ends in 404; the ownership check answers immediately only when wait > 0 (with wait=0 the lookup simply finds nothing, same result). wait is 0–60 seconds (larger values are clamped to 60); a timeout returns 404 with code not_found.
Optional pattern: point at the code in your own mail instead of relying on auto-detection. It is plain text (not a regex) with exactly one {code} (3–16 letters, digits or inner hyphens) or {digits} (3–12 digits) marking where the code sits, up to 200 characters. Matching is case-insensitive and spaces are flexible. For a mail body of key là : 5599292 and code là : 9292992, pattern=key là : {code} returns 5599292 and pattern=code là : {code} returns 9292992 (URL-encode the value). The pattern is applied to the text, then the HTML, then the subject of your 25 newest matching messages. An invalid pattern returns 400 with code invalid_request. Without pattern we auto-detect the code.
{
"code": "482913",
"message_id": "8f6c9e2a-2a3b-4b7a-9b0e-2c1f3a9d7e11",
"from": "security@facebookmail.com",
"subject": "Your confirmation code is 482913",
"received_at": "2026-09-26T10:02:11.000Z"
}GET /api/v1/links
Same parameters as /code, but waits for and returns the latest verification link instead.
{
"primary": "https://yourapp.example/verify?token=abc123",
"links": ["https://yourapp.example/verify?token=abc123"],
"message_id": "8f6c9e2a-2a3b-4b7a-9b0e-2c1f3a9d7e11",
"received_at": "2026-09-26T10:02:11.000Z"
}6. Examples
Each example waits up to 30 seconds (wait=30) for a code sent to an address on your domain, filtered to mail received since the moment you triggered the sign-up (since=...).
curl
SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# ... trigger the sign-up on your site here, using an address on one of your domains ...
# -G with --data-urlencode keeps "since" (which contains ":") correctly encoded.
# -D writes response headers to headers.txt, -o writes the JSON body to body.json,
# and -w prints only the HTTP status code to stdout, which we read into STATUS below.
STATUS=$(curl -s -D headers.txt -o body.json -w '%{http_code}' \
-H "Authorization: Bearer fdm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-G "https://freedomainmail.com/api/v1/code" \
--data-urlencode "address=qa-user@yourdomain.com" \
--data-urlencode "wait=30" \
--data-urlencode "since=$SINCE")
case "$STATUS" in
200)
grep -o '"code":"[^"]*"' body.json | cut -d'"' -f4
;;
404)
echo "No code arrived before the wait ended; trigger again or retry" >&2
exit 1
;;
429)
RETRY_AFTER=$(grep -i '^Retry-After:' headers.txt | tr -d '\r' | cut -d' ' -f2)
echo "Rate limited; retry after ${RETRY_AFTER}s (see Retry-After header)" >&2
exit 1
;;
*)
CODE=$(grep -o '"code":"[^"]*"' body.json | head -n1 | cut -d'"' -f4)
MESSAGE=$(grep -o '"message":"[^"]*"' body.json | head -n1 | cut -d'"' -f4)
echo "Error ($STATUS) $CODE: $MESSAGE" >&2
exit 1
;;
esacpython (requests)
import sys
import time
import requests
API_KEY = "fdm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
since = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
# ... trigger the sign-up on your site here, using an address on one of your domains ...
def fetch_code():
response = requests.get(
"https://freedomainmail.com/api/v1/code",
headers={"Authorization": "Bearer " + API_KEY},
params={"address": "qa-user@yourdomain.com", "wait": 30, "since": since},
timeout=35,
)
if response.status_code == 200:
return response.json()["code"]
if response.status_code == 404:
print("No code arrived before the wait ended; trigger again or retry", file=sys.stderr)
return None
if response.status_code == 429:
retry_after = response.headers.get("Retry-After", "a few")
print("Rate limited; retry after " + retry_after + "s (see Retry-After header)", file=sys.stderr)
return None
error = response.json().get("error", {})
print("Error (" + str(response.status_code) + ") " + str(error.get("code")) + ": " + str(error.get("message")), file=sys.stderr)
return None
code = fetch_code()
if code:
print(code)
else:
sys.exit(1)Node (fetch)
const API_KEY = "fdm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
const since = new Date().toISOString();
// ... trigger the sign-up on your site here, using an address on one of your domains ...
async function fetchCode() {
const url = new URL("https://freedomainmail.com/api/v1/code");
url.searchParams.set("address", "qa-user@yourdomain.com");
url.searchParams.set("wait", "30");
url.searchParams.set("since", since);
const response = await fetch(url, {
headers: { Authorization: "Bearer " + API_KEY },
});
if (response.status === 200) {
const data = await response.json();
return data.code;
}
if (response.status === 404) {
console.error("No code arrived before the wait ended; trigger again or retry");
return null;
}
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After") || "a few";
console.error("Rate limited; retry after " + retryAfter + "s (see Retry-After header)");
return null;
}
const body = await response.json();
console.error("Error (" + response.status + ") " + body.error.code + ": " + body.error.message);
return null;
}
const code = await fetchCode();
if (code) {
console.log(code);
} else {
process.exitCode = 1;
}7. Errors
Every error response has the same JSON shape:
{
"error": {
"code": "not_found",
"message": "No matching message."
}
}| Code | Meaning |
|---|---|
unauthorized | Missing, malformed, or revoked API key. |
rate_limited | Too many requests; see Retry-After. |
too_many_waits | Too many concurrent wait requests for this key. |
invalid_request | A parameter is missing or malformed. |
not_found | No matching message, or a wait timed out. |
internal_error | Something went wrong on our end. Safe to retry. |
8. Acceptable use
Only use this API on domains you own, and only for lawful testing and privacy purposes — QA, sign-up flow tests, and automated OTP or verification-link checks. Using the API for fraud or to violate a third party’s terms of service leads to account suspension. Read the full acceptable use policy.