Fax API
Send PDFs to any fax number and receive faxes on your own number, over plain HTTPS. Delivery updates arrive as signed webhooks, busy lines are retried automatically, and documents are deleted once they are delivered.
Overview
| Base URL | https://xenfax.com/v1 |
| Format | JSON responses; multipart or JSON requests |
| Auth | Bearer API key (live and test keys) |
| Statuses | queued → sending → delivered or failed |
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/faxes | Send a fax |
| GET | /v1/faxes/{id} | Current status of a fax |
| GET | /v1/inbound-faxes/{id} | Details of a received fax |
| GET | /v1/inbound-faxes/{id}/pdf | Download a received fax |
| DELETE | /v1/inbound-faxes/{id} | Delete a received fax now |
| POST | /v1/test/inbound-faxes | Test keys only: simulate an incoming fax |
| GET | /v1/account | Your fax number, webhook URL and limits |
| PATCH | /v1/account | Set your webhook URL (live key) |
Authentication
Send your key in the Authorization header. Keys starting xf_live_ send real faxes; keys starting xf_test_ never touch the phone network (see Test mode). Each key only sees its own faxes.
Authorization: Bearer xf_live_...Account & webhook URL
GET /v1/account shows your dedicated fax number, caller name, webhook URL and daily limit. Set or change the webhook URL with your live key. Test and live events go to the same URL, and livemode tells them apart. The URL must be public HTTPS.
curl -X PATCH https://xenfax.com/v1/account -H "Authorization: Bearer $XENFAX_API_KEY" -H "Content-Type: application/json" -d '{"webhook_url":"https://your-app.example/webhooks/xenfax"}'{
"object": "account",
"name": "Acme Health",
"contact_email": "dev@acme.example",
"livemode": true,
"fax_number": "+15185550100",
"caller_name": "Acme Health",
"webhook_url": "https://your-app.example/webhooks/xenfax",
"webhook_secret_configured": true,
"daily_send_cap": 500
}Your webhook signing secret is issued once, when your account is created, and is never shown by the API.
Send a fax
POST /v1/faxes. Provide the document either as an upload or as a link we download once.
| Field | Required | Description |
|---|---|---|
to | yes | Recipient fax number in E.164 format, e.g. +14155550123. |
file | one of | The PDF, as a multipart upload (up to ~4 MB). |
file_url | one of | HTTPS link to the PDF (up to 20 MB). Downloaded once, immediately; a link valid for 5 minutes is enough. |
reference | yes | Your own ID for this fax (e.g. an order ID), 1–100 chars of letters, digits and . _ : -. This is the only label we store; never put personal details here. |
idempotency_key | yes | Unique per fax, 8–200 chars. Repeating a request with the same key returns the original fax instead of sending again. Can also be sent as an Idempotency-Key header. |
Upload
curl https://xenfax.com/v1/faxes \
-H "Authorization: Bearer $XENFAX_API_KEY" \
-F to=+14155550123 \
-F reference=ORD-10293 \
-F idempotency_key=ORD-10293-rx-1 \
-F file=@prescription.pdfLink
curl https://xenfax.com/v1/faxes \
-H "Authorization: Bearer $XENFAX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to":"+14155550123","reference":"ORD-10293",
"idempotency_key":"ORD-10293-rx-1",
"file_url":"https://storage.googleapis.com/...signed..."}'Response · 201 Created
{
"id": "fax_3kQ9d0w1ZQ2m6n3pX8aL1g",
"object": "fax",
"livemode": true,
"reference": "ORD-10293",
"to": "+14155550123",
"from": "+15185550100",
"status": "queued",
"attempts": 0,
"max_attempts": 3,
"next_attempt_at": null,
"page_count": 2,
"failure_reason": null,
"failure_message": null,
"file_deleted": false,
"created_at": "2026-11-08T17:02:11.204Z",
"queued_at": "2026-11-08T17:02:11.391Z",
"sending_at": null,
"delivered_at": null,
"failed_at": null,
"updated_at": "2026-11-08T17:02:11.391Z"
}A repeat with the same idempotency_key returns 200 with the same fax and an Idempotent-Replayed: true header. Reusing a key with a different to or reference returns 409. If a request fails validation, the key is not used up: fix the request and retry with the same key.
Get fax status
GET /v1/faxes/{id} returns the same object as above. Use it to reconcile if you think you missed a webhook.
curl https://xenfax.com/v1/faxes/fax_3kQ9d0w1ZQ2m6n3pX8aL1g -H "Authorization: Bearer $XENFAX_API_KEY"Webhooks
We POST JSON to your webhook URL when something changes:
| Type | When |
|---|---|
fax.sending | An attempt started dialling (sent once per attempt). |
fax.delivered | The receiving machine confirmed the fax. Includes page_count and delivered_at. |
fax.failed | Final failure, after any retries. Includes failure_reason, failure_message and failed_at. |
fax.received | A fax arrived on your number. Includes download_url. |
POST https://your-app.example/webhooks/xenfax
Content-Type: application/json
Xenfax-Event-Id: evt_x1Y2...
Xenfax-Event-Type: fax.delivered
Xenfax-Signature: t=1762621400,v1=5f2b...e9
{
"id": "evt_x1Y2...",
"object": "event",
"type": "fax.delivered",
"livemode": true,
"created_at": "2026-11-08T17:03:20.118Z",
"data": { ...fax object, as returned by GET /v1/faxes/{id}... }
}Verifying the signature
v1 is the hex HMAC-SHA256 of `${t}.${rawBody}` using your webhook secret. Compare it in constant time and reject timestamps more than 5 minutes old.
import crypto from 'crypto';
export function verifyXenfax(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}Delivery guarantees
- Respond with any 2xx within 10 seconds. Anything else is retried after 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h and 24 h.
- Delivery is at least once, so you may see the same event twice: dedupe on
id(also inXenfax-Event-Id). - Events can arrive out of order (for example, a retried
fax.sendingafterfax.delivered). Treatdeliveredandfailedas final, and useGET /v1/faxes/{id}if unsure.
Retries
If the line is busy, doesn't answer, or the call drops, we try again automatically: 3 attempts over about 30 minutes (immediately, about 10 minutes later, then about 20 minutes after that). Between attempts the fax shows status: "queued" with attempts, the last failure_reason, and next_attempt_at. Numbers that are invalid, not in service, or voice lines fail at once with no retry. After the final attempt the fax becomes failed and you get fax.failed.
| failure_reason | Retried? | Meaning |
|---|---|---|
user_busy | yes | Line busy |
receiver_no_answer | yes | No answer |
receiver_no_response | yes | No fax tone |
receiver_call_dropped | yes | Call dropped mid-transmission |
receiver_incompatible_destination | no | Not a fax machine (voice line) |
receiver_unallocated_number | no | Number not in service |
destination_invalid | no | Invalid number |
Receiving faxes
Your account has a dedicated fax number. Outgoing faxes are sent from it, with your business name as the caller name, and faxes sent to it trigger fax.received:
{
"type": "fax.received",
"data": {
"id": "infax_8Hk2...",
"object": "inbound_fax",
"livemode": true,
"from": "+15185550142",
"to": "+15185550100",
"page_count": 1,
"received_at": "2026-11-08T18:11:04.002Z",
"file_available": true,
"download_url": "https://xenfax.com/v1/inbound-faxes/infax_8Hk2.../pdf",
"file_expires_at": "2026-11-15T18:11:04.002Z"
}
}Download with your API key (the link alone is not enough):
curl -o fax.pdf "$DOWNLOAD_URL" -H "Authorization: Bearer $XENFAX_API_KEY"The file is deleted 24 hours after the first download, or 7 days after it arrived, whichever comes first. Call DELETE /v1/inbound-faxes/{id} to delete it as soon as you have saved it. After deletion the download returns 410.
Test mode
Requests made with an xf_test_ key go through the same validation, idempotency, webhooks and retry logic, but no fax is ever dialled. Test faxes finish within seconds, test retries wait about 20 seconds instead of minutes, and webhooks carry "livemode": false.
| Send to | Simulated result |
|---|---|
+15005550001 | Delivered on the first attempt (any other number behaves the same) |
+15005550002 | Busy on every attempt, then failed with user_busy after 3 attempts |
+15005550003 | Busy on attempt 1, delivered on attempt 2 |
+15005550004 | No answer on every attempt, then failed with receiver_no_answer |
+15005550005 | Voice line: failed immediately, no retry |
To simulate a pharmacy faxing you back:
curl -X POST https://xenfax.com/v1/test/inbound-faxes \
-H "Authorization: Bearer $XENFAX_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{"from":"+15185550142","pages":2}'Data handling
- Outgoing documents are deleted from XenFax storage as soon as a fax is delivered or finally fails, and never kept longer than 7 days.
- Our carrier is told not to store media, and we delete the carrier's fax record after each attempt and after each inbound fax is collected.
- Received faxes are deleted 24 hours after first download or 7 days after arrival, or straight away on
DELETE. - The only label stored for a fax is your
reference. Our logs contain fax IDs and references only: no document content, no file links. - Files are kept in private storage and are only served to your API key over HTTPS.
Errors
{ "error": { "code": "invalid_to", "message": "`to` must be an E.164 fax number, e.g. +14155550123." } }| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing or invalid API key |
| 403 | live_key_required, test_mode_only | Endpoint needs the other key type |
| 409 | idempotency_key_reused | Key already used with different parameters |
| 410 | file_deleted | Inbound fax file deleted under the retention policy |
| 415 | unsupported_media_type | Use multipart/form-data or application/json |
| 422 | invalid_webhook_url | webhook_url must be public HTTPS |
| 422 | invalid_to, invalid_reference, invalid_idempotency_key | Bad field |
| 422 | invalid_file, file_too_large, too_many_pages | PDF problem (max 20 MB, 100 pages) |
| 422 | invalid_file_url, file_download_failed | file_url not public HTTPS, expired, or unreachable |
| 429 | daily_cap_reached | Daily send limit reached (contact us to raise it) |
| 500 | internal_error | Safe to retry with the same idempotency_key |
Get API access
Tell us your expected monthly volume and whether you need a dedicated number. We'll send live and test keys.
Email hello@xenfax.com