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
/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
/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:
- At-least-once, not exactly-once. A delivery can arrive more than once — on retry, or because we re-sent it by hand after an outage on your side. Deduplicate on
delivery_id, which stays the same across every attempt at the same event. - No ordering guarantee. Events are not serialised per prescription. Compare
occurred_atbefore applying one, and never let an older event overwrite a newer state.
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:
- Pharmacist scans the QR code
- Enters their pharmacy IK number (9-digit Institutionskennzeichen)
- Sees a one-time access warning
- 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
- Zero-knowledge encryption: PDFs are encrypted with AES-256-GCM. The encryption key exists only in the QR code URL and is never stored server-side — not in the database, not in logs.
- Key handling: The key travels in the URL path (
/v/<token>-<key>) so that barcode scanners can reproduce it. It is stripped from the request path in middleware before anything is logged, handed to the browser in the response body, and removed from the address bar client-side. Every subsequent request carries it in the POST body, never in a URL. - Atomic redemption: Token invalidation uses an atomic database update (
UPDATE WHERE status = 'active') to prevent race conditions and double access. - Audit trail: All access events are logged with timestamps and IP addresses. Audit logs are retained for 10 years (§630f BGB).
- GDPR compliance: IP addresses in audit logs are pseudonymized after 90 days.
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.