By Michael Chen · Published June 25, 2026 · Updated June 25, 2026 · 11 min read
Quick answer: An online fax API lets you send and receive faxes from any internet-connected app via standard HTTP requests. Sign up for mFax Business, get an API key at developers.mfax.to, and you can send a fax in one
POSTcall — no fax machine, no phone line, no on-premise server.
You have an app. Somewhere in the workflow — a healthcare portal, a legal platform, an insurance back-office — faxes are still required. The fastest way to handle them isn't a physical fax machine or a shared email inbox. It's an online fax API: a REST interface that turns fax send/receive into ordinary HTTP calls your backend can make like any other external service.
This guide covers the full picture: what an online fax API actually is, how to send, how to receive inbound faxes (the part most tutorials skip), and how to wire faxing into a real web app, EHR, or CRM. The mFax API is used for all examples — the same patterns apply to any REST fax provider.
Already know the basics?
If you've sent faxes via API before and want to skip straight to receiving faxes and integration patterns, jump to Step 3: Receive Inbound Faxes and Step 4: Real Integration Patterns.
What Is an Online Fax API?
An online fax API (also called a cloud fax API or internet fax API) is a hosted REST service that converts your HTTP requests into fax transmissions — and converts incoming faxes into webhook events your code can process.
Instead of maintaining a fax server connected to a phone line, you call an endpoint:
POST https://developers.mfax.to/v1/faxes
Authorization: Bearer zk_live_…
Content-Type: multipart/form-data
to: +14155550100
file: [your PDF]
The provider takes it from there: queues the job, converts your PDF to T.38 or G3 fax protocol, transmits it over the PSTN or VoIP, and fires a fax.delivered webhook when the recipient confirms receipt.
The key difference from a manual online fax dashboard: the API is programmable. Your code decides when to send, what to send, and what to do when something arrives — no human in the loop.
Why "online" matters
"Online fax" distinguishes this approach from two older alternatives:
| Method | How it works | Why teams move away |
|---|---|---|
| Physical fax machine | Analog line, thermal paper | No audit trail, no automation, maintenance overhead |
| On-premise fax server | A server inside your network with a phone card | Capital cost, IT maintenance, single point of failure |
| Online fax API | REST calls to a hosted provider | No hardware, scales with demand, built-in delivery receipts |
Healthcare organizations still exchange over 9 billion fax pages per year in the United States, and 70% of clinic-to-clinic communication flows through fax. An online fax API lets software handle that volume automatically.
Step 1: Get Your API Key and Fax Number
Create an mFax Business account
Go to mFax.to/business and sign up. Plans start from about $9/mo (annual billing) and include API access on every tier. When your organization is created at app.mfax.to, a virtual fax number is assigned automatically — this is the number that receives inbound faxes.
Generate an API key
In the mFax dashboard, navigate to Developer → API Keys and create a new key. Keys look like zk_live_…. Treat this like a database password: store it in an environment variable (MFAX_API_KEY), never in source code.
Note your fax number
Find your assigned virtual number under Settings → Fax Numbers. You'll need it when configuring inbound routing and when sharing your fax address with senders. The format is E.164: +1XXXXXXXXXX.
Never expose your API key in frontend code
Your fax API key grants full account access — send faxes, read inbound PDFs, manage settings. It must live only on your server. Frontend JavaScript calling the API directly will expose the key to anyone who views source.
Step 2: Send a Fax
Sending requires exactly two form fields: to (recipient in E.164 format) and file (the PDF document).
cURL
curl -X POST https://developers.mfax.to/v1/faxes \
-H "Authorization: Bearer $MFAX_API_KEY" \
-F "to=+14155550100" \
-F "file=@/path/to/document.pdf"
Node.js (fetch)
import { readFileSync } from 'fs';
async function sendFax(to, pdfPath) {
const form = new FormData();
form.append('to', to);
form.append('file', new Blob([readFileSync(pdfPath)], { 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.ok) throw new Error(`Fax send failed: ${res.status}`);
return res.json(); // { id, status: 'queued', to, pages, created_at }
}
Python
import os, requests
def send_fax(to: str, pdf_path: str) -> dict:
with open(pdf_path, 'rb') as f:
response = requests.post(
'https://developers.mfax.to/v1/faxes',
headers={'Authorization': f"Bearer {os.environ['MFAX_API_KEY']}"},
data={'to': to},
files={'file': ('document.pdf', f, 'application/pdf')},
)
response.raise_for_status()
return response.json()
Go
package main
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
)
func sendFax(to, pdfPath string) error {
f, err := os.Open(pdfPath)
if err != nil {
return err
}
defer f.Close()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
_ = w.WriteField("to", to)
fw, _ := w.CreateFormFile("file", filepath.Base(pdfPath))
io.Copy(fw, f)
w.Close()
req, _ := http.NewRequest("POST", "https://developers.mfax.to/v1/faxes", &buf)
req.Header.Set("Authorization", "Bearer "+os.Getenv("MFAX_API_KEY"))
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 202 {
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
return nil
}
The API responds with HTTP 202 and a fax object:
{
"id": "fax_01HXYZ…",
"status": "queued",
"to": "+14155550100",
"pages": 3,
"created_at": "2026-06-25T10:30:00Z"
}
Track delivery by polling GET /v1/faxes/{id} or — better — listening for the fax.delivered webhook (covered next).
Status lifecycle:
| Status | Meaning |
|---|---|
queued | Job accepted, waiting for a transmission slot |
sending | Actively dialing and transmitting |
delivered | Recipient confirmed receipt |
failed | Could not deliver; check failure_reason (e.g. user_busy, no_answer) |
Step 3: Receive Inbound Faxes — The Part Most Tutorials Skip
Sending is well-documented. Receiving is where most integrations get stuck. Here's the full inbound pipeline.
How inbound faxes work
When someone sends a fax to your virtual number, the provider:
- Receives the analog transmission
- Converts it to a PDF
- Stores the PDF on secure, time-limited storage
- POSTs a
fax.receivedwebhook event to your configured URL
The webhook fires within seconds of the fax completing. Your endpoint has a short window — typically 24–72 hours — to download the PDF before the storage URL expires.
Configure your webhook URL
In the mFax dashboard: Developer → Webhooks → Add Endpoint. Set the URL to your server endpoint (e.g. https://yourapp.com/webhooks/fax). Enable the fax.received event. Optionally add a signing secret for signature verification.
The fax.received payload
{
"id": "evt_01HXYZ…",
"type": "fax.received",
"created": "2026-06-25T11:05:42Z",
"data": {
"id": "fax_01HABC…",
"status": "received",
"from": "+14085551234",
"to": "+14155550100",
"pages": 2,
"media_url": "https://storage.mfax.to/faxes/fax_01HABC…/document.pdf?token=…",
"media_url_expires_at": "2026-06-26T11:05:42Z",
"received_at": "2026-06-25T11:05:38Z"
}
}
Key fields:
from— sender's fax number (use for routing)media_url— time-limited URL to download the PDF; download immediatelymedia_url_expires_at— when the URL stops working; never rely on fetching it later
Handle the webhook in Node.js
import express from 'express';
import crypto from 'crypto';
import { writeFileSync } from 'fs';
const app = express();
app.use(express.json());
const SIGNING_SECRET = process.env.MFAX_WEBHOOK_SECRET;
const processed = new Set(); // use Redis or a DB in production
app.post('/webhooks/fax', async (req, res) => {
// 1. Verify signature
const sig = req.headers['x-zelda-signature']; // t=…,v1=…
const timestamp = req.headers['x-zelda-timestamp'];
const payload = `${timestamp}.${JSON.stringify(req.body)}`;
const expected = 'v1=' + crypto
.createHmac('sha256', SIGNING_SECRET)
.update(payload)
.digest('hex');
const received = sig.split(',').find(p => p.startsWith('v1='));
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return res.status(401).send('Invalid signature');
}
// 2. Deduplicate (webhooks deliver at-least-once)
const eventId = req.body.id;
if (processed.has(eventId)) return res.status(200).send('duplicate');
processed.add(eventId);
// 3. Respond quickly, process async
res.status(200).send('ok');
if (req.body.type === 'fax.received') {
await handleInboundFax(req.body.data);
}
});
async function handleInboundFax(fax) {
// Download the PDF before the URL expires
const resp = await fetch(fax.media_url);
const buffer = await resp.arrayBuffer();
// Store durably — S3, GCS, your DB blob, etc.
const filename = `fax_${fax.id}_${Date.now()}.pdf`;
writeFileSync(`/storage/faxes/${filename}`, Buffer.from(buffer));
// Route based on sender or your business logic
await routeFax({ from: fax.from, to: fax.to, filename, pages: fax.pages });
}
Download immediately — URLs expire
The media_url in a fax.received event expires within 24–72 hours. If your webhook handler fails or your server is down, you may lose access to the document. Always download and persist the PDF as the first action inside your handler — before any other processing.
Polling as a webhook fallback
If your environment can't receive webhooks (local dev, firewall-restricted network, batch jobs), poll the list endpoint instead:
# Get all faxes received in the last hour
curl "https://developers.mfax.to/v1/faxes?direction=inbound&since=2026-06-25T10:00:00Z" \
-H "Authorization: Bearer $MFAX_API_KEY"
Polling is fine for low-volume or non-real-time workflows. For anything that needs near-instant processing — a patient intake form, a legal filing confirmation — use webhooks.
Step 4: Real Integration Patterns
Pattern A: Web Form → Backend → Fax
The most common pattern. A user fills out a form in your web UI; your backend sends it as a fax. Never call the fax API from the browser directly.
Browser (form submit)
→ POST /api/send-fax (your backend)
→ validates input
→ generates/fetches the PDF
→ POST /v1/faxes (mFax API)
→ returns { jobId } to browser
→ browser polls GET /api/fax-status/:jobId
// Your Express backend route
app.post('/api/send-fax', upload.single('file'), async (req, res) => {
const { recipientFax } = req.body;
const pdfBuffer = req.file.buffer;
const form = new FormData();
form.append('to', recipientFax);
form.append('file', new Blob([pdfBuffer], { type: 'application/pdf' }), 'document.pdf');
const mfaxRes = await fetch('https://developers.mfax.to/v1/faxes', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.MFAX_API_KEY}` },
body: form,
});
const { id } = await mfaxRes.json();
res.json({ jobId: id });
});
Pattern B: CRM Trigger → Fax Send
When a deal closes in your CRM (Salesforce, HubSpot), automatically fax the contract. Use your CRM's webhook or automation rules to call your backend, which then calls the fax API.
// Receive a CRM webhook (e.g. deal status changed to "closed-won")
app.post('/crm/webhooks/deal-closed', async (req, res) => {
res.status(200).send('ok'); // respond fast
const { contactFaxNumber, contractPdfUrl } = req.body;
// Fetch the contract PDF from your document storage
const pdfResponse = await fetch(contractPdfUrl);
const pdfBuffer = await pdfResponse.arrayBuffer();
// Send via mFax API
await sendFax(contactFaxNumber, Buffer.from(pdfBuffer));
});
Pattern C: Serverless Inbound Fax Processor
Receive a fax webhook in an AWS Lambda function, download the PDF, and email it to the right team member based on the sender's number.
// Lambda handler (Node.js)
export const handler = async (event) => {
const body = JSON.parse(event.body);
if (body.type !== 'fax.received') return { statusCode: 200 };
const fax = body.data;
const pdfResp = await fetch(fax.media_url);
const pdfBuffer = await pdfResp.arrayBuffer();
// Upload to S3
await s3Client.send(new PutObjectCommand({
Bucket: process.env.FAX_BUCKET,
Key: `inbound/${fax.id}.pdf`,
Body: Buffer.from(pdfBuffer),
ContentType: 'application/pdf',
}));
// Route by sender number to the right inbox
const recipient = routingTable[fax.from] ?? process.env.DEFAULT_FAX_EMAIL;
await sesClient.send(new SendEmailCommand({
Destination: { ToAddresses: [recipient] },
Message: {
Subject: { Data: `Incoming fax from ${fax.from} (${fax.pages} pages)` },
Body: { Text: { Data: `PDF saved to s3://${process.env.FAX_BUCKET}/inbound/${fax.id}.pdf` } },
},
Source: 'fax-router@yourcompany.com',
}));
return { statusCode: 200, body: 'ok' };
};
Local Development and Testing
You can't test a webhook endpoint that isn't on the public internet. Use ngrok (or Cloudflare Tunnel) to expose your local server:
# Terminal 1 — start your local server
node server.js # listens on :3000
# Terminal 2 — expose it publicly
ngrok http 3000
# Output: Forwarding https://abc123.ngrok-free.app → localhost:3000
Set https://abc123.ngrok-free.app/webhooks/fax as your webhook URL in the mFax dashboard, then send a test fax to your virtual number. The webhook fires on your local machine.
Mock webhook payloads for unit tests
Don't send real faxes in automated tests. Instead, build a fixture from a real event and call your handler function directly:
// test/webhooks.test.js
import { handleInboundFax } from '../src/fax-handler.js';
const MOCK_FAX_RECEIVED = {
type: 'fax.received',
data: {
id: 'fax_TEST001',
from: '+14085551234',
to: '+14155550100',
pages: 2,
media_url: 'https://example.com/test-fax.pdf',
media_url_expires_at: '2099-01-01T00:00:00Z',
received_at: '2026-06-25T11:00:00Z',
},
};
test('routes fax to correct inbox', async () => {
const routed = await handleInboundFax(MOCK_FAX_RECEIVED.data);
expect(routed.recipient).toBe('billing@yourcompany.com');
});
Tip: Use a separate test API key
Create a second API key in the mFax dashboard labeled "test" and use it only in development. If it's accidentally logged or leaked, you can revoke it without affecting production.
Common Use Cases
Healthcare & EHR
Automatically fax referrals, prior authorization forms, and lab results from your EHR when a provider approves them — no manual printing. Receive inbound results and route them to the patient record.
Legal Platforms
Trigger a fax send when a contract is signed or a court deadline fires. Receive signed documents back via inbound webhook and attach them to the case file automatically.
Insurance & Finance
Send claims, policy documents, and compliance filings to carriers that still require fax. Receive confirmations and auto-file them with an audit timestamp.
Government & IRS
Submit IRS forms, state filings, and federal applications that accept fax. Useful in healthcare, real estate, and non-profit sectors where agencies have set fax-only receipt windows.
Generating Fax-Ready PDFs Programmatically
Many integrations need to generate the PDF dynamically — filling a template with patient data, contract fields, or form values — before faxing. Two common approaches:
Puppeteer (headless Chrome):
import puppeteer from 'puppeteer';
async function generateFaxPdf(html) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'Letter', // North American fax standard
printBackground: true,
margin: { top: '0.5in', bottom: '0.5in', left: '0.5in', right: '0.5in' },
});
await browser.close();
return pdf; // Buffer — pass directly to FormData
}
Tips for fax-compatible PDFs:
- Use Letter or A4 page size — fax machines default to these
- Keep resolution at 200–300 DPI — higher doesn't improve fax quality and increases file size
- Black and white content transmits faster and more reliably than color
- Target under 5 MB per document; use Optimize PDF to compress if needed
Online Fax API Providers Compared
| Provider | Auth | Send endpoint | Inbound webhooks | HIPAA/BAA | Price (approx.) |
|---|---|---|---|---|---|
| mFax | Bearer zk_live_… | POST /v1/faxes | fax.received | ✅ Signed BAA | From ~$9/mo (annual) |
| Telnyx | Bearer | POST /v2/faxes | fax.received | ✅ BAA available | ~$0.007/page + number |
| Sinch/Phaxio | Basic (key/secret) | POST /phaxio/faxes | fax_completed | ✅ BAA available | ~$0.007/page |
| Fax.Plus | OAuth 2.0 | REST | fax_sent, fax_received | ✅ BAA available | From $4.99/mo |
| Notifyre | Bearer | REST | Yes | ✅ BAA available | Pay-per-page |
Twilio Programmable Fax is retired
Twilio sunset its fax product on December 17, 2021. If your codebase references api.twilio.com/…/Fax, you need to migrate. The mFax API is a drop-in replacement with an equivalent request structure.
For a deeper comparison including pricing tiers and feature differences, see Best Fax API Providers.
Idempotency: Prevent Duplicate Fax Sends
Network timeouts can cause your code to retry a send that already succeeded. The result: the same fax delivered twice. Protect against this with an idempotency key — a unique identifier you generate per logical send operation:
async function sendFaxIdempotent(to, pdfBuffer, idempotencyKey) {
const form = new FormData();
form.append('to', to);
form.append('file', new Blob([pdfBuffer]), 'document.pdf');
// Check if we've already sent this (store keys in Redis or DB)
const existing = await redis.get(`fax:idem:${idempotencyKey}`);
if (existing) return JSON.parse(existing);
const res = await fetch('https://developers.mfax.to/v1/faxes', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.MFAX_API_KEY}` },
body: form,
});
const result = await res.json();
// Store result with a TTL longer than your retry window
await redis.set(`fax:idem:${idempotencyKey}`, JSON.stringify(result), 'EX', 86400);
return result;
}
// Usage: use a stable key derived from the business event
const key = `contract-${contractId}-${recipientFax}`;
await sendFaxIdempotent(recipientFax, pdfBuffer, key);
Generate idempotency keys from stable business identifiers — a contract ID, an order number, a form submission ID. That way, even if your server restarts mid-request, retrying with the same key returns the original result without sending twice.
Error Handling Reference
| HTTP Status | Code | Meaning | Action |
|---|---|---|---|
400 | invalid_number | Recipient number invalid or unroutable | Validate E.164 format before sending |
402 | quota_exceeded | Monthly page limit reached | Upgrade plan or wait for reset |
403 | subscription_inactive | Account or plan suspended | Check billing at app.mfax.to |
429 | rate_limited | Too many requests per second | Honor Retry-After header; use exponential backoff |
On 429, always check the Retry-After response header for the exact wait time before retrying. For all other transient errors (5xx), retry with exponential backoff: wait 1 s, then 2 s, then 4 s — up to 3 retries.
Get Started With the mFax API
mFax Business gives you API access on every plan — from a single-developer setup at about $9/mo (annual) to multi-seat teams with thousands of pages per month. Plans are usage-based: $3 per seat + $4 per 100 pages/mo, with a 20% discount on annual billing. A signed BAA is included on every plan.
Read the full reference at developers.mfax.to and create your first API key in the mFax dashboard.
Related guides: