Skip to main content

ringivo

The Python client for the Ringivo fax API: send a fax, read one, list them, cancel one, fetch its pages, and verify the webhooks that tell you what happened.

pip install ringivo

Python 3.10 or newer. The only runtime dependencies are httpx and attrs.

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

There is no default host, and none is compiled in. Your provider gives you the API root, a client id and a client secret; everything in this README uses https://api.yourprovider.example where yours goes.

The client exchanges your credentials for a bearer token on the first call, caches it until a minute before it expires, and replaces it if the server ever refuses one. You never handle the token.

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",
) 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.

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",
    ) 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

Ringivo(base_url, client_id, client_secret, *, scopes=None, timeout=30.0) The client. A context manager, or call close().
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=…, …) Send one fax. Returns the accepted Fax.
client.faxes.get(fax_id, *, include=None) One fax, complete.
client.faxes.list(*, filters…, after=None, before=None, page_size=None) A FaxPage: iterable, with next_cursor. Default page size 25, ceiling 100.
client.faxes.cancel(fax_id) Withdraw a fax before it is answered.
client.faxes.media(fax_id, *, format="pdf") The document's bytes.
client.faxes.media_link(fax_id, *, format="pdf") The URL and its expiry, as a MediaLink.
webhooks.verify(payload, header, secret, *, tolerance=300) Raises unless the body is genuine and fresh.

Fax, FaxDocument, FaxPage and MediaLink 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.

The full endpoint surface, generated from the OpenAPI document, is vendored at ringivo._generated for the resources this hand-written layer does not cover yet. It is private and its shape can change with a regeneration.

Licence

MIT.

Release files for ringivo 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ringivo 0.2.0
File Size Uploaded
ringivo-0.2.0.tar.gz 148.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ringivo 0.2.0
File Interpreter ABI Platform
ringivo-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 360.3 kB

Release files / ringivo-0.2.0.tar.gz

Download URL ringivo-0.2.0.tar.gz
Size 148.8 kB
Tags Source
SHA-256 checksum
How to use checksums
8c52d86e6eb41e92c40ab4c57a518e6e76f612ce04c06e8fabc0ae1956613ff5
BLAKE2b-256 checksum
How to use checksums
a096eaef1393d92738ea3a33a6a52e41d8b802265dcc85fe2d69c6ea99f6a0f7
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 Aug 19, 2026.

Transparency log

Release files / ringivo-0.2.0-py3-none-any.whl

Download URL ringivo-0.2.0-py3-none-any.whl
Size 211.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bb138e491dfb4a3f273c795f351d0bed6e6f0e71aab165371d27b68a94b73097
BLAKE2b-256 checksum
How to use checksums
9d2b4969994d4c039f1dbdb4446eb9233b79f959a23a331bfe479af7b4d02c38
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 Aug 19, 2026.

Transparency log

Release history Release notifications | RSS feed

0.14.0

2 release files

0.13.0

2 release files

0.12.0

2 release files

0.11.0

2 release files

0.10.0

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.2

2 release files

0.2.1

2 release files

This release

0.2.0 This release

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page