ringivo
The Python client for the Ringivo fax API: send a fax, read one, list them, cancel one, fetch its pages, manage your customers' fax accounts, register the webhooks that tell you what happened, and verify what arrives.
pip install ringivo
Python 3.10 or newer. Two runtime dependencies: httpx, and
typing-extensions (4.10 or newer) on every supported interpreter — the
generated types use TypedDict(closed=True), which no version of the
standard library's typing carries.
There are two clients: Ringivo and AsyncRingivo. They take the same
arguments and have the same methods; the async one awaits them. Pick the one
that matches your program and do not mix them: each client's authentication
refuses the other's transport rather than quietly sending your requests
without a token, so an httpx.AsyncClient handed Ringivo's auth — or an
httpx.Client handed AsyncRingivo's — raises NotImplementedError naming
the reason.
Your base URL and your credential
There is no default host, and none is compiled in. Your provider gives you
the API root, a client id, a client secret, and the id of the tenant your
credential acts for; everything in this README uses
https://api.yourprovider.example where yours goes.
with Ringivo(
base_url="https://api.yourprovider.example",
client_id="0198c4a1-1f2e-7a3b-9c40-5f6e7d8a9b01",
client_secret="9tK2xr4mQ7vBnZ1sD5hL0pWfC8jY3aE6",
tenant="0198c4a1-3d4e-7f50-a1b2-c3d4e5f6a7b8",
scopes=["fax:read", "fax:write"],
) as client:
...
On the first call the client sends all of that in one request and gets back a bearer token that lasts about a quarter of an hour. It caches the token, mints a new one a minute before that one expires, and mints another if the platform ever refuses one — you never handle the token.
Ask for the scopes you need. The client refuses to construct without
scopes=, and raises ValueError naming the fix: a request that asks for
no scopes authorises nothing, so the platform refuses it — a 400 you would
otherwise meet on your first call rather than on the line that caused it.
Ask for more than your credential was granted and the extra is dropped
rather than refused, as long as one scope survives, so a call can still fail
later at the resource. The scopes this client's calls need are fax:read
and fax:write for faxes, and fax-accounts:write for opening, changing or
deleting a fax account — a reseller-tier scope, so a credential issued for
one customer cannot hold it however it is asked for. The webhook calls need
webhooks:read and webhooks:write, except on an endpoint scoped to one fax
account: a fax:* token already reaches those.
A client that provisions accounts and then reads them asks for both:
with Ringivo(
base_url="https://api.yourprovider.example",
client_id="0198c4a1-1f2e-7a3b-9c40-5f6e7d8a9b01",
client_secret="9tK2xr4mQ7vBnZ1sD5hL0pWfC8jY3aE6",
tenant="0198c4a1-3d4e-7f50-a1b2-c3d4e5f6a7b8",
scopes=["fax:read", "fax-accounts:write"],
) as provisioning:
account = provisioning.fax_accounts.create(
customer="0198c4a1-4d5e-7f60-a172-3c4d5e6f7081",
name="Front desk",
)
print(account.id, account.retention_days)
tenant= is required, and it is a required argument rather than a
checked one: leave it out and Python refuses the constructor by name. There
is no inference behind it — a mint that names no tenant is refused.
Pass customer= as well when your credential was issued for one customer
inside that tenant. Both selectors NAME a grant your provider already wrote
for your credential; they never widen one, and a selector no grant covers is
refused with a 400 however good your credentials are.
Send a fax
from pathlib import Path
from ringivo import Ringivo
with Ringivo(
base_url="https://api.yourprovider.example",
client_id="0198c4a1-1f2e-7a3b-9c40-5f6e7d8a9b01",
client_secret="9tK2xr4mQ7vBnZ1sD5hL0pWfC8jY3aE6",
tenant="0198c4a1-3d4e-7f50-a1b2-c3d4e5f6a7b8",
scopes=["fax:read", "fax:write"],
) as client:
fax = client.faxes.send(
fax_account="0198c4a1-3c4d-7e5f-9061-2b3c4d5e6f70",
to="+13025556789",
file=Path("chart-4471.pdf"),
client_reference="chart-4471",
)
print(fax.id, fax.status) # 0198c4a1-… queued
send() returns as soon as the fax is accepted. The render and the call
happen afterwards, so status is queued here — read the fax again to see
how it ended:
finished = client.faxes.get(fax.id)
print(finished.status, finished.pages_transferred)
Point at pages instead of uploading them with urls=[...] (up to five
https links). Uploads and URLs cannot be mixed in one request.
Retrying a send safely
Every send carries an Idempotency-Key, and the client invents one when you
do not pass it. If you intend to retry a send whose response you never
saw — a timeout, a dropped connection — pass your own key and reuse it. The
server replays the first fax instead of sending a second, and tells you it
did:
fax = client.faxes.send(
fax_account=account_id,
to="+13025556789",
file=pdf_bytes,
idempotency_key="chart-4471-attempt-1",
)
if fax.idempotent_replay:
print("this was already sent")
Read, list, cancel, download
fax = client.faxes.get(fax_id)
page = client.faxes.list(direction="inbound", read=False, tags={"clinic": "north"})
for fax in page:
print(fax.id, fax.from_, fax.pages_total)
if page.next_cursor: # newest first; follow the cursor
page = client.faxes.list(after=page.next_cursor)
client.faxes.cancel(fax_id) # before the far end answers
pdf = client.faxes.media(fax_id) # the document's bytes
Path("received.pdf").write_bytes(pdf)
media() mints a short-lived download link and follows it for you. Use
media_link() instead if you want the URL and its expiry — but do not cache
it or pass it on: anyone holding it reads that document.
Walking the whole collection
A page holds 25 rows by default, up to a ceiling of 100 with page_size=.
To backfill every fax matching a filter, follow next_cursor — the
server's own cursor — until it comes back None:
faxes = []
after = None
while True:
page = client.faxes.list(direction="inbound", after=after)
faxes.extend(page)
if page.next_cursor is None: # the last page
break
after = page.next_cursor
after= walks forward; before= walks backward from a cursor instead —
how you poll for rows that arrived since your last read.
Fax accounts
A fax account is a customer's container: the numbers routed to it, the faxes
sent and received on it, and the settings that govern both. Opening,
changing and deleting one is client.fax_accounts.
account = client.fax_accounts.create(
customer="0198c4a1-4d5e-7f60-a172-3c4d5e6f7081",
name="Front desk",
header_text="ACME VETERINARY",
retention_days=365,
)
page = client.fax_accounts.list(customer="0198c4a1-4d5e-7f60-a172-3c4d5e6f7081")
for account in page:
print(account.id, account.name, account.status)
for number in client.fax_accounts.numbers(account.id):
print(number.e164, number.status)
client.fax_accounts.update(account.id, status="suspended") # receive only
client.fax_accounts.delete(account.id)
An account belongs to one customer for its whole life. Every fax it holds carries the customer it was sent or received for, so there is no way to move it and no argument that would try.
Numbers are attached through the routing API, not here. A number points
at one destination, and that rule belongs to the number:
POST /v1/phone-numbers/{id}/routing with target_type: fax, through
client.request(). numbers() reads back what is pointed at this account —
all of them, walking the pages for you, because a half-list of a fax
account's numbers looks exactly like a full one.
Retention: two rules, either of them off
Retention here DELETES; it never holds anything back.
| Setting | What it does | Off |
|---|---|---|
retention_days |
Delete a fax's pages once they are older than this many days. | None — kept for ever |
retention_pages |
Keep only this many of the newest pages on the account. | None — no page limit |
A new account gets your provider's defaults — a year, and no page limit, at the time of writing — because this client sends nothing for an argument you did not name.
client.fax_accounts.update(account.id, retention_days=90, retention_pages=5000)
client.fax_accounts.update(account.id, retention_days=None) # keep for ever
Deleting a FAX is never blocked by retention: DELETE /v1/faxes/{id}
removes its pages now.
Changing one setting changes one setting
update() is a sparse PATCH: it sends only the arguments you pass, so
suspending an account leaves its retention rules exactly as they were.
None is a value rather than an omission — it clears a nullable field.
client.fax_accounts.update(account.id, default_from_e164=None) # clears it
client.fax_accounts.update(account.id) # ValueError
Deleting an account
delete() DESTROYS the stored pages of every fax on the account and cannot
be undone — download anything worth keeping first. The account then leaves
your listings and the people granted it lose access; the fax records
themselves survive as the billing and audit evidence, and nothing bills
after the delete.
It is refused while any number still routes to the account:
try:
client.fax_accounts.delete(account.id)
except ApiError as refusal:
if refusal.code == "fax_account_has_routed_numbers":
print("move or release its numbers first")
Branch on code, not on the 409: a fax that cannot be cancelled is a 409
too, and it carries no code at all.
Who may read an account's faxes
Holding the account write permission lets somebody manage an account
without being granted it. A GRANT is how you hand ONE account's faxes and
pages to somebody who holds no such permission — a customer-facing staffer
who should see this customer's faxes and no others. That is
client.fax_account_users.
grant = client.fax_account_users.create(
fax_account=account.id,
user="0198c4a1-7081-72a3-d4a5-6f7081920314",
)
for row in client.fax_account_users.list(fax_account=account.id):
print(row.user_email, row.user_id)
client.fax_account_users.delete(grant.id) # withdraw it
A grant is a pair and a fact — this user, this account — and it holds no
settings, so there is no update(): you withdraw one by deleting it and
re-make it by creating another. Withdrawing takes nothing else with it, and
somebody who reaches the account by permission rather than by a grant still
reaches it.
Listing and reading grants needs fax:read; making and withdrawing them
needs fax-accounts:write. The account you grant may be one you hold no
grant on yourself — administering an account is permission-gated while
reading its content is grant-gated, so somebody has to be able to add the
first member.
Webhook endpoints
An endpoint is where the platform calls you, and what it calls you about.
Registering one is client.webhook_endpoints; checking what arrives is
webhooks.verify(), further down this page.
endpoint = client.webhook_endpoints.create(
url="https://hooks.acme-vet.example/faxes",
scope_type="fax_account",
scope_id=account.id,
events=["fax.received", "fax.delivered"],
)
# whsec_… — the only time this is readable. Put it where your
# receiver can find it; no call reads it back.
secret = endpoint.secret
The signing secret is in that answer and nowhere else, ever. Store it
before you do anything else. Every later read of the endpoint publishes
secret=None, and that is the platform saying it keeps no readable copy, not
this client failing to find one. Lose it and your only way back is
rotate_secret().
scope_type is tenant, customer or fax_account, and scope_id names
the one you mean. The three are a containment order, so a reseller-wide
endpoint and a per-account one both hear about the same fax. Neither
scope_type nor scope_id can be changed afterwards: the delivery record is
the evidence of what that scope was told, so a different scope is a new
endpoint.
events is the list you want, and None or [] both mean every event in
scope. An event name the platform does not publish is a 422 — a typo would
otherwise subscribe you to silence.
Registering needs webhooks:write, or fax:write for a fax_account-scoped
endpoint only. Naming a customer or tenant scope with a fax:* token is a
422. Reading needs webhooks:read, and a fax:read token lists
fax-account-scoped endpoints alone — the wider ones are absent from its page
rather than refused, so an empty result under a fax:* token says nothing
about whether a wider endpoint exists.
Adding an event to an endpoint you already have
update() is a sparse PATCH, like fax_accounts.update(): it sends only the
arguments you pass. So the fix for "we registered for fax.received and the
outbound events never arrived" is one call, and it leaves the URL and the
switch exactly as they were:
client.webhook_endpoints.update(
endpoint.id,
events=["fax.received", "fax.sending", "fax.delivered", "fax.failed"],
)
The list is REPLACED, not merged — send every event you want, not just the
new ones. events=None (or []) asks for every event in scope instead.
Switching an endpoint off keeps it and its events, and stops the fan-out:
client.webhook_endpoints.update(endpoint.id, active=False) # deaf, not gone
client.webhook_endpoints.delete(endpoint.id) # gone
delete() stops the fan-out at once and keeps the delivery record — "why
did our integration stop hearing about faxes?" is answered by the deliveries
of the endpoint somebody removed. Both need webhooks:write, or fax:write
on a fax-account-scoped endpoint.
Rotating the secret
rotated = client.webhook_endpoints.rotate_secret(endpoint.id)
print(rotated.secret) # the new one, once
print(rotated.secret_previous_expires_at) # when the old one stops
A rotation does not replace the secret at once. It mints a new one and starts
a 24-hour clock: the PREVIOUS secret goes on signing until
secret_previous_expires_at, and during that window a delivery's header
carries two v1 signatures, newest first. webhooks.verify() tries every one
of them, so a rotation costs you no deliveries as long as your own copy is
rolled before the deadline. Needs webhooks:write, or fax:write on a
fax-account-scoped endpoint.
What we could not deliver
client.webhook_deliveries is evidence of failure, not a delivery history. A
delivery that reaches your endpoint leaves NO ROW: a row appears when an
attempt fails, moves along the retry ladder, and is removed the moment a
later attempt succeeds.
for delivery in client.webhook_deliveries.list(status="dead"):
print(delivery.event_id, delivery.event_type, delivery.status_code)
So status="dead" is the query this collection exists for — what an outage
cost you, and the only place that list exists. pending is everything still
on the ladder. There is no delivered: the API answers 400 to it rather
than handing back an empty page. An empty page for either real status is the
good news.
Filter by endpoint= and event_type= too, and read one row with
client.webhook_deliveries.get(delivery_id). The body that was POSTed is
never published here — only payload_sha256, the digest of the exact bytes
that were signed, so an integrator who kept what they received can prove it is
what was sent. Reading needs webhooks:read; a delivery borrows its
endpoint's reach, so a fax:read token sees the deliveries of
fax-account-scoped endpoints alone.
The async client
AsyncRingivo is the same client for programs already running on asyncio.
The constructor is identical, every method is awaited, and async with
replaces with:
import asyncio
from pathlib import Path
from ringivo import AsyncRingivo
async def main():
async with AsyncRingivo(
base_url="https://api.yourprovider.example",
client_id="0198c4a1-1f2e-7a3b-9c40-5f6e7d8a9b01",
client_secret="9tK2xr4mQ7vBnZ1sD5hL0pWfC8jY3aE6",
tenant="0198c4a1-3d4e-7f50-a1b2-c3d4e5f6a7b8",
scopes=["fax:read", "fax:write"],
) as client:
fax = await client.faxes.send(
fax_account="0198c4a1-3c4d-7e5f-9061-2b3c4d5e6f70",
to="+13025556789",
file=Path("chart-4471.pdf"),
)
terminal = {"delivered", "partial", "cancelled", "failed"}
while fax.status not in terminal: # a rendered PDF needs one of these
await asyncio.sleep(5)
fax = await client.faxes.get(fax.id)
if fax.status == "delivered":
pdf = await client.faxes.media(fax.id)
asyncio.run(main())
Outside a context manager, release the connections with await client.aclose() — the async spelling of close().
Everything else reads the same. The exceptions are the same classes, the
returned Fax, FaxPage and MediaLink are the same frozen dataclasses,
and webhooks.verify() is the same function: it is pure computation with no
network, so there is nothing to await.
Verify a webhook
Every delivery carries a Ringivo-Signature header. Check it before you
trust the body — this needs no client and no network:
from ringivo import SignatureVerificationError, webhooks
@app.post("/hooks/fax")
def receive(request):
try:
webhooks.verify(
request.body, # the RAW bytes
request.headers[webhooks.SIGNATURE_HEADER],
secret="whsec_...",
)
except SignatureVerificationError:
return Response(status=400)
event = json.loads(request.body)
...
return Response(status=202)
Two rules decide whether this works:
- Give it the raw body. Parsing the JSON and re-encoding it before verifying will fail, and correctly so — key order, escaping and number formatting are free choices no two encoders make alike. Reach for your framework's raw-body accessor.
- Answer any 2XX to accept. Deliveries are at-least-once: dedupe on
event_id, because a retry carries the same one.
verify() returns None and raises SignatureVerificationError on any
failure — a stale timestamp, the wrong secret, a malformed header. During a
secret rotation the header carries two signatures and either secret
verifies, so a rotation costs you no deliveries.
When something is refused
from ringivo import ApiError, AuthenticationError
try:
client.faxes.send(fax_account=account_id, to="not-e164", file=pdf)
except ApiError as error:
error.status_code # 422
error.code # "validation_failed" — the vocabulary to branch on
error.errors[0].detail # "The to field format is invalid."
error.errors[0].source # {"parameter": "to"}
AuthenticationError (a subclass) means the credential itself was refused —
the client had already replaced its token and retried once by then.
Connection failures, timeouts and TLS errors are httpx's own exceptions and
are deliberately not wrapped.
What is in the box
| Scope | ||
|---|---|---|
Ringivo(base_url, client_id, client_secret, *, tenant, customer=None, scopes=None, timeout=30.0) |
— | The client. A context manager, or call close(). tenant is required. scopes is spelled as a keyword but required too — an empty one raises. |
AsyncRingivo(…same arguments…) |
— | The asyncio twin. An async context manager, or await aclose(). Every method below is awaited. |
client.faxes.send(*, fax_account, to, file=…|urls=…, …) |
fax:write |
Send one fax. Returns the accepted Fax. |
client.faxes.get(fax_id, *, include=None) |
fax:read |
One fax, complete. |
client.faxes.list(*, filters…, after=None, before=None, page_size=None) |
fax:read |
A FaxPage: iterable, with next_cursor. Default page size 25, ceiling 100. |
client.faxes.cancel(fax_id) |
fax:write |
Withdraw a fax before it is answered. |
client.faxes.media(fax_id, *, format="pdf") |
fax:read |
The document's bytes. |
client.faxes.media_link(fax_id, *, format="pdf") |
fax:read |
The URL and its expiry, as a MediaLink. |
client.fax_accounts.list(*, customer=None, status=None, after=None, before=None, page_size=None) |
fax:read |
A FaxAccountPage: iterable, with next_cursor. |
client.fax_accounts.get(fax_account_id) |
fax:read |
One FaxAccount. |
client.fax_accounts.numbers(fax_account_id) |
fax:read |
Every FaxAccountNumber routed to it, all pages walked. |
client.fax_accounts.create(*, customer, name, header_text=…, default_from_e164=…, retention_days=…, retention_pages=…) |
fax-accounts:write |
Open an account for a customer. |
client.fax_accounts.update(fax_account_id, *, name=…, header_text=…, default_from_e164=…, retention_days=…, retention_pages=…, status=…) |
fax-accounts:write |
A sparse PATCH: only what you pass. |
client.fax_accounts.delete(fax_account_id) |
fax-accounts:write |
Delete the account and its pages. 409 while numbers route to it. |
client.fax_account_users.list(*, fax_account=None, user=None, after=None, before=None, page_size=None) |
fax:read |
A FaxAccountUserPage of grants: iterable, with next_cursor. The two filters are "who can see this account?" and "what can this person see?". |
client.fax_account_users.get(fax_account_user_id) |
fax:read |
One FaxAccountUser. |
client.fax_account_users.create(*, fax_account, user) |
fax-accounts:write |
Grant this user access to this account's content. Both are required; neither can be changed afterwards. |
client.fax_account_users.delete(fax_account_user_id) |
fax-accounts:write |
Withdraw the grant. The only way to undo one — there is no update route. |
client.webhook_endpoints.list(*, scope_type=None, scope_id=None, active=None, after=None, before=None, page_size=None) |
webhooks:read |
A WebhookEndpointPage: iterable, with next_cursor. A fax:read token sees fax-account-scoped rows only. |
client.webhook_endpoints.get(webhook_endpoint_id) |
webhooks:read |
One WebhookEndpoint. secret is always None here. |
client.webhook_endpoints.create(*, url, scope_type, scope_id, events=…, active=…) |
webhooks:write |
Register an endpoint. The only answer that carries the signing secret — store it. fax:write for a fax_account scope. |
client.webhook_endpoints.update(webhook_endpoint_id, *, url=…, events=…, active=…) |
webhooks:write |
A sparse PATCH: only what you pass. The scope cannot change. |
client.webhook_endpoints.delete(webhook_endpoint_id) |
webhooks:write |
Remove it. The fan-out stops; the deliveries stay. |
client.webhook_endpoints.rotate_secret(webhook_endpoint_id) |
webhooks:write |
Mint a new secret and start the 24-hour grace window. |
client.webhook_deliveries.list(*, endpoint=None, event_type=None, status=None, after=None, before=None, page_size=None) |
webhooks:read |
A WebhookDeliveryPage of what is still owed or was given up on. status="dead" is the one to ask after an outage. |
client.webhook_deliveries.get(webhook_delivery_id) |
webhooks:read |
One WebhookDelivery. |
webhooks.verify(payload, header, secret, *, tolerance=300) |
— | Raises unless the body is genuine and fresh. |
Fax, FaxAccount, FaxAccountNumber, FaxAccountPage,
FaxAccountUser, FaxAccountUserPage, FaxDocument, FaxPage,
MediaLink, WebhookDelivery, WebhookDeliveryPage, WebhookEndpoint
and WebhookEndpointPage are frozen dataclasses, and each
keeps the JSON it was built from in .raw — so a field the API adds after
this release reaches you without a new SDK.
NOT_GIVEN is the sentinel the create() and update() calls on
fax_accounts and webhook_endpoints default every optional argument to.
You never need to pass it; it exists so that None can mean "clear this
field", or "every event in scope", rather than "I said nothing".
Reaching an endpoint this client does not wrap
The table above is the fax and webhook surfaces. For anything else the API
offers, use client.request() — the same escape hatch in both clients,
awaited on the async one:
response = client.request("GET", "/v1/sip-trunks")
trunks = response.json()["data"]
It carries your credential, your timeout, your User-Agent and the same
typed errors, and it hands back the httpx.Response untouched: past that
line the JSON is the API's own, not one of the frozen objects above.
spec/openapi.yaml in this repository is the reference for what those
endpoints take and answer. The same shapes are generated into
ringivo._generated_types as TypedDicts, which your type checker can
read; that module is private, machine-written, and rewritten wholesale
whenever the spec changes.
Licence
MIT.
Release files for ringivo 0.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ringivo-0.7.0.tar.gz | 238.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ringivo-0.7.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 328.4 kB
Release files / ringivo-0.7.0.tar.gz
| Download URL | ringivo-0.7.0.tar.gz |
|---|---|
| Size | 238.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2d2b16f52da0c7ccd621065c841ae303336919f7c9aa2bde72d3fb0cc38fe89c
|
|
BLAKE2b-256 checksum How to use checksums |
c1ced028097f269547f566bead30ab34020a47495243afbf313acfe49bff5375
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency logRelease files / ringivo-0.7.0-py3-none-any.whl
| Download URL | ringivo-0.7.0-py3-none-any.whl |
|---|---|
| Size | 89.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5cd034ebf24cb33c7b56f540a6d71b2b434fa084116ee12c21cda6193dd78d67
|
|
BLAKE2b-256 checksum How to use checksums |
52b30096fbcf99e9cc6786da0f225e32db49529eaf6121001e6281178592cfb0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency log