By Sarah Martinez · Published June 18, 2026 · Updated June 18, 2026 · 9 min read
Quick answer: Programmatic faxing is faxing triggered by your software instead of a person. An event in your system enqueues a job, a worker POSTs the document to a fax API, and a signed webhook reports delivery. Build it on the mFax API, included with every mFax Business plan.
Most teams arrive at programmatic faxing the same way. Someone is manually re-faxing referral forms, or a nightly export gets emailed to a fax gateway and nobody knows whether it landed. The fix is not a better dashboard. It is treating fax as what it is — an unreliable external delivery channel — and wrapping it in the same patterns you already use for payments and email.
This guide is about that wrapper. Not the syntax of a single request (our send a fax via API tutorial covers that in four languages) and not the business case (see fax automation), but the design decisions that separate a fax integration that runs itself from one that pages you every week.
What Is Programmatic Faxing?
Programmatic faxing is sending or receiving faxes from code, triggered by system events rather than human action. The defining characteristic is not the API — it is the absence of a person in the loop.
| Approach | Trigger | Confirmation | Fails how |
|---|---|---|---|
| Fax machine | A person walks over | A printed report nobody reads | Silently |
| Web dashboard | A person uploads and clicks | An on-screen status | Silently, after they log out |
| Email-to-fax | A person sends an email | A reply email, sometimes | Into a spam folder |
| Programmatic fax | A system event | A webhook your code handles | Loudly, into your alerting |
That last column is the real upgrade. Automation is nice; knowing when delivery failed is what makes fax safe to depend on.
The Anatomy of an Automatic Fax
Every reliable programmatic fax integration is the same five-stage loop, regardless of language or provider.
Trigger
A business event occurs — a discharge summary is signed, a claim is approved, a nightly batch closes. Something durable records that a fax is owed.
Build
The document is assembled: rendered to PDF, merged with attachments, given a cover sheet, and checked for fax-readiness.
Send
A background worker POSTs the document to the fax API and stores the returned uuid against your own idempotency key.
Confirm
A signed webhook reports fax.delivered or fax.failed. Your code updates the record and, on failure, decides what happens next.
Reconcile
A periodic sweep catches jobs that never reached a terminal state — dropped webhooks, deploy windows, provider incidents.
Skip stage 5 and the system works fine until the day it doesn't, quietly.
Step 1: Choose the Trigger
The trigger is the highest-leverage decision in the whole integration, because it determines what happens when your process crashes halfway through.
| Trigger | Best for | Watch out for |
|---|---|---|
| Transactional outbox | Anything where the fax must match a committed database change | Needs a relay process; worth it |
| Message queue | High volume, multiple producers | At-least-once delivery — you must dedupe |
| Scheduled batch | Nightly reports, statements, digests | A whole day of faxes fails at once |
| Inbound webhook | Fax triggered by another SaaS (CRM, e-sign) | You inherit their retry semantics |
| Direct user action | Low volume, human-initiated | Still enqueue — never fax inside a request handler |
The pattern that survives contact with production is the transactional outbox: in the same database transaction that records the business event, insert a row into a fax_jobs table. If the transaction rolls back, no fax is owed. If it commits, the job exists and a worker will pick it up — even if the process dies one line later.
Never call the fax API from a request handler
A fax takes 30–90 seconds to deliver, and the API responds in milliseconds with 202 queued — but a network hiccup on that call inside a web request leaves you with no record of whether the fax was created. Write the job down first, send from a worker.
Step 2: Build a Document That Survives Transmission
Fax reduces everything to 1-bit black and white at roughly 204 × 196 dpi. Documents that look fine on screen degrade badly. Before your worker sends anything:
- Render at 200 dpi or better, black on white, with no reliance on colour to convey meaning.
- Avoid hairline rules and light grey text — both disappear entirely once rasterized.
- Merge attachments into a single PDF. One transmission is faster and cheaper than three, and a partial multi-fax delivery is a mess to reconcile. Our free merge PDF tool does this by hand when you need a one-off.
- Compress large scans. Fewer, cleaner pages transmit faster and fail less; try the optimize PDF tool to see the difference.
- Normalize the recipient number to E.164 before it reaches the queue —
+14155550100, not(415) 555-0100. A number rejected at send time is a job you have to reprocess. - Generate the cover sheet in code if the recipient requires one — and keep sensitive detail off it, since it sits face-up in a shared tray. The fax cover sheet generator shows the fields that matter.
Page count drives both cost and failure rate. Every extra page is another ~7 seconds on the line and another opportunity for the call to drop.
Step 3: Send — and Make It Safe to Retry
Here is the constraint that shapes the whole send path: the mFax API has no Idempotency-Key header, and neither do several other fax APIs. If your worker retries after a timeout, nothing on the provider side stops a second fax from going out.
So you own deduplication. The pattern is a unique key derived from the business event, written before the call:
// fax_jobs: idempotency_key TEXT UNIQUE, fax_uuid TEXT NULL, status TEXT
async function sendFaxJob(job) {
// 1. Already sent? The uuid is the proof.
if (job.fax_uuid) return job.fax_uuid;
const form = new FormData();
form.append('to', job.to); // E.164, e.g. +14155550100
form.append('file', new Blob([job.pdf], { type: 'application/pdf' }), 'document.pdf');
const res = await fetch('https://developers.mfax.to/v1/faxes', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.MFAX_API_KEY}` },
body: form,
});
if (res.status === 429) {
throw new RetryableError(Number(res.headers.get('Retry-After') ?? 30));
}
if (!res.ok) {
const { code, message } = await res.json();
throw new FaxError(code, message); // classified in Step 5
}
const fax = await res.json(); // 202 Accepted
// 2. Persist the uuid immediately — this is what makes the retry safe.
await db.faxJobs.update(job.id, { fax_uuid: fax.uuid, status: fax.status });
return fax.uuid;
}
Two details do the heavy lifting. The unique constraint on idempotency_key means two workers racing on the same event cannot both create a job. Writing fax_uuid the instant the API returns means a crash after the send is recoverable: the next attempt sees the uuid and stops.
If you crash between the POST and the write
Rare, but real. Recover by listing recent faxes — GET /v1/faxes?since=<timestamp> — and matching on recipient and time window before creating a new send. Cheaper than apologising to a recipient who got the same document twice.
Step 4: Confirm With Webhooks, Verify Before You Trust
The provider POSTs three event types to your endpoint: fax.delivered, fax.failed, and fax.received. Each arrives in a stable envelope with an id, a type, a unix created timestamp, and a data object shaped exactly like the REST fax resource.
Delivery is at-least-once, so duplicates are expected, not exceptional. Two rules cover it: verify the signature, then dedupe on the event id.
import crypto from 'crypto';
function verifyMfaxSignature(secret, header, timestampHeader, rawBody) {
const v1 = header.split(',').find(p => p.startsWith('v1='))?.slice(3);
const expected = crypto.createHmac('sha256', secret)
.update(`${timestampHeader}.${rawBody}`)
.digest('hex');
// Reject anything older than five minutes — replay protection.
if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > 300) return false;
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
app.post('/webhooks/fax', express.raw({ type: 'application/json' }), async (req, res) => {
const ok = verifyMfaxSignature(
process.env.MFAX_WEBHOOK_SECRET,
req.get('X-Zelda-Signature'),
req.get('X-Zelda-Timestamp'),
req.body.toString(),
);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString());
// Accept fast, then queue. Never do slow work inside the handler.
if (await seenEvents.add(event.id)) return res.sendStatus(200); // duplicate
await faxEventQueue.push(event);
res.sendStatus(200);
});
Note the shape: verify at the edge, dedupe against a store, enqueue, return 200 immediately. A webhook handler that downloads a PDF inline will eventually time out, the provider will retry, and you will have built yourself a duplicate storm.
Then reconcile. A sweep every few minutes over jobs still in queued or sending past a sane threshold, using GET /v1/faxes?since=<timestamp>, closes out anything a dropped webhook left dangling.
Step 5: Decide What to Retry
Retrying everything is as wrong as retrying nothing. Classify by cause:
| Signal | Cause | Retry? | Policy |
|---|---|---|---|
429 rate_limited | You are sending too fast | Yes | Honour Retry-After, exponential backoff with jitter |
| Network timeout | Transport | Yes | Backoff — but check for an existing uuid first |
failed + user_busy | Recipient's line in use | Yes | Wait 5–15 minutes, cap at 3–5 attempts |
failed, no fax tone | Wrong number type | Maybe once | Then flag for a human to verify the number |
400 invalid_number | Bad input | No | Dead-letter and alert — repeating cannot help |
402 quota_exceeded | Plan limit hit | No | Hold the queue, alert operations |
403 subscription_inactive | Billing lapsed | No | Page someone; every send will fail until fixed |
Anything that exhausts its retries belongs in a dead-letter table with the original document reference, the last failure_reason, and the attempt history — not in a log line. Faxes usually carry documents someone is waiting for, so a weekly review of that table is part of the system, not a nice-to-have. Our fax error codes reference decodes the machine-side vocabulary behind these failures.
The one retry rule people get wrong
A partially transmitted fax that fails mid-document is retried as a whole document. Group 3 fax has no resume: if the recipient received four of six pages, they will receive all six again — worth a note in your cover sheet or your ops runbook.
Batch and Scheduled Faxing
Nightly runs are where programmatic faxing meets rate limits. Three habits keep batches healthy:
- Bound your concurrency. A fixed worker pool sized to your provider's rate limit beats firing 5,000 requests and absorbing
429s. - Spread the schedule. Starting every batch at midnight means every failure happens at midnight too. Stagger by recipient or region.
- Make the batch resumable. Each job carries its own idempotency key, so a rerun after a crash sends only what is genuinely outstanding.
For teams that want automation without a codebase, no-code connectors like Zapier and Make can trigger sends from spreadsheet rows or form submissions — covered in our fax automation guide. They are a fine starting point and a poor place to end up: you lose idempotency control and signature verification.
Observability: What to Log
Fax is a channel you cannot inspect after the fact, so log at the boundaries:
- ✓Every state transition with the fax
uuid, timestamp, andfailure_reason— this is your audit trail. - ✓Time from trigger to delivered, not just API latency. The business cares about the former.
- ✓Retry counts per recipient number — a number failing repeatedly is bad data, not bad luck.
- ✓Webhook receipt gaps, so a silent provider or a broken endpoint surfaces within minutes.
- ✓Never log the document or the API key. If you handle PHI, that rule is a compliance requirement — see the HIPAA fax API guide.
Frequently Asked Questions
Do I need a fax number to send faxes programmatically?
Not for outbound — the provider transmits over its own carrier infrastructure. You need a virtual fax number to receive, and inbound faxes then arrive as fax.received webhook events. See how to receive a fax online.
Can I send faxes programmatically without writing backend code?
Partly. No-code platforms can trigger sends, and mFax Business itself sends from web, mobile, and desktop. But your API key must never live in a browser or mobile app, so any genuinely programmatic flow needs something server-side holding the credential.
How many faxes can I send at once?
That depends on your provider's rate limit and your plan's page quota, not on your code. Size your worker pool to the limit, honour Retry-After, and treat 402 quota_exceeded as a signal to pause rather than push harder.
Is programmatic faxing HIPAA compliant?
It can be, with a signed BAA, TLS in transit, AES-256 at rest, access controls, and audit logging. mFax Business includes a signed BAA on every plan. The HIPAA fax API guide has the full checklist.
Start Sending Faxes From Code
You need three things: an organization on mFax Business (usage-based pricing from about $9/mo annually, API access on every plan), a zk_live_… key from the mFax dashboard, and a webhook endpoint that verifies signatures.
The reference lives at developers.mfax.to. Start with one send and one webhook, add idempotency before you go to production, and add reconciliation before you go to volume. For the layer underneath — what actually happens once your request is accepted — read how programmatic cloud faxing works.