Darstellung
Quickstart: TypeScript
Dieses Beispiel sendet ein PDF an einen Signer, wartet auf die Signatur und speichert das versiegelte PDF. Es nutzt nur fetch aus Node.js, also keine weiteren Pakete.
Du brauchst:
- Node.js 18 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:writeunddocuments: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
npm init -y
npm install --save-dev tsx
export RATUM_API_KEY=ratum_live_… # dein Key, nie ins Repository committenLege die Datei quickstart.ts 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:
ts
const API_URL = process.env.RATUM_API_URL ?? 'https://api.ratum.ch'
const API_KEY = process.env.RATUM_API_KEY ?? ''
type Problem = { status: number; code: string; detail?: string }
async function ratum<T>(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
const res = await fetch(`${API_URL}/api/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${API_KEY}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!res.ok) {
// API-Fehler kommen als application/problem+json mit stabilem `code` (Leitfaden "Fehlercodes").
// Ein vorgelagerter Proxy (z. B. 502/503) kann aber auch HTML liefern — dann den Rohtext zeigen.
if (res.headers.get('content-type')?.includes('problem+json')) {
const problem = (await res.json()) as Problem
throw new Error(`${method} ${path} → ${problem.status} ${problem.code}: ${problem.detail ?? ''}`)
}
throw new Error(`${method} ${path} → ${res.status}: ${(await res.text()).slice(0, 200)}`)
}
return (res.status === 204 ? undefined : await res.json()) as T
}GET /api-keys/current zeigt, ob der Key funktioniert, und nennt Konto und Scopes:
ts
const me = await ratum<{ user_email: string; key: { prefix: string; scopes: string[] } }>('GET', '/api-keys/current')
console.log(`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_heightsind 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-Keyist hier Pflicht. Bei einem Netzwerkfehler wiederholst du den Request mit demselben Key und bekommst dieselbe Antwort, aber kein zweites Dokument (Idempotenz).
ts
const file = await readFile(filePath)
const created = await ratum<CreateAndSendOut>(
'POST',
'/documents/create-and-send',
{
filename: basename(filePath),
content_type: 'application/pdf',
size_bytes: file.byteLength,
signers: [
{
email: signerEmail,
name: 'Erika Muster',
fields: [
// PDF-Punkte, Ursprung oben links. page_width/page_height = Grösse der 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.
{ 'Idempotency-Key': randomUUID() },
)
const documentId = created.document_id4. 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:
ts
const put = await fetch(created.upload_url, { method: 'PUT', headers: created.upload_headers, body: file })
// Der Storage antwortet mit XML (z. B. <Code>SignatureDoesNotMatch</Code>) — den Text mit ausgeben.
if (!put.ok) throw new Error(`Upload fehlgeschlagen: ${put.status} ${(await put.text()).slice(0, 300)}`)
await ratum('POST', `/documents/${documentId}/complete`)
console.log(`Dokument ${documentId} 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.
ts
const done = new Set(['signed', 'archived', 'declined', 'failed'])
const deadline = Date.now() + 60 * 60 * 1000 // höchstens 1 h warten
let doc = await ratum<DocumentDetail>('GET', `/documents/${documentId}`)
while (!done.has(doc.status) && !doc.signers.some((s) => s.status === 'expired')) {
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung: Status ${doc.status}`)
console.log(`Status ${doc.status} · Signer: ${doc.signers.map((s) => `${s.email}=${s.status}`).join(', ')}`)
await new Promise((r) => setTimeout(r, 10_000))
doc = await ratum<DocumentDetail>('GET', `/documents/${documentId}`)
}
if (doc.status === 'failed') throw new Error(`Versand gescheitert: ${doc.failure_code}`)status | Bedeutung |
|---|---|
draft | angelegt, Upload/Scan läuft noch (send_requested: true = Versand ist angefordert) |
pending | versendet, wartet auf Signaturen |
signed | alle haben signiert; das versiegelte PDF ist Sekunden später abrufbar |
archived | zusätzlich im Langzeitarchiv abgelegt (oder im GUI manuell archiviert) |
declined | ein Signer hat abgelehnt |
failed | Versand 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.
ts
if (doc.status === 'signed' || doc.status === 'archived') {
// Versiegelt wird kurz nach der letzten Signatur im Hintergrund; bis dahin liefert die Route 404.
let res: Response
for (let attempt = 1; ; attempt++) {
res = await fetch(`${API_URL}/api/v1/documents/${documentId}/download/sealed`, {
headers: { Authorization: `Bearer ${API_KEY}` },
})
if (res.status !== 404 || attempt === 30) break
await new Promise((r) => setTimeout(r, 2_000))
}
if (res.status === 404) throw new Error('PDF nach 60 s noch nicht versiegelt — später erneut abrufen.')
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status} ${(await res.text()).slice(0, 200)}`)
await writeFile(`${documentId}-signiert.pdf`, Buffer.from(await res.arrayBuffer()))
console.log(`Versiegeltes PDF gespeichert: ${documentId}-signiert.pdf`)
} else {
// Abgelehnt oder Frist abgelaufen: kein Erfolg — Exit-Code 1 für Skripte/Cron-Jobs.
const states = doc.signers.map((s) => `${s.email}=${s.status}`).join(', ')
throw new Error(`Nicht signiert: Status ${doc.status} · Signer: ${states}`)
}Ausführen
sh
npx tsx quickstart.ts vertrag.pdf signer@example.comDie 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.pdfDas Dokument erscheint auch in deinem ratum-Dashboard, dort mit dem Badge «via API».
Wenn etwas schiefgeht
| Meldung | Ursache und Lösung |
|---|---|
401 unauthenticated | Key fehlt, ist falsch kopiert, abgelaufen oder widerrufen |
403 plan_required | API-Zugang braucht einen bezahlten Tarif; Zahlungsmethode in ratum hinterlegen |
403 insufficient_scope | dem Key fehlt ein Scope; neuen Key mit documents:read, documents:write und documents:send erzeugen |
422 validation_error | Feld ragt über die Seite oder ein Pflichtfeld fehlt; Details stehen in errors |
Versand gescheitert: scan_infected | die 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 versiegelt | das Versiegeln dauert ungewöhnlich lange; später erneut /download/sealed abrufen |
502/503 mit HTML-Text | kurzzeitige Störung vor der API; nach kurzer Wartezeit erneut versuchen |
Alle Codes stehen unter Fehlercodes.
Mit dem SDK
@ratum/sdk nimmt dir Upload, Idempotency-Key, Retries bei 429 und transienten 5xx (mit Retry-After, höchstens 30 s Wartezeit) und das Polling ab. Es hat keine Abhängigkeiten und läuft ab Node 18.
bash
npm install @ratum/sdk # bzw. pnpm add @ratum/sdkts
// Quickstart mit dem TypeScript-SDK (@ratum/sdk). Eingebunden in docs.ratum.ch/quickstart/typescript;
// typgeprüft gegen sdk/typescript (Pfad-Alias in tsconfig.json), damit Methodennamen nie veralten.
import { writeFile } from 'node:fs/promises'
import { NotFoundError, Ratum } from '@ratum/sdk'
const ratum = new Ratum({ apiKey: process.env.RATUM_API_KEY ?? '' })
const doc = await ratum.createAndSend('./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 },
],
},
],
expiryDays: 14,
})
const done = await ratum.waitForCompletion(doc.id, { timeoutMs: 3_600_000 })
if (done.status !== 'signed' && done.status !== 'archived') {
throw new Error(`Nicht signiert: ${done.status} ${done.failure_code ?? ''}`)
}
// Das Siegel wird kurz nach `signed` geschrieben; bis dahin 404 (NotFoundError).
for (let attempt = 1; ; attempt++) {
try {
await writeFile(`${doc.id}-signiert.pdf`, await ratum.downloadSealedDocument(doc.id))
break
} catch (error) {
if (!(error instanceof NotFoundError) || attempt === 30) throw error
await new Promise((r) => setTimeout(r, 2_000))
}
}Wichtige Methoden: createAndSend, waitForCompletion, getDocument, downloadSealedDocument, getSigningLink und verifyWebhookSignature; alle Routen der API stehen zusätzlich unter ratum.api. API-Fehler sind typisierte Exceptions (RateLimitError, DocumentProcessingError, …) mit dem code aus Fehlercodes. Details: sdk/typescript/README.md im Repository.
Vollständiges Skript
quickstart.ts
ts
// ratum Quickstart (TypeScript, Node 18+, keine Abhängigkeiten).
//
// RATUM_API_KEY=ratum_live_… npx tsx quickstart.ts 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/typescript
import { randomUUID } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import { basename } from 'node:path'
// #region client
const API_URL = process.env.RATUM_API_URL ?? 'https://api.ratum.ch'
const API_KEY = process.env.RATUM_API_KEY ?? ''
type Problem = { status: number; code: string; detail?: string }
async function ratum<T>(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
const res = await fetch(`${API_URL}/api/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${API_KEY}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!res.ok) {
// API-Fehler kommen als application/problem+json mit stabilem `code` (Leitfaden "Fehlercodes").
// Ein vorgelagerter Proxy (z. B. 502/503) kann aber auch HTML liefern — dann den Rohtext zeigen.
if (res.headers.get('content-type')?.includes('problem+json')) {
const problem = (await res.json()) as Problem
throw new Error(`${method} ${path} → ${problem.status} ${problem.code}: ${problem.detail ?? ''}`)
}
throw new Error(`${method} ${path} → ${res.status}: ${(await res.text()).slice(0, 200)}`)
}
return (res.status === 204 ? undefined : await res.json()) as T
}
// #endregion client
type Signer = { id: string; email: string; status: 'pending' | 'signed' | 'expired' | 'declined' }
type DocumentDetail = {
id: string
status: 'draft' | 'pending' | 'signed' | 'archived' | 'declined' | 'failed'
scan_status: string
failure_code: string | null
signers: Signer[]
}
type CreateAndSendOut = {
document_id: string
upload_url: string
upload_headers: Record<string, string>
document: DocumentDetail
}
async function main(filePath: string, signerEmail: string): Promise<void> {
// #region whoami
const me = await ratum<{ user_email: string; key: { prefix: string; scopes: string[] } }>('GET', '/api-keys/current')
console.log(`Angemeldet als ${me.user_email} mit ${me.key.prefix}…`)
// #endregion whoami
// #region create
const file = await readFile(filePath)
const created = await ratum<CreateAndSendOut>(
'POST',
'/documents/create-and-send',
{
filename: basename(filePath),
content_type: 'application/pdf',
size_bytes: file.byteLength,
signers: [
{
email: signerEmail,
name: 'Erika Muster',
fields: [
// PDF-Punkte, Ursprung oben links. page_width/page_height = Grösse der 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.
{ 'Idempotency-Key': randomUUID() },
)
const documentId = created.document_id
// #endregion create
// #region upload
const put = await fetch(created.upload_url, { method: 'PUT', headers: created.upload_headers, body: file })
// Der Storage antwortet mit XML (z. B. <Code>SignatureDoesNotMatch</Code>) — den Text mit ausgeben.
if (!put.ok) throw new Error(`Upload fehlgeschlagen: ${put.status} ${(await put.text()).slice(0, 300)}`)
await ratum('POST', `/documents/${documentId}/complete`)
console.log(`Dokument ${documentId} hochgeladen — Versand folgt nach dem Malware-Scan.`)
// #endregion upload
// #region poll
const done = new Set(['signed', 'archived', 'declined', 'failed'])
const deadline = Date.now() + 60 * 60 * 1000 // höchstens 1 h warten
let doc = await ratum<DocumentDetail>('GET', `/documents/${documentId}`)
while (!done.has(doc.status) && !doc.signers.some((s) => s.status === 'expired')) {
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung: Status ${doc.status}`)
console.log(`Status ${doc.status} · Signer: ${doc.signers.map((s) => `${s.email}=${s.status}`).join(', ')}`)
await new Promise((r) => setTimeout(r, 10_000))
doc = await ratum<DocumentDetail>('GET', `/documents/${documentId}`)
}
if (doc.status === 'failed') throw new Error(`Versand gescheitert: ${doc.failure_code}`)
// #endregion poll
// #region download
if (doc.status === 'signed' || doc.status === 'archived') {
// Versiegelt wird kurz nach der letzten Signatur im Hintergrund; bis dahin liefert die Route 404.
let res: Response
for (let attempt = 1; ; attempt++) {
res = await fetch(`${API_URL}/api/v1/documents/${documentId}/download/sealed`, {
headers: { Authorization: `Bearer ${API_KEY}` },
})
if (res.status !== 404 || attempt === 30) break
await new Promise((r) => setTimeout(r, 2_000))
}
if (res.status === 404) throw new Error('PDF nach 60 s noch nicht versiegelt — später erneut abrufen.')
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status} ${(await res.text()).slice(0, 200)}`)
await writeFile(`${documentId}-signiert.pdf`, Buffer.from(await res.arrayBuffer()))
console.log(`Versiegeltes PDF gespeichert: ${documentId}-signiert.pdf`)
} else {
// Abgelehnt oder Frist abgelaufen: kein Erfolg — Exit-Code 1 für Skripte/Cron-Jobs.
const states = doc.signers.map((s) => `${s.email}=${s.status}`).join(', ')
throw new Error(`Nicht signiert: Status ${doc.status} · Signer: ${states}`)
}
// #endregion download
}
const [filePath, signerEmail] = process.argv.slice(2)
if (!API_KEY || !filePath || !signerEmail) {
console.error('Aufruf: RATUM_API_KEY=ratum_live_… npx tsx quickstart.ts <datei.pdf> <signer-email>')
process.exit(2)
}
main(filePath, signerEmail).catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err)
process.exit(1)
})