Developers
A fax API that behaves like the rest of your stack.
REST over HTTPS with JSON, bearer API keys, idempotent creates, signed webhooks, and an MCP server generated from the same API.
HTTP API
REST and JSON with bearer keys. Upload, send with an Idempotency-Key, then poll or wait for the webhook.
POST /v1/accounts/{id}/faxesMCP server
The same API as Model Context Protocol tools, filtered by your key's scopes, for agents that fax.
send_fax · get_fax · list_faxesWebhooks
HMAC-SHA256 signed deliveries with the same fax object, retried on a schedule, deduped by delivery_id.
fax.received · fax.succeededQuick start
Create an API key in the app under your account's API keys. Keys look like bfx_live_… or bfx_test_…. A test key creates sandbox faxes: rendered and checked like real ones, then marked succeeded without dialing or billing. Send the key as Authorization: Bearer.
- 1. UploadPDF, TIFF, JPEG, or PNG, up to 20 MiB. The
content_reflasts one hour and is used once. - 2. Send
tois E.164. Reuse the sameIdempotency-Keyon retry for 24 hours. - 3. FollowPoll the fax, or wait for
fax.succeededorfax.failed.
export FAX_API=https://api.faxdev.com
export FAX_API_KEY=bfx_test_... # bfx_test_ = sandbox: never dials, never bills
export ACCOUNT_ID=8a1f5c2e-3b7d-4e0a-9c61-2f4d8b7e1a90
# 1. Upload the document (raw body, up to 20 MiB)
curl -sS -X POST "$FAX_API/v1/accounts/$ACCOUNT_ID/uploads" \
-H "Authorization: Bearer $FAX_API_KEY" \
-H "Content-Type: application/pdf" \
-H "Idempotency-Key: upload-invoice-1042" \
--data-binary @invoice-1042.pdf
# -> { "content_ref": "3f9b2d71-6c4e-4a8b-b0d2-7e5a1c9f8d36", ... }
# 2. Send it
curl -sS -X POST "$FAX_API/v1/accounts/$ACCOUNT_ID/faxes" \
-H "Authorization: Bearer $FAX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: send-invoice-1042" \
-d '{
"to": "+13125550142",
"content_ref": "3f9b2d71-6c4e-4a8b-b0d2-7e5a1c9f8d36",
"quality": "fine",
"client_reference": "invoice-1042",
"cover": { "enabled": true, "line1": "Invoice 1042", "line2": "Attn: Accounts payable" }
}'
# 3. Follow it
curl -sS "$FAX_API/v1/accounts/$ACCOUNT_ID/faxes/c0e7a4b2-91d5-4f3e-8a26-5b1c9d0e7f43" \
-H "Authorization: Bearer $FAX_API_KEY"# pip install requests
import os, time, requests
API = os.environ["FAX_API"] # https://api.faxdev.com
KEY = os.environ["FAX_API_KEY"] # bfx_test_... = sandbox
ACCOUNT = os.environ["ACCOUNT_ID"]
auth = {"Authorization": f"Bearer {KEY}"}
base = f"{API}/v1/accounts/{ACCOUNT}"
# 1. Upload the document
with open("invoice-1042.pdf", "rb") as f:
up = requests.post(f"{base}/uploads", data=f, timeout=60, headers={
**auth, "Content-Type": "application/pdf", "Idempotency-Key": "upload-invoice-1042"})
up.raise_for_status()
# 2. Send it (reuse the same Idempotency-Key if you retry)
res = requests.post(f"{base}/faxes", timeout=30,
headers={**auth, "Idempotency-Key": "send-invoice-1042"},
json={"to": "+13125550142", "content_ref": up.json()["content_ref"],
"client_reference": "invoice-1042"})
res.raise_for_status()
fax = res.json()
# 3. Poll until it finishes (or wait for the webhook)
DONE = {"succeeded", "failed", "cancelled", "expired", "rejected"}
while fax["status"] not in DONE:
time.sleep(5)
fax = requests.get(fax["links"]["self"], headers=auth, timeout=30).json()
print(fax["status"], fax["pages_delivered"], fax["caller_id_delivery"])// Node 18+ (global fetch), ES modules
import { readFile } from "node:fs/promises";
const { FAX_API, FAX_API_KEY, ACCOUNT_ID } = process.env; // bfx_test_... = sandbox
const base = `${FAX_API}/v1/accounts/${ACCOUNT_ID}`;
const auth = { Authorization: `Bearer ${FAX_API_KEY}` };
// 1. Upload the document
const up = await fetch(`${base}/uploads`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/pdf", "Idempotency-Key": "upload-invoice-1042" },
body: await readFile("invoice-1042.pdf"),
}).then((r) => r.json());
// 2. Send it (reuse the same Idempotency-Key if you retry)
let fax = await fetch(`${base}/faxes`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": "send-invoice-1042" },
body: JSON.stringify({ to: "+13125550142", content_ref: up.content_ref, client_reference: "invoice-1042" }),
}).then((r) => r.json());
// 3. Poll until it finishes (or wait for the webhook)
const done = new Set(["succeeded", "failed", "cancelled", "expired", "rejected"]);
while (!done.has(fax.status)) {
await new Promise((r) => setTimeout(r, 5000));
fax = await fetch(fax.links.self, { headers: auth }).then((r) => r.json());
}
console.log(fax.status, fax.pages_delivered, fax.caller_id_delivery);Once the document exists, links.document redirects (302) your authenticated request to a five-minute download URL. On an account with logged document access, the same request returns the document itself (200), and ?redirect=false returns a single-use link that works once, for 60 seconds. Every document read is audit-logged with who read it and how. If a fax completes over an alternate carrier network, caller_id_delivery is "not_guaranteed": the pages went through, but the recipient may see a different caller ID.
Responses
A fax moves through queued → rendering → admitted → … and ends as succeeded, failed, cancelled, expired, or rejected. The webhook carries the same object. Read a status by what it means for you:
queued,rendering: accepted, and being turned into fax pages.admitted: verified and scheduled for delivery.transmitting: pages are going out now.retrying: an attempt did not complete; we have already scheduled the next one, possibly over another carrier network. You do not resend.succeeded: delivered and billed for the pages that arrived.failed,cancelled,expired, andrejectedare not billed.
A fax may pass through other in-progress statuses while a connection is being set up. Treat any status that is not terminal as in progress, and the per-attempt record under links.attempts as the full story.
HTTP/1.1 202 Accepted
{
"id": "c0e7a4b2-91d5-4f3e-8a26-5b1c9d0e7f43",
"status": "queued",
"direction": "outbound",
"to": "+13125550142",
"from": "+14155550100",
"did_id": "5d2c8e1a-7b4f-4c9d-a3e6-0f1b2c3d4e5f",
"caller_id_delivery": null,
"pages": null,
"pages_delivered": null,
"retail_cents": null,
"sandbox": true,
"read_at": null,
"failure_class": null,
"reject_reason": null,
"client_reference": "invoice-1042",
"created_at": "2026-10-27T14:04:05Z",
"completed_at": null,
"links": {
"self": "https://api.faxdev.com/v1/accounts/8a1f5c2e-3b7d-4e0a-9c61-2f4d8b7e1a90/faxes/c0e7a4b2-91d5-4f3e-8a26-5b1c9d0e7f43",
"attempts": "https://api.faxdev.com/v1/accounts/8a1f5c2e-3b7d-4e0a-9c61-2f4d8b7e1a90/faxes/c0e7a4b2-91d5-4f3e-8a26-5b1c9d0e7f43/attempts",
"document": null
}
}HTTP/1.1 201 Created
{
"content_ref": "3f9b2d71-6c4e-4a8b-b0d2-7e5a1c9f8d36",
"content_type": "application/pdf",
"bytes": 48213,
"sha256": "9f2c4e...b71a",
"expires_at": "2026-10-27T15:04:05Z"
}Errors and retries
Errors are application/problem+json with a stable machine-readable code. Retry network errors and 429 / 5xx with the same Idempotency-Key, and honor Retry-After. Do not retry other 4xx responses without changing the request.
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.faxdev.com/problems/idempotency_conflict",
"title": "Idempotency key reused with a different request",
"status": 409,
"detail": "Idempotency-Key was used with a different request",
"instance": "<the X-Request-ID of this request>",
"code": "idempotency_conflict"
}Webhooks
Register an HTTPS endpoint per account, or one per organization for every customer account. Each delivery is signed with the endpoint secret shown when you create or rotate it.
Fax-Timestamp: Unix seconds.Fax-Signature:hex(hmac_sha256(secret, "{timestamp}.{raw_body}")).- Reject a timestamp more than five minutes from your clock, and dedupe on
delivery_id, which is stable across retries. - We retry after 1 minute, 5 minutes, 30 minutes, 2 hours, and 8 hours.
- A webhook never carries document bytes; fetch
fax.links.documentwith your API key.
- fax.queued
- fax.succeeded
- fax.failed
- fax.received
- fax.rejected
- fax.cancelled
- fax.expired
POST /your/webhook HTTP/1.1
Content-Type: application/json
Fax-Timestamp: 1761574800
Fax-Signature: 5b0e6c...e91d
{
"event": "fax.received",
"delivery_id": "0b6f3c7e-2a91-4d5b-8e40-1c7a9f2d6e38",
"occurred_at": "2026-10-27T14:20:00Z",
"fax": { "id": "...", "status": "succeeded", "direction": "inbound", "pages": 3,
"links": { "self": "...", "attempts": "...", "document": "https://api.faxdev.com/v1/accounts/.../faxes/.../document" } }
}// Node 18+ / Express: verify before you parse. Use the raw body bytes.
import crypto from "node:crypto";
export function verifyFaxWebhook(rawBody /* Buffer */, headers, secret /* "whsec_..." */) {
const ts = headers["fax-timestamp"];
const sigHeader = headers["fax-signature"];
if (!ts || !sigHeader) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // reject skew over 5 minutes
const expected = crypto.createHmac("sha256", secret)
.update(`${ts}.`).update(rawBody).digest("hex");
// After a secret rotation the header carries two comma-separated signatures for one hour.
return sigHeader.split(",").some((s) => {
const a = Buffer.from(s.trim(), "hex"), b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}# Python 3: same check. Cache f"{ts}.{signature}" for 5 minutes to drop replays.
import hashlib, hmac, time
def verify_fax_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
h = {k.lower(): v for k, v in headers.items()}
ts, sig_header = h.get("fax-timestamp"), h.get("fax-signature")
if not ts or not sig_header or abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s.strip()) for s in sig_header.split(","))Use the endpoint's test action to receive a signed webhook.test before your first real fax.
MCP for AI agents
The FaxDev API is also a Model Context Protocol server (Streamable HTTP) at https://api.faxdev.com/mcp. It uses the same bearer API key as REST. The tool list is generated from the API and filtered by your key's level and scopes, so an agent never sees a tool its key cannot call.
{
"mcpServers": {
"fax": {
"type": "http",
"url": "https://api.faxdev.com/mcp",
"headers": { "Authorization": "Bearer bfx_live_..." }
}
}
}claude mcp add --transport http fax https://api.faxdev.com/mcp \
--header "Authorization: Bearer bfx_live_..."Give agents a key with only the scopes they need. A read-only agent should never hold faxes:send.
Tools an account key sees, scopes permitting:
| Tool | REST operation | Scope |
|---|---|---|
send_fax | POST / | faxes:send |
get_fax | GET / | faxes:read |
list_faxes | GET / | faxes:read |
cancel_fax | POST / | faxes:cancel |
list_attempts | GET …/ | faxes:read |
get_fax_document_url | GET …/ | faxes:read |
list_numbers | GET / | numbers:read |
list_contacts | GET / | contacts:read |
create_contact | POST / | contacts:write |
get_usage | GET / | usage:read |
get_fax_document_url returns a short-lived URL rather than file bytes, so agent transcripts do not fill with documents; each call is audit-logged like a download in the app. send_fax takes a content_ref from a prior upload (preferred) or base64 content up to 20 MiB.
Full reference: API docs . Questions: contact us.
Delivery and reliability
We get the fax there. Every outbound fax is routed over multiple independent carrier networks with fax-grade transport and error correction. Busy lines, no-answers, and failed handshakes are retried and rerouted automatically; if the primary routes cannot complete a fax, an alternate carrier network takes over, and the fax may show a different caller ID. You never resend, and you are billed only for pages that arrive.
- Per-attempt delivery record. Every try is listed under
links.attemptswith its outcome, pages transferred, and duration. - Idempotent API. Repeat a create with the same
Idempotency-Keyand you get the original fax, never a second one. - Signed webhooks. HMAC-SHA256 over the timestamp and body, retried on a schedule and deduped by
delivery_id. - Encryption at rest. Documents and backups are encrypted, and every read is audit-logged.
- Capacity that follows demand. Delivery capacity scales up and down across US regions; a node is drained before it is retired, so faxes in progress finish first.
Developer questions
How do I authenticate?
Send an API key as Authorization: Bearer. Keys look like bfx_live_ or bfx_test_, carry only the scopes you grant, and can be revoked at any time.
How do I avoid sending the same fax twice?
Send an Idempotency-Key header on every create. Repeating the request with the same key within 24 hours returns the original response instead of sending a second fax.
How do I verify a webhook?
Compute hex(hmac_sha256(secret, timestamp + '.' + raw_body)) and compare it with the Fax-Signature header in constant time. Reject timestamps more than five minutes from your clock and dedupe on delivery_id.
How does an AI agent connect over MCP?
Point an MCP client at https://api.faxdev.com/mcp over Streamable HTTP with the same bearer API key as REST. The tool list is generated from the API and filtered by the key's scopes.
Can I test without dialing or paying?
Yes. A bfx_test_ key creates sandbox faxes that are rendered and checked like real ones, then marked succeeded without dialing or billing.
What happens when a delivery attempt fails?
Nothing you need to do. A busy line, no answer, or failed handshake is retried and rerouted across independent carrier networks automatically, and if the primary routes cannot complete it, an alternate carrier network is used. When that happens, caller_id_delivery is not_guaranteed: the pages went through, but the recipient may see a different caller ID. Every attempt is listed under links.attempts, and failed pages are never billed.