Skip to content

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: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
npm init -y
npm install --save-dev tsx
export RATUM_API_KEY=ratum_live_…   # dein Key, nie ins Repository committen

Lege 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_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).
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_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:

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}`)
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.

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.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/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/sdk
ts
// 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)
})

ratum · Talent Factory GmbH