Probato API v1

Upload QES-signed prescriptions and receive one-time verification QR codes.


Authentication

All API requests require a Bearer token in the Authorization header. API keys are issued by MeetOne GmbH and scoped per account.

Authorization: Bearer YOUR_API_KEY

Requests without a valid key return 401 Unauthorized. Inactive accounts return 401. Exceeding the per-account rate limit returns 429 Too Many Requests.


Upload Prescription

POST /api/v1/prescriptions

Upload a QES-signed PDF prescription. The server validates the signature, encrypts the PDF with a randomly generated key, and returns a QR code URL that carries the encryption key.

Request

Content-Type: multipart/form-data

Parameter Type Required Description
file File Yes QES-signed PDF file (max 10 MB, application/pdf)
expires_in_days Integer No Prescription validity in days (default: 28, min: 1, max: per account setting)
patient_name String Not yet Name of the person the medicine is for (§ 2 AMVV). Shown to the pharmacy on retrieval. Send it. This will become required; a prescription without it cannot tell the pharmacy who it is for.
patient_birth_date String Not yet Date of birth as YYYY-MM-DD. Cannot be in the future. Becomes required alongside patient_name. A value that is sent but cannot be read is rejected today.

Both values are encrypted with the same AES-256-GCM key as the PDF — the key that exists only in the QR code URL. The server can write them and can never read them back, and they are destroyed together with the PDF when the pharmacy's retrieval window closes.

Example Request

curl -X POST https://app.probato.eu/api/v1/prescriptions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@/path/to/signed-prescription.pdf" \
  -F "expires_in_days=28" \
  -F "patient_name=Erika Mustermann" \
  -F "patient_birth_date=1980-05-13"

Response (201 Created)

{
  "prescription_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "redemption_code": "K7M2-9XQP-4RTV",
  "qr_code_url": "https://app.probato.eu/r/K7M2-9XQP-4RTV",
  "qr_code_image_base64": "data:image/png;base64,iVBOR...",
  "expires_at": "2026-04-18T12:00:00Z",
  "patient_information_stored": true,
  "qes_validation": {
    "status": "valid",
    "doctor_name": "Dr. Anna Schmidt",
    "certificate_issuer": "EU-Trust GmbH"
  }
}

Important: redemption_code is the secret, and qr_code_url is the same secret in scannable form — twelve Crockford Base32 characters in three groups. The encryption key is derived from it and neither is ever stored on the server. This response is the only place either appears; if both are lost the prescription cannot be recovered and the prescriber must re-upload. Pass the URL through unchanged; do not re-encode it.

Print the redemption_code next to the QR code. A pharmacy with no camera and no QR-capable scanner — which is common — types it at /einloesen instead, and that is the only route that does not depend on the patient's own phone.

Migrating: prescriptions created before August 2026 returned a /v/<token>-<key> URL carrying a Base64url 256-bit key and no redemption_code. Those URLs keep working for as long as the prescriptions are valid; no action is needed. New integrations should read redemption_code and surface it to the patient alongside the QR code.

QR Code Image

The qr_code_image_base64 field contains a Base64-encoded PNG image (400×400px, high error correction) ready to embed in printouts or display in a UI:

<img src="<%= response['qr_code_image_base64'] %>" alt="QR Code" />

Invalidate Prescription

DELETE /api/v1/prescriptions/:id

Withdraw a prescription before it expires — a mistaken upload, or a prescription the practice has revoked. The redemption code stops working immediately and the encrypted PDF is deleted from storage. The prescription record and its audit trail are retained for legal compliance; nothing is erased, the prescription is marked invalidated.

Only active prescriptions can be invalidated. One that has already been redeemed, expired, or invalidated returns 409 Conflict. You can only invalidate prescriptions uploaded by your own account; any other id returns 404.

Example Request

curl -X DELETE https://app.probato.eu/api/v1/prescriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
  -H "Authorization: Bearer YOUR_API_KEY"

Response (200 OK)

{
  "prescription_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "invalidated"
}

A pharmacist opening the QR code of an invalidated prescription sees a page stating that the prescription was withdrawn by the issuer and cannot be redeemed.


Webhooks

There is no endpoint for reading a prescription back. Instead, Probato tells you when one leaves the active state, so you never have to poll and never have to guess. Set a webhook URL on your account and we will POST a signed JSON body to it on every status change.

Ask us to enable webhooks for your account. You will be given a signing secret beginning whsec_, shown once. Keep it out of your source tree.

Events

Event Sent when
prescription.redeemed A pharmacy has identified itself and retrieved the document. The code is spent.
prescription.expired The validity period ran out without a redemption.
prescription.invalidated The prescription was withdrawn — through DELETE /api/v1/prescriptions/:id or by us on request.

Request

POST /your/webhook/endpoint
Content-Type: application/json
User-Agent: Probato-Webhooks/1
X-Probato-Event: prescription.redeemed
X-Probato-Delivery: 0f9c1a02-7b3e-4a11-9d2f-6c5b8e410a77
X-Probato-Signature: t=1756500000,v1=6f1c…

{
  "event": "prescription.redeemed",
  "delivery_id": "0f9c1a02-7b3e-4a11-9d2f-6c5b8e410a77",
  "occurred_at": "2026-08-29T12:34:56Z",
  "data": {
    "prescription_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "status": "redeemed",
    "previous_status": "active",
    "expires_at": "2026-09-26T00:00:00Z"
  }
}

The body carries no patient data. You already hold prescription_id from the upload response, and that is the only identifier you need to match the event to your own record.

Verifying the signature

The X-Probato-Signature header carries a timestamp and an HMAC-SHA256 of "<timestamp>.<raw body>", keyed with your signing secret. Verify against the raw request body, before any JSON parsing or re-serialisation, and reject anything with a timestamp far from your own clock — that is what stops an old delivery being replayed at you.

timestamp, signature = request.headers["X-Probato-Signature"].split(",").map { |p| p.split("=", 2).last }

expected = OpenSSL::HMAC.hexdigest("SHA256", ENV["PROBATO_WEBHOOK_SECRET"], "#{timestamp}.#{request.raw_post}")

head :unauthorized unless
  ActiveSupport::SecurityUtils.secure_compare(expected, signature) &&
  Time.at(timestamp.to_i).after?(5.minutes.ago)

Delivery guarantees

Answer with any 2xx as soon as you have stored the event; do your own work afterwards. A response we cannot read as success is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, then abandoned. Anything you did not answer in 10 seconds counts as a failure.

Two properties to build against:


Error Responses

Status Error Code Description
401 unauthorized Missing, invalid, or inactive API key
422 file_required No PDF file provided in the request
422 invalid_content_type File is not a PDF (application/pdf required)
422 file_too_large File exceeds 10 MB limit
422 invalid_expires_in_days Value out of allowed range (1 to account maximum)
422 invalid_patient_birth_date Supplied patient_birth_date could not be read as YYYY-MM-DD, or is in the future
422 qes_required The document is not a valid qualified electronic signature (QES). The response also returns validation_status and signature_qualification (the level actually found).
404 not_found No prescription with this id in your account (DELETE)
409 not_invalidatable The prescription is not active; its current status is included in the response (DELETE)
503 validation_unavailable The signature validation service is temporarily unavailable; retry later
429 rate_limit_exceeded Too many requests per minute for this account

Error Response Format

{
  "error": "file_required",
  "detail": "A PDF file is required"
}

Verification Flow

The QR code URL directs the pharmacist to the Probato verification portal. The flow is:

  1. Pharmacist scans the QR code
  2. Enters their pharmacy IK number (9-digit Institutionskennzeichen)
  3. Sees a one-time access warning
  4. Confirms and downloads the prescription PDF

After download, the encrypted PDF is permanently deleted from storage. The QES metadata (document hash, signature, certificate) is retained for legal compliance.

One-time access: Each QR code can only be used once. After a pharmacist accesses the prescription, the token is permanently invalidated. The access is logged with timestamp, IP address, and IK number.


Security Model


QES Validation

The signature is validated at upload time against the EU Trusted List. Only a Qualified Electronic Signature (QES) is accepted — the legal requirement for a Privatrezept. The signature must validate as valid and reach the qualification level QESIG; anything else is rejected with qes_required and the response reports the qualification that was found.

Result Meaning Action
valid + QESIG Valid signature, qualified certificate on the EU Trusted List, qualified signature creation device Prescription accepted
valid, lower qualification Cryptographically valid but only an advanced signature (e.g. ADESIG) — not qualified Rejected — qes_required
indeterminate Qualified status could not be confirmed (e.g. trust chain incomplete) Rejected — qes_required
invalid Signature is broken, unsigned, or the certificate is not trusted Rejected — qes_required

Rejections are reported immediately so the issuer can correct the document (use a qualified signature) before re-uploading. If the validator is temporarily unavailable the upload is rejected with validation_unavailable (HTTP 503) — never stored unverified — so retry shortly.