The Python client for Fax for Agents, the fax API built for AI agents.
Website · Quickstart · API reference · SDKs · MCP · Dashboard · Pricing · Changelog · Affiliate program · GitLab
Fax for Agents is a fax API, a hosted MCP server and SDKs built so AI agents can send and receive faxes. It is made by JSB Holdings LLC and built by Synapse.
pip install faxforagents # the client
pip install "faxforagents[cli]" # plus the faxforagents commandPython 3.10 or later. Two dependencies, httpx and pydantic.
A test key (ffa_test_...) reaches the sandbox provider only. No carrier, no cost, nothing leaves the
box. The destination number picks the outcome. Keys are created in the dashboard at
https://app.faxforagents.com or with POST /v1/api-keys.
import os
from faxforagents import Client
os.environ["FAXFORAGENTS_API_KEY"] = "ffa_test_..." # or export it in the shell
with Client() as ffa:
doc = ffa.documents.create(text="Hello from an agent.", filename="hello")
print(doc.id, doc.page_count, doc.sha256[:12])
quote = ffa.quotes.create(document_id=doc.id, to="+15550000001")
print(quote.amount_micro, "micro, expires", quote.expires_at)
fax = ffa.faxes.create(quote_id=quote.id) # Idempotency-Key minted for you
print(fax.id, fax.state) # queued, never delivered yet
fax = ffa.faxes.wait(fax.id) # polls until a terminal state
print(fax.state, fax.pages_sent, fax.evidence) # delivered, 1, {...}
for job in ffa.faxes.iter(state="failed"): # follows next_cursor
print(job.id, job.failure_code)The same in async form, with AsyncClient and await on every call.
- Every mutating call carries an
Idempotency-Key. When you pass none, a uuid4 is minted once per call and reused on every retry, so a retried send never creates a second fax. Passidempotency_key=to pick your own. - 429 and 5xx answers are retried up to three times.
Retry-Afteris honoured. Connection errors and timeouts are retried the same way. - Cursor-paginated fax, document, event, approval, inbox, extraction and billing lists have iterators
that follow
next_cursor. documents.create(text=...),markdown=...orhtml=...renders on the server. HTML prints with scripts off and no network, inline CSS anddata:images only, at most 2 MiB.documents.upload(path)runs the whole upload path for a PDF or an image (PNG, JPG, TIFF): the upload session, the signed PUT with the content type read from the filename, the sha256 check, the document. An image becomes gray letter pages.faxes.wait(fax_id)andapprovals.wait(approval_id)poll with a timeout.verify_webhook(secret, headers, body)checks the Standard Webhooks signature on a delivery.- Models are pydantic v2, generated from the public OpenAPI document. Money is an integer count of
USD micro-units (
39000is $0.039). Timestamps are UTC strings.
For raw HTTP upload completion, send a JSON object even when you omit the checksum.
curl -X POST "https://api.faxforagents.com/v1/uploads/$UPLOAD_ID/complete" \
-H "Authorization: Bearer $FAXFORAGENTS_API_KEY" \
-H "Content-Type: application/json" -d '{}'Method names are the operationIds of the OpenAPI document, shortened by their resource. The reference at https://faxforagents.github.io/openapi/ names the scope each call needs.
| Resource | Calls |
|---|---|
ffa.identity |
me, list_api_keys, create_api_key, rotate_api_key, revoke_api_key |
ffa.documents |
create, upload, create_upload, put_upload_content, complete_upload, cancel_upload, list, iter, get, delete, get_validation, get_download, download, list_pages |
ffa.quotes |
create, get |
ffa.faxes |
create, list, iter, get, wait, list_events, list_attempts, get_receipt, get_receipt_pdf, cancel |
ffa.webhooks |
list, create, update, delete, rotate_secret, test, list_deliveries, replay_delivery |
ffa.events |
list, iter |
ffa.billing |
balance, list_ledger, iter_ledger, iter_topup_sessions, iter_invoices, settings, top-ups, auto top-ups, budgets, usage, exports, invoices, subscriptions, create_portal_session |
ffa.platform |
capabilities, service_status |
ffa.sandbox |
list_scenarios, create_scenario, create_inbound_fax, reset |
ffa.approvals |
request, request_help, list, iter, get, wait, approve, reject, revoke |
ffa.policies |
list, create, effective, get_auto_send, set_auto_send, get, update, list_versions, evaluate |
ffa.agents |
list, create, get, update, create_key, activity |
ffa.inbox |
list, iter, get, update, delete, get_download, download |
ffa.numbers |
coverage, inventory, create_order, list_orders, get_order, list, get, update, release, check_portability, list_port_orders, create_port_order, get_port_order, create_port_out_request, verify_sender |
ffa.extractions |
create, list, iter, get, correct, classify, list_classifications, iter_classifications, get_classification, summarize, list_summaries, iter_summaries, get_summary, list_schemas, create_schema, get_schema |
ffa.organizations |
list, current, update_current, request_data_export, request_data_deletion, download_export_archive |
ffa.members |
list, update, remove, list_invitations, invite, revoke_invitation |
ffa.oauth |
list_grants, iter_grants, revoke_grant |
ffa.one_time_sends |
config, create, create_from_quote, list, iter, get, wait, checkout, read_by_token, confirm, resend_confirmation, get_receipt_pdf |
ffa.request("GET", "/v1/anything") reaches an operation the resources do not cover yet.
A fax job is queued, active, partial, failed, unknown or delivered. delivered is written
only when the carrier's evidence is on the attempt, and fax.evidence holds it. partial and
unknown never retransmit on their own. Whether to resend is a human decision.
Sandbox destinations: +15550000001 delivered, +15550000002 busy then delivered, +15550000003
partial, +15550000004 no answer, +15550000005 unknown, +15550000006 provider timeout,
+15550000007 invalid number. Any other +1 number is delivered.
from faxforagents import APIError, RateLimitedError
try:
fax = ffa.faxes.create(quote_id=quote.id)
except RateLimitedError as exc:
print("retry in", exc.retry_after)
except APIError as exc:
print(exc.code, exc.message, exc.correlation_id) # quote_expired, insufficient_balance, approval_requiredAuthenticationError (401), ScopeError (403), NotFoundError (404), ConflictError (409),
PaymentRequiredError (402), InvalidRequestError (400, 422), RateLimitedError (429) and
ServerError (5xx) are all APIError. APIConnectionError and APITimeoutError mean the API was
not reached.
Every refusal is the same envelope, a 422 for a field that fails validation included. A 413
payload_too_large is a plain APIError: the body is over the limit of its route. The published
limits are in one table at https://faxforagents.com/docs/limits/.
A live send needs either a human approval bound to the quote or a delegated policy that allows it.
req = ffa.approvals.request(quote.id) # a person is mailed
req = ffa.approvals.wait(req.id) # pending until they decide
if req.status == "approved":
fax = ffa.faxes.create(quote_id=quote.id, approval_id=req.id)An agent's own API key can never approve. ffa.policies.evaluate(quote_id=...) is a dry run that says
whether a policy would allow the send.
A key made for a connected app used over stdio, such as the Gemini CLI extension, is created with
connected_app=True. The organization's auto-send setting (ffa.policies.get_auto_send) then binds it like
an OAuth connection. An agent's key cannot carry the flag.
key = ffa.identity.create_api_key("gemini", scopes=["fax:send"], connected_app=True)
key.connected_app # True, also on ffa.identity.list_api_keys()A person with one fax to send needs no wallet and no key. one_time_sends makes an order, opens a hosted
card payment and follows the order until the fax ends. The person pays on the hosted page and clicks the
link mailed to them. The client never sees a card.
from faxforagents import Client
with Client(keyless=True) as ffa: # no API key: only one_time_sends answers
config = ffa.one_time_sends.config() # open here? the card and the limits
order = ffa.one_time_sends.create(
to="+12025550123",
sender_name="Dana Example",
email="[email protected]", # the confirmation link and the receipt go here
accept_aup=True, # the policy and terms that config names
file="form.pdf", # or text="...", text_format="markdown"
)
token = order.status_token # answered once: keep it
page = ffa.one_time_sends.checkout(order.id, order_token=token)
print(page.checkout_url) # the person pays here, then clicks the mailed link
order = ffa.one_time_sends.wait(order.id, order_token=token, timeout=1800)
print(order.status, order.fax["state"] if order.fax else None)
pdf = ffa.one_time_sends.get_receipt_pdf(order.id, order_token=token)order.next names the step that is waiting: checkout, confirm, wait or done. wait polls until
done, or until the step you pass as until. The card is authorized at checkout and charged only when
the carrier confirms delivery.
The status token goes in the X-FFA-Order-Token header. It reads the order, opens its checkout, mails
the confirmation again and downloads the receipt. A client with a live key of the order's organization
needs no token. A test key is refused: an order is always live.
With a live key, create_from_quote turns a quote the wallet does not cover into an order.
from faxforagents import PaymentRequiredError
try:
fax = ffa.faxes.create(quote_id=quote.id, approval_id=approval_id)
except PaymentRequiredError as exc:
print(exc.top_up_url) # where a person adds funds to the wallet
offer = exc.one_time_offer # None when this send has no card path
if offer:
print(offer["amount_micro"]) # the one-time price of this document
order = ffa.one_time_sends.create_from_quote(
quote_id=quote.id, sender_name="Dana Example", approval_id=offer["approval_id"]
)
print(order.status_url) # hand this page to the person who paysPaying by card does not replace approval. With a key, a card order must have an approval or a
delegated policy, as a wallet send does. Pass the approval as approval_id. Without either the answer is 403
approval_required.
The person who pays accepts the acceptable use policy and the terms on the order page, never the key.
With a key, the order answers aup_accepted false and a status_url. Give that link to the payer.
On a page of your own, checkout(order.id, order_token=token, accept_aup=True) records the payer's
acceptance and opens the payment.
order.queue and config().queue tell whether a fax would wait for a carrier line, with jobs_ahead
and estimated_wait_minutes. A queued order is kept and sent in turn.
confirm(order_id, token=...) and read_by_token(token) serve a page of your own that receives the
mailed link. read_by_token posts the token, so it stays out of the address. get_by_token still
works and is deprecated. The amounts are on the pricing page
and in config().routes, in micro-units.
from faxforagents import verify_webhook, parse_event, SignatureError
try:
event_id = verify_webhook(secret, request.headers, request.body)
except SignatureError:
return 400
event = parse_event(request.body) # event.type is fax.delivered, fax.failed, fax.received...Verify on the raw bytes. Store event_id to drop a redelivery you already handled. During a secret
rotation two signatures arrive for 24 hours and either one verifies.
faxforagents.webhooks.verify is an alias of verify_webhook. Both names accept the same arguments.
export FAXFORAGENTS_API_KEY=ffa_test_...
faxforagents documents upload letter.pdf # or .png, .jpg, .tiff, .html, .md, .txt
faxforagents fax quote --document doc_... --to +15550000001
faxforagents fax send --quote quote_... --wait
faxforagents fax list --state failed
faxforagents inbox list
faxforagents approvals list --status pending
faxforagents balance
faxforagents sandbox scenario partial --to +12025550100Every command takes --json for the raw answer.
Price card launch-v3 is effective from 2026-11-12. $0.039 per page sent, $0.019 per page received.
$2 per number per month, $19 per month for the Agent workspace. Sandbox pages cost 0.
ffa.platform.capabilities() returns the card in force. One fax by card has its own price:
ffa.one_time_sends.config() returns it.
pip install -e ".[dev,cli]"
pytest -q # unit tests, respx fixtures, no network
FAXFORAGENTS_API_KEY=ffa_test_... pytest -q -m live # one real sandbox send
python scripts/gen_models.py --check # models.py matches spec/openapi.yaml
ruff check src tests && mypy src
sh scripts/truthcheck.shspec/openapi.yaml follows the public document at API version 1.20.0 and keeps the schemas of the earlier live documents.
The two validation error schemas are the exception. 0.5.0 removed them because the server never sent that shape.
0.6.0 removed two cost fields of an extraction, because the server stopped sending them.
It also retains earlier public model names and route-documented response additions.
To refresh it, merge the live schemas while preserving existing declarations, then run uv run python scripts/gen_models.py.
Keep new response fields optional so older payloads and existing callers remain valid.
The schema drift test compares every saved schema property with the generated declarations without network access.
MIT. See LICENSE. Security reports go to [email protected], see SECURITY.md.
