Skip to content

Quickstart: Python ​

Dieses Beispiel sendet ein PDF an einen Signer, wartet auf die Signatur und speichert das versiegelte PDF. Es braucht nur httpx.

Du brauchst:

  • Python 3.10 oder neuer
  • einen ratum-Account in einem bezahlten Tarif. API-Zugang gibt es nur mit hinterlegter Zahlungsmethode.
  • einen API-Key mit den Scopes documents:read, documents:write und documents:send (Key erzeugen)
  • ein PDF mit mindestens einer A4-Seite, z. B. vertrag.pdf

Zum Ausprobieren: Test-Key

Mit einem Test-Key (ratum_test_…) gehen keine Mails raus und es wird kein Kontingent belastet. Die Signier-Links holst du über den Link-Endpoint. Siehe Test-Modus.

1. Projekt anlegen ​

sh
mkdir ratum-quickstart && cd ratum-quickstart
python3 -m venv .venv && source .venv/bin/activate
pip install httpx
export RATUM_API_KEY=ratum_live_…   # dein Key, nie ins Repository committen

Lege die Datei quickstart.py mit dem vollständigen Skript unten an. Die folgenden Schritte erklären die einzelnen Teile.

2. Client und Key prüfen ​

Jeder Request trägt den Key als Bearer-Token. Fehler kommen als application/problem+json; der Helper wirft sie mit ihrem stabilen code weiter:

py
API_URL = os.environ.get("RATUM_API_URL", "https://api.ratum.ch")
API_KEY = os.environ.get("RATUM_API_KEY", "")

client = httpx.Client(
    base_url=f"{API_URL}/api/v1",
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=30,
)


def ratum(method: str, path: str, **kwargs: Any) -> Any:
    response = client.request(method, path, **kwargs)
    if response.is_error:
        # API-Fehler kommen als application/problem+json mit stabilem `code` (Leitfaden
        # "Fehlercodes"). Ein vorgelagerter Proxy (z. B. 502/503) kann aber auch HTML liefern.
        if "problem+json" in response.headers.get("content-type", ""):
            problem = response.json()
            raise RuntimeError(
                f"{method} {path} → {response.status_code} {problem.get('code')}: "
                f"{problem.get('detail', '')}"
            )
        raise RuntimeError(f"{method} {path} → {response.status_code}: {response.text[:200]}")
    return None if response.status_code == 204 else response.json()

GET /api-keys/current zeigt, ob der Key funktioniert, und nennt Konto und Scopes:

py
me = ratum("GET", "/api-keys/current")
print(f"Angemeldet als {me['user_email']} mit {me['key']['prefix']}…")

3. Dokument, Signer und Felder in einem Aufruf ​

POST /documents/create-and-send legt Dokument, Signer und Signaturfelder an und fordert den Versand an. Der eigentliche Versand passiert, sobald die Datei hochgeladen und geprüft ist.

  • Koordinaten sind PDF-Punkte (1 pt = 1/72 Zoll), Ursprung oben links. page_width/page_height sind die Masse der Seite und dienen der Prüfung, ob das Feld auf die Seite passt. Eine A4-Seite misst 595 × 842 pt.
  • Statt Koordinaten kannst du Felder auch per Platzhalter im Dokument setzen ("anchors": {}), siehe Anker.
  • Der Idempotency-Key ist hier Pflicht. Bei einem Netzwerkfehler wiederholst du den Request mit demselben Key und bekommst dieselbe Antwort, aber kein zweites Dokument (Idempotenz).
py
data = file_path.read_bytes()
created = ratum(
    "POST",
    "/documents/create-and-send",
    json={
        "filename": file_path.name,
        "content_type": "application/pdf",
        "size_bytes": len(data),
        "signers": [
            {
                "email": signer_email,
                "name": "Erika Muster",
                "fields": [
                    # PDF-Punkte, Ursprung oben links. page_width/page_height = Seite (A4).
                    {
                        "field_type": "signature",
                        "page": 1,
                        "x": 72,
                        "y": 700,
                        "width": 200,
                        "height": 50,
                        "page_width": 595,
                        "page_height": 842,
                    },
                    {
                        "field_type": "date",
                        "page": 1,
                        "x": 320,
                        "y": 715,
                        "width": 120,
                        "height": 24,
                        "page_width": 595,
                        "page_height": 842,
                    },
                ],
            }
        ],
        "expiry_days": 14,
    },
    # Pflicht bei createAndSend: ein Retry mit demselben Key legt kein zweites Dokument an.
    headers={"Idempotency-Key": str(uuid.uuid4())},
)
document_id = created["document_id"]

4. Datei hochladen ​

Die Antwort enthält eine vorsignierte upload_url. Lade die Datei mit PUT und den mitgelieferten upload_headers hoch, ohne Authorization-Header. Danach meldest du den Upload mit /complete ab:

py
# Die Upload-URL ist vorsigniert: ohne Authorization-Header, aber mit upload_headers.
put = httpx.put(created["upload_url"], content=data, headers=created["upload_headers"])
if put.is_error:
    # Nicht raise_for_status(): dessen Meldung enthielte die vorsignierte URL samt Signatur.
    # Der Storage antwortet mit XML (z. B. <Code>SignatureDoesNotMatch</Code>).
    raise RuntimeError(f"Upload fehlgeschlagen: {put.status_code} {put.text[:300]}")
ratum("POST", f"/documents/{document_id}/complete")
print(f"Dokument {document_id} hochgeladen — Versand folgt nach dem Malware-Scan.")

Ab hier läuft alles serverseitig: Malware-Scan, bei DOCX die Konvertierung nach PDF, dann der Versand. Die Einladung geht per E-Mail an den Signer.

5. Status verfolgen ​

GET /documents/{id} liefert Dokument-Status, Signer-Status und gegebenenfalls einen failure_code. Das Beispiel fragt alle 10 Sekunden ab. In Produktion nimmst du besser Webhooks.

py
done = {"signed", "archived", "declined", "failed"}
deadline = time.monotonic() + 3600  # höchstens 1 h warten
doc = ratum("GET", f"/documents/{document_id}")
while doc["status"] not in done and not any(s["status"] == "expired" for s in doc["signers"]):
    if time.monotonic() > deadline:
        raise RuntimeError(f"Zeitüberschreitung: Status {doc['status']}")
    signers = ", ".join(f"{s['email']}={s['status']}" for s in doc["signers"])
    print(f"Status {doc['status']} · Signer: {signers}")
    time.sleep(10)
    doc = ratum("GET", f"/documents/{document_id}")
if doc["status"] == "failed":
    raise RuntimeError(f"Versand gescheitert: {doc['failure_code']}")
statusBedeutung
draftangelegt, Upload/Scan läuft noch (send_requested: true = Versand ist angefordert)
pendingversendet, wartet auf Signaturen
signedalle haben signiert; das versiegelte PDF ist Sekunden später abrufbar
archivedzusätzlich im Langzeitarchiv abgelegt (oder im GUI manuell archiviert)
declinedein Signer hat abgelehnt
failedVersand gescheitert, Grund in failure_code (z. B. scan_infected)

6. Versiegeltes PDF laden ​

Nach der letzten Signatur versiegelt ratum das PDF im Hintergrund. Bis das fertig ist, meist nach wenigen Sekunden, antwortet /download/sealed mit 404. Das Beispiel wartet deshalb kurz.

py
if doc["status"] in {"signed", "archived"}:
    # Versiegelt wird kurz nach der letzten Signatur im Hintergrund; bis dahin kommt 404.
    for _attempt in range(30):
        sealed = client.get(f"/documents/{document_id}/download/sealed")
        if sealed.status_code != 404:
            break
        time.sleep(2)
    if sealed.status_code == 404:
        raise RuntimeError("PDF nach 60 s noch nicht versiegelt — später erneut abrufen.")
    if sealed.is_error:
        raise RuntimeError(f"Download fehlgeschlagen: {sealed.status_code} {sealed.text[:200]}")
    target = Path(f"{document_id}-signiert.pdf")
    target.write_bytes(sealed.content)
    print(f"Versiegeltes PDF gespeichert: {target}")
else:
    # Abgelehnt oder Frist abgelaufen: kein Erfolg — Exit-Code 1 für Skripte/Cron-Jobs.
    states = ", ".join(f"{s['email']}={s['status']}" for s in doc["signers"])
    raise RuntimeError(f"Nicht signiert: Status {doc['status']} · Signer: {states}")

Ausführen ​

sh
python quickstart.py vertrag.pdf signer@example.com

Die erwartete Ausgabe sieht so aus:

Angemeldet als du@firma.ch mit ratum_live_a1b2c3…
Dokument 6f1c… hochgeladen — Versand folgt nach dem Malware-Scan.
Status draft · Signer: signer@example.com=pending
Status pending · Signer: signer@example.com=pending
…  (jetzt im Postfach von signer@example.com signieren)
Versiegeltes PDF gespeichert: 6f1c…-signiert.pdf

Das Dokument erscheint auch in deinem ratum-Dashboard, dort mit dem Badge «via API».

Wenn etwas schiefgeht ​

MeldungUrsache und Lösung
401 unauthenticatedKey fehlt, ist falsch kopiert, abgelaufen oder widerrufen
403 plan_requiredAPI-Zugang braucht einen bezahlten Tarif; Zahlungsmethode in ratum hinterlegen
403 insufficient_scopedem Key fehlt ein Scope; neuen Key mit documents:read, documents:write und documents:send erzeugen
422 validation_errorFeld ragt über die Seite oder ein Pflichtfeld fehlt; Details stehen in errors
Versand gescheitert: scan_infecteddie Datei wurde als schädlich erkannt und entfernt
Nicht signiert: Status declined …ein Signer hat abgelehnt bzw. seine Frist ist abgelaufen; das Skript endet mit Exit-Code 1
PDF nach 60 s noch nicht versiegeltdas Versiegeln dauert ungewöhnlich lange; später erneut /download/sealed abrufen
502/503 mit HTML-Textkurzzeitige Störung vor der API; nach kurzer Wartezeit erneut versuchen

Alle Codes stehen unter Fehlercodes.

Mit dem SDK ​

ratum nimmt dir Upload, Idempotency-Key, Retries bei 429 und transienten 5xx (mit Retry-After, höchstens 30 s Wartezeit) und das Polling ab. Es gibt Ratum (synchron) und AsyncRatum mit denselben Methoden, ab Python 3.11.

bash
pip install ratum
py
"""Quickstart mit dem Python-SDK (``ratum``). Eingebunden in docs.ratum.ch/quickstart/python."""

import os
import time
from pathlib import Path

from ratum import NotFoundError, Ratum

with Ratum(os.environ["RATUM_API_KEY"]) as client:
    doc = client.create_and_send(
        "vertrag.pdf",
        signers=[
            {
                "email": "erika@example.com",
                "name": "Erika Muster",
                "fields": [
                    {
                        "field_type": "signature",
                        "page": 1,
                        "x": 72,
                        "y": 700,
                        "width": 200,
                        "height": 50,
                        "page_width": 595,
                        "page_height": 842,
                    }
                ],
            }
        ],
        expiry_days=14,
    )
    done = client.wait_for_completion(doc.id, timeout=3600)
    if done.status not in {"signed", "archived"}:
        raise SystemExit(f"Nicht signiert: {done.status} {done.failure_code or ''}")

    # Das Siegel wird kurz nach `signed` geschrieben; bis dahin 404 (NotFoundError).
    for attempt in range(30):
        try:
            Path(f"{doc.id}-signiert.pdf").write_bytes(client.download_sealed_document(doc.id))
            break
        except NotFoundError:
            if attempt == 29:
                raise
            time.sleep(2)

Wichtige Methoden: create_and_send, wait_for_completion, get_document, download_sealed_document, get_signing_link und verify_webhook_signature. API-Fehler sind typisierte Exceptions (RateLimitError, NotFoundError, …) mit dem code aus Fehlercodes. Details: sdk/python/README.md im Repository.

Vollständiges Skript ​

quickstart.py
py
"""ratum Quickstart (Python 3.10+, nur httpx).

    pip install httpx
    RATUM_API_KEY=ratum_live_… python quickstart.py vertrag.pdf du@example.com

Legt ein Dokument an, lädt die Datei hoch, lässt es an einen Signer senden, wartet auf die
Signatur und speichert das versiegelte PDF. Die Abschnitte (# region) sind in der Doku
eingebunden: docs.ratum.ch/quickstart/python
"""

import os
import sys
import time
import uuid
from pathlib import Path
from typing import Any

import httpx

# region client
API_URL = os.environ.get("RATUM_API_URL", "https://api.ratum.ch")
API_KEY = os.environ.get("RATUM_API_KEY", "")

client = httpx.Client(
    base_url=f"{API_URL}/api/v1",
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=30,
)


def ratum(method: str, path: str, **kwargs: Any) -> Any:
    response = client.request(method, path, **kwargs)
    if response.is_error:
        # API-Fehler kommen als application/problem+json mit stabilem `code` (Leitfaden
        # "Fehlercodes"). Ein vorgelagerter Proxy (z. B. 502/503) kann aber auch HTML liefern.
        if "problem+json" in response.headers.get("content-type", ""):
            problem = response.json()
            raise RuntimeError(
                f"{method} {path} → {response.status_code} {problem.get('code')}: "
                f"{problem.get('detail', '')}"
            )
        raise RuntimeError(f"{method} {path} → {response.status_code}: {response.text[:200]}")
    return None if response.status_code == 204 else response.json()


# endregion client


def main(file_path: Path, signer_email: str) -> None:
    # region whoami
    me = ratum("GET", "/api-keys/current")
    print(f"Angemeldet als {me['user_email']} mit {me['key']['prefix']}…")
    # endregion whoami

    # region create
    data = file_path.read_bytes()
    created = ratum(
        "POST",
        "/documents/create-and-send",
        json={
            "filename": file_path.name,
            "content_type": "application/pdf",
            "size_bytes": len(data),
            "signers": [
                {
                    "email": signer_email,
                    "name": "Erika Muster",
                    "fields": [
                        # PDF-Punkte, Ursprung oben links. page_width/page_height = Seite (A4).
                        {
                            "field_type": "signature",
                            "page": 1,
                            "x": 72,
                            "y": 700,
                            "width": 200,
                            "height": 50,
                            "page_width": 595,
                            "page_height": 842,
                        },
                        {
                            "field_type": "date",
                            "page": 1,
                            "x": 320,
                            "y": 715,
                            "width": 120,
                            "height": 24,
                            "page_width": 595,
                            "page_height": 842,
                        },
                    ],
                }
            ],
            "expiry_days": 14,
        },
        # Pflicht bei createAndSend: ein Retry mit demselben Key legt kein zweites Dokument an.
        headers={"Idempotency-Key": str(uuid.uuid4())},
    )
    document_id = created["document_id"]
    # endregion create

    # region upload
    # Die Upload-URL ist vorsigniert: ohne Authorization-Header, aber mit upload_headers.
    put = httpx.put(created["upload_url"], content=data, headers=created["upload_headers"])
    if put.is_error:
        # Nicht raise_for_status(): dessen Meldung enthielte die vorsignierte URL samt Signatur.
        # Der Storage antwortet mit XML (z. B. <Code>SignatureDoesNotMatch</Code>).
        raise RuntimeError(f"Upload fehlgeschlagen: {put.status_code} {put.text[:300]}")
    ratum("POST", f"/documents/{document_id}/complete")
    print(f"Dokument {document_id} hochgeladen — Versand folgt nach dem Malware-Scan.")
    # endregion upload

    # region poll
    done = {"signed", "archived", "declined", "failed"}
    deadline = time.monotonic() + 3600  # höchstens 1 h warten
    doc = ratum("GET", f"/documents/{document_id}")
    while doc["status"] not in done and not any(s["status"] == "expired" for s in doc["signers"]):
        if time.monotonic() > deadline:
            raise RuntimeError(f"Zeitüberschreitung: Status {doc['status']}")
        signers = ", ".join(f"{s['email']}={s['status']}" for s in doc["signers"])
        print(f"Status {doc['status']} · Signer: {signers}")
        time.sleep(10)
        doc = ratum("GET", f"/documents/{document_id}")
    if doc["status"] == "failed":
        raise RuntimeError(f"Versand gescheitert: {doc['failure_code']}")
    # endregion poll

    # region download
    if doc["status"] in {"signed", "archived"}:
        # Versiegelt wird kurz nach der letzten Signatur im Hintergrund; bis dahin kommt 404.
        for _attempt in range(30):
            sealed = client.get(f"/documents/{document_id}/download/sealed")
            if sealed.status_code != 404:
                break
            time.sleep(2)
        if sealed.status_code == 404:
            raise RuntimeError("PDF nach 60 s noch nicht versiegelt — später erneut abrufen.")
        if sealed.is_error:
            raise RuntimeError(f"Download fehlgeschlagen: {sealed.status_code} {sealed.text[:200]}")
        target = Path(f"{document_id}-signiert.pdf")
        target.write_bytes(sealed.content)
        print(f"Versiegeltes PDF gespeichert: {target}")
    else:
        # Abgelehnt oder Frist abgelaufen: kein Erfolg — Exit-Code 1 für Skripte/Cron-Jobs.
        states = ", ".join(f"{s['email']}={s['status']}" for s in doc["signers"])
        raise RuntimeError(f"Nicht signiert: Status {doc['status']} · Signer: {states}")
    # endregion download


if __name__ == "__main__":
    if not API_KEY or len(sys.argv) != 3:
        sys.exit(
            "Aufruf: RATUM_API_KEY=ratum_live_… python quickstart.py <datei.pdf> <signer-email>"
        )
    try:
        main(Path(sys.argv[1]), sys.argv[2])
    except (RuntimeError, httpx.HTTPError) as err:
        sys.exit(str(err))

ratum · Talent Factory GmbH