Endpoints
Customers
Section titled “Customers”The customer resource (/v1/customers) is your debtor register. Fields are English;
amounts and conventions as above.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/customers | customers:read | Cursor list. Filters: cvr, email (exact match), externalReference |
| POST | /v1/customers | customers:write | Create. 201 + Location |
| GET | /v1/customers/{id} | customers:read | |
| PATCH | /v1/customers/{id} | customers:write | Partial: omitted fields kept, null clears. Last-write-wins |
| GET | /v1/customers/{id}/departments | customers:read | Departments (contact person + reference) |
| POST | /v1/customers/{id}/departments | customers:write | |
| DELETE | /v1/customers/{id}/departments/{deptId} | customers:write | Idempotent 204 |
curl -X POST https://api.rieckflow.com/v1/customers \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: customer-crm-9001" \ -H "Content-Type: application/json" \ -d '{ "type": "company", "name": "Acme ApS", "cvr": "12345678", "email": "invoices@acme.example", "address": { "street": "Hovedgaden 1", "postalCode": "8000", "city": "Aarhus" }, "externalReference": "crm-9001", "metadata": { "segment": "b2b" } }'Customer objects carry hasCpr as a boolean only — Danish civil registration numbers can
never be read or written through this API. Email/phone can be filtered by exact match
only (they are stored encrypted with blind indexes; free-text search is deliberately
impossible).
Products & accounts
Section titled “Products & accounts”Your product catalogue (/v1/products), its categories (/v1/product-categories) and
the sales chart of accounts (/v1/accounts). All three share the products:* scopes.
Products are deactivated, never deleted (invoice history may reference them). A
vatRateBps of 0 requires a vatExemptionReason.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/products | products:read | Full list; ?active=true filters |
| POST | /v1/products | products:write | sku unique per organisation (case-insensitive) |
| GET | /v1/products/{id} | products:read | |
| PATCH | /v1/products/{id} | products:write | Partial; "active": false deactivates |
| GET | /v1/product-categories | products:read | Whole tree in one response; no cursor |
| POST | /v1/product-categories | products:write | parentId for a subcategory; omit for top level |
| GET | /v1/accounts | products:read | ?active=true filters |
| POST | /v1/accounts | products:write |
The category tree is exactly two levels deep. A parentId that is itself a subcategory
is rejected with 422 — the rule lives in the database, so the API does not restate it.
curl -X POST https://api.rieckflow.com/v1/products \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: product-ABO-12" \ -H "Content-Type: application/json" \ -d '{ "sku": "ABO-12", "name": "Subscription 12 months", "unitPriceOere": 9900, "vatRateBps": 2500 }'Quotes
Section titled “Quotes”The quote (/v1/quotes) is the offer the customer says yes or no to before anything is
ordered or invoiced. It has its own numbering series, it is frozen when sent (content
hash + sealed PDF), and the customer answers through their own link — one click, a drawn
signature or MitID, whichever you chose for the quote.
The API never accepts on the customer’s behalf. There is no /accept or /decline
call. The customer’s answer is evidence (who, when, from where — and the signature when one
was required), and an acceptance recorded by you needs a named person and a note in the
portal. The API reads the outcome (acceptedAt, acceptedBy, declinedAt, signedAt) and
gets it pushed (quote.accepted is the event an integration actually waits for). The signing
code is never returned.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/quotes | quotes:read | Cursor list. Filters: status (draft, sent, accepted, declined, expired, converted), customerId. expired is derived: a sent quote past validUntil lists as expired |
| POST | /v1/quotes | quotes:write | Creates a draft. 201 + Location. Same line shape as invoices; signatureMethod: none / simple / mitid |
| GET | /v1/quotes/{id} | quotes:read | Header + lines, acceptedBy (customer / creditor), orderId or invoiceId once converted |
| DELETE | /v1/quotes/{id} | quotes:write | Drafts only (422 quote_not_deletable) — a sent quote is a frozen record |
| POST | /v1/quotes/{id}/send | quotes:write | Freezes the content, seals the PDF and e-mails the customer their link when an address is on file. meta.delivery.channel is email / null; meta.delivery.url is the customer’s link — treat it as a secret and deliver it through your own channel when channel is null. Calling again resends with a new link; the old link stops working |
| POST | /v1/quotes/{id}/order | quotes:write | The accepted quote becomes an order (same operation as POST /v1/orders/from-quote). Idempotent per quote: an already converted quote returns its order with 200 |
| POST | /v1/quotes/{id}/invoice | quotes:write | The accepted quote becomes an invoice draft directly — the quote PDF and its attachments are attached as documentation. Replay returns the same invoice with 200. A quote that became an order cannot also become an invoice (422 quote_not_accepted) |
| GET | /v1/quotes/{id}/pdf | quotes:read | The sealed quote (document rate class). Exists after sending only (404 quote_pdf_not_found for a draft) |
| GET | /v1/quotes/{id}/attachments | quotes:read | Metadata only: filename, mime, sizeBytes, type (contract / terms / correspondence / other), sendWithQuote, sha256 |
| POST | /v1/quotes/{id}/attachments | quotes:write | { filename, mime, data (base64), type, sendWithQuote } — the contract or terms the customer must see before signing. Max 10 MB, malware-scanned (422 malware_detected), insert-only. document rate class |
| GET | /v1/quotes/{id}/attachments/{attachmentId} | quotes:read | The raw file, always Content-Disposition: attachment |
| GET | /v1/quotes/{id}/timeline | quotes:read | Newest first. kind: created, sent, resent, viewed, signed, accepted, declined, expired, converted; actor: customer / user / system |
curl -X POST https://api.rieckflow.com/v1/quotes \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: quote-2026-0042" \ -H "Content-Type: application/json" \ -d '{ "customerId": "<customer uuid>", "validUntil": "2026-10-31", "heading": "Roof renovation", "signatureMethod": "simple", "lines": [ { "description": "Roofing", "quantity": 10, "unitPriceOere": 100000, "vatRateBps": 2500 } ] }'
# Send it. Without an e-mail address on the customer, channel is null and url is yours to deliver.curl -X POST https://api.rieckflow.com/v1/quotes/<quote uuid>/send \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: quote-2026-0042-send-1"
# ... the customer accepts through their link; you receive quote.accepted ...curl -X POST https://api.rieckflow.com/v1/quotes/<quote uuid>/order \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: quote-2026-0042-order"Quote events: quote.sent (resent: true on a resend — the customer got a new link),
quote.viewed (via: link or code), quote.accepted (acceptedBy: customer or
creditor, signed), quote.declined and quote.expired (recorded when a customer answers a
quote past its validUntil). The ids live in data (quoteId, customerId); there is no
quote block. Amounts, dates, hashes and classifications only — never the customer snapshot,
the acceptance note or the signature evidence.
Orders
Section titled “Orders”The order (/v1/orders) is the step between a quote and an invoice: what the customer
ordered, confirmed in writing before the work is done. It has its own numbering series
(it never consumes an invoice number), and one order can produce several invoices —
advances, stages, partial deliveries. The remainder is never a counter: it is derived from
what has actually been invoiced, line by line.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/orders | orders:read | Cursor list. Filters: status (draft, confirmed, partially_invoiced, invoiced, cancelled), customerId |
| POST | /v1/orders | orders:write | Creates a draft. 201 + Location. Same line shape as invoices |
| POST | /v1/orders/from-quote | orders:write | { "quoteId" } — an accepted quote becomes an order; lines are copied. Idempotent per quote: a quote already converted returns its order with 200 |
| GET | /v1/orders/{id} | orders:read | Header + lines, each with invoicedQuantity / remainingQuantity |
| DELETE | /v1/orders/{id} | orders:write | Drafts only, and not drafts born from a quote (422 order_not_deletable) — cancel instead |
| POST | /v1/orders/{id}/confirm | orders:write | Confirm and send in one step: freezes the lines, seals the confirmation PDF and sends it. meta.delivery.channel is email / digital_post / letter / null |
| POST | /v1/orders/{id}/invoice | orders:write | Creates an invoice draft. { "lines": [{ "orderLineId", "quantity" }] } invoices a part; empty body invoices the remainder. 201 + Location: /v1/invoices/{id} |
| POST | /v1/orders/{id}/cancel | orders:write | Closes the open remainder; invoices already produced remain. Fully invoiced orders cannot be cancelled — credit instead |
| GET | /v1/orders/{id}/invoices | orders:read | The invoices the order produced, with status and balance — the read path partial invoicing depends on |
| GET | /v1/orders/{id}/timeline | orders:read | kind: created, confirmed, sent, resent, invoiced, invoice_released, cancelled |
| GET | /v1/orders/{id}/pdf | orders:read | The sealed confirmation (document rate class). Exists after confirmation only |
curl -X POST https://api.rieckflow.com/v1/orders \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: order-4711" \ -H "Content-Type: application/json" \ -d '{ "customerId": "<customer uuid>", "deliveryDate": "2026-10-15", "lines": [ { "description": "Roofing", "quantity": 10, "unitPriceOere": 100000, "vatRateBps": 2500 } ] }'
# Invoice 4 of the 10 units now; the order becomes partially_invoiced.curl -X POST https://api.rieckflow.com/v1/orders/<order uuid>/invoice \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: order-4711-stage-1" \ -H "Content-Type: application/json" \ -d '{ "lines": [ { "orderLineId": "<line uuid>", "quantity": 4 } ], "paymentTermsDays": 14 }'An order confirmation never collects money — no payment code, no due date. A deposit is a
real invoice with its own number. Quantities beyond the remainder are rejected by the database
(422 quantity_exceeds_remaining); remainingOere on the order and unpaidOere on the list
answer two different questions — what is left to invoice, and what is left to pay.
Order events: order.created, order.confirmed, order.invoiced (fired per invoicing,
partial included — the payload’s status says whether more remains) and order.cancelled.
The ids live in data (orderId, customerId, invoiceId); there is no order block.
Payment plans
Section titled “Payment plans”A payment plan (/v1/payment-plans) lets a customer pay an overdue balance in instalments
instead of going to collection. It covers all the customer’s open invoices (a plan is per
customer, not per invoice), it is a proposal until the customer signs the debt acknowledgement
with MitID, and while it is active the reminder chain on the covered invoices is paused — not
switched off. Instalments are expectations; the payments on the covered invoices are the truth,
so partial and early payments simply count.
The API never activates a plan. Activation is the customer’s signature, delivered through
the signing provider’s evidence — there is no /activate. You read status/activatedAt and
receive payment_plan.activated. Sending the agreement for signature is done from the portal for
now. No card fields, ever: autopay cards are enrolled by the customer with their own purpose.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/customers/{id}/payment-plan-terms | paymentplans:read | eligible, the open invoices a plan would cover, the suggested plans (3/6/12/… — any instalments from 2 to maxInstalments with an instalment ≥ minInstalmentOere is admissible), firstInstalmentDate. reason: terms_disabled / nothing_admissible |
| GET | /v1/payment-plans | paymentplans:read | Cursor list. Filters: status (proposed, active, fulfilled, defaulted, cancelled), customerId |
| POST | /v1/payment-plans | paymentplans:write | { customerId, instalments, note?, replyBy?, offerAutopay? } — creates a proposal on the customer’s whole open balance. You send only the count; amounts are computed from the balance and your terms, and recomputed by the database. One live plan per customer (409 payment_plan_conflict). 201 + Location; customerUrl is the customer’s own page — treat it as a secret |
| GET | /v1/payment-plans/{id} | paymentplans:read | Covered invoices (balance at start and now), the schedule, cumulative progress while active, remindersPaused |
| POST | /v1/payment-plans/{id}/close | paymentplans:write | { outcome: cancelled | defaulted | fulfilled, note? }. This is where the reminder chain wakes up: paused days are booked on the covered invoices and the clock continues where it stopped. meta.invoicesResumed. 422 payment_plan_not_live when already closed |
| GET | /v1/invoices/{id}/payment-plan | paymentplans:read | The proposed or active plan covering the invoice — the pause made readable per invoice. 404 payment_plan_not_found when the cadence runs normally |
# What may this customer be offered?curl https://api.rieckflow.com/v1/customers/<customer uuid>/payment-plan-terms \ -H "Authorization: Bearer $RIECK_API_KEY"
# Propose 6 instalments on the whole open balance.curl -X POST https://api.rieckflow.com/v1/payment-plans \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: plan-2026-0007" \ -H "Content-Type: application/json" \ -d '{ "customerId": "<customer uuid>", "instalments": 6, "replyBy": "2026-10-15", "offerAutopay": true }'
# ... the customer signs; you receive payment_plan.activated; each payment yields payment_plan.instalment_paid ...Payment-plan events: payment_plan.created (a proposal — binds nobody yet), payment_plan.activated
(the customer signed; reminders on the covered invoices are now paused; the payload carries the
frozen instalment amounts), payment_plan.instalment_paid (a payment landed on a covered invoice
while the plan was active — amountOere plus the plan’s cumulative paidOere/remainingOere;
correlate with payment.received for the payment id), payment_plan.defaulted (the cadence
terminated after a demand, or you closed it as defaulted — wasActive) and payment_plan.closed
(outcome: fulfilled or cancelled). The ids live in data (planId, customerId,
invoiceId); there is no payment_plan block, and the payload never carries the customer’s
link, your note or any card data.
Invoices
Section titled “Invoices”The core resource. An invoice starts as a draft, is issued (assigned its sequential number and a hash-locked PDF, atomically — Danish bookkeeping law requires an unbroken sequence), then sent. Issued invoices are immutable.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/invoices | invoices:read | Cursor list. Filters: status, customerId, externalReference |
| POST | /v1/invoices | invoices:write | Creates a draft. 201 + Location |
| GET | /v1/invoices/{id} | invoices:read | Header + lines |
| PUT | /v1/invoices/{id} | invoices:write | Full replace, drafts only (422 invoice_not_editable otherwise). Deliberately PUT: lines are always recalculated as a whole |
| DELETE | /v1/invoices/{id} | invoices:write | Drafts only |
| POST | /v1/invoices/{id}/issue | invoices:write | Optional body { "issueDate", "dueDate" }. Returns the invoice with its number |
| POST | /v1/invoices/{id}/send | invoices:write | Emails the PDF to the customer; resend replays |
| GET | /v1/invoices/{id}/pdf | invoices:read | The WORM-locked PDF (document rate class) |
| GET | /v1/invoices/{id}/timeline | invoices:read | Stable event codes: created, issued, sent, delivered, opened, reminder, payment, collections_ready, collections, … |
curl -X POST https://api.rieckflow.com/v1/invoices \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: invoice-order-4711" \ -H "Content-Type: application/json" \ -d '{ "customerId": "<customer uuid>", "lines": [ { "description": "Consulting", "quantity": 10, "unitPriceOere": 95000, "vatRateBps": 2500 } ], "paymentTermsDays": 14, "externalReference": "order-4711", "metadata": { "shopOrderId": "4711" } }'The one-call shortcut
Section titled “The one-call shortcut”The fastest integration in Danish invoicing — create the customer, the invoice, issue it (sequential number + locked PDF) and email it, in one call:
curl -X POST "https://api.rieckflow.com/v1/invoices?issue=true&send=true" \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: order-4712" \ -H "Content-Type: application/json" \ -d '{ "customer": { "type": "company", "name": "Acme ApS", "cvr": "12345678", "email": "invoices@acme.example", "externalReference": "crm-9001" }, "lines": [ { "description": "Order 4712", "quantity": 1, "unitPriceOere": 80000 } ], "externalReference": "order-4712" }'- Pass
customer(inline) orcustomerId— never both. Inline customers are found-or-created (matched onexternalReference, then CVR, then email) and never overwritten; inline creation also requires thecustomers:writescope. send=trueimpliesissue=true. Creation + issuing is atomic; sending runs after — a mail failure never rolls back an issued invoice. Checkmeta.send({"status": "sent"}or{"status": "failed", "code": "customer_email_missing"}):201means “the invoice exists”, not “the email arrived”.- The query flags are part of the idempotency hash: replaying the same key with different
flags returns
409 idempotency_conflict, never the wrong response. - Everything else — reminders per your settings, and collections via
POST /v1/invoices/{id}/collect(coming) — happens on our side. Subscribe to webhooks for the rest of the story.
Amount fields on responses: netOere, vatOere, grossOere, paidOere, creditedOere,
reminderFeeOere and balanceOere — what the customer owes right now
(gross + reminder fees − paid − credited), always computed, never stale.
Collections — the bridge no one else has
Section titled “Collections — the bridge no one else has”POST /v1/invoices/{id}/collect (scope: cases:write)Hands an unpaid, overdue invoice over to debt collection — same engine, same compliance
gates as everything else (Danish 10-day demand letter, fee caps, the works). The invoice
must be issued, past due and not fully paid. 201 + Location: /v1/cases/{caseId};
calling again returns 200 with the same case ("replayed": true). Rejections are
specific: invoice_not_due, invoice_already_paid, reminder_flow_incomplete,
only_fees_outstanding.
From there, GET /v1/cases/{caseId} and the sag.* webhook events tell the story.
Payments
Section titled “Payments”| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/invoices/{id}/payments | payments:read | The invoice’s payments, newest first |
| POST | /v1/invoices/{id}/payments | payments:write | Register a manual payment (bank transfer). Body { "amountOere", "paymentDate" } |
| GET | /v1/payments | payments:read | Org-wide cursor list — built for nightly reconciliation |
| GET | /v1/payments/{id}/receipt | payments:read | Creditor-branded receipt PDF (generated moments after registration — a 404 right away means retry shortly) |
Your Idempotency-Key is the bookkeeping key: the same key never registers a payment
twice, no matter when the retry arrives, and replays return the original response. The
response reports meta.outcome (partial / paid_in_full / registered) and the
updated invoice with its balanceOere.
Card and wallet payments arrive by themselves (via payment links and the coming embedded
checkout) and show up in the same lists with "source": "quickpay" — plus a
payment.received webhook either way. Older rows may still carry the historical sources
"verifone", "altapay" and "mobilepay"; treat source as an opaque string, not a closed
enum.
Payment links
Section titled “Payment links”POST/GET /v1/invoices/{id}/payment-link (payments:write / payments:read)Mints the invoice’s stable payment link (idempotent — POST and GET return the same
link). The response carries url (honours your verified custom payment domain), the
128-bit reference and the short typeable paymentCode (for pay.rieckflow.com). The checkout
session itself is created when the customer clicks — always for the current balance,
so a link sent before a partial payment collects the remainder. Card data never touches
your systems. Drafts have no link (422 invoice_not_issued).
Checkout sessions for your own UI (POST /v1/checkout-sessions, payments:write). Your
server posts {"invoiceId": "…"} and gets back reference, embedUrl, hostedUrl,
paymentCode and an indicative balanceOere. Your browser only ever loads embedUrl — the
reference is an unguessable capability that can pay exactly this one invoice, so there is no
publishable key to leak. The call is idempotent: the reference is minted once per invoice, and a
session shares it with the invoice’s payment link.
balanceOere is indicative on purpose. The gateway session is minted when the payer clicks,
always on the CURRENT outstanding amount — so a part payment in between lowers what they are
asked for, rather than charging a stale figure. Drafts are rejected with 422 invoice_not_issued, fully settled invoices with 422 invoice_already_settled.
Credit notes
Section titled “Credit notes”Credit notes share the invoice table and number series ("type": "creditNote") but have
their own resource path. Crediting is full, once per invoice, and requires a reason
(Danish bookkeeping law — the trail must explain why). The credit note is issued and
PDF-locked in the same call; the original invoice’s creditedOere and balanceOere
reflect it immediately.
| Method | Path | Scope | Notes |
|---|---|---|---|
| POST | /v1/invoices/{id}/credit | invoices:write | Body { "reason": "…" }. 201 + the credit note; 422 invoice_not_creditable if not issued / already credited / in collections |
| GET | /v1/creditnotes | invoices:read | Cursor list; ?customerId= filter |
| GET | /v1/creditnotes/{id} | invoices:read | Header + lines; creditsInvoiceId points at the original |
| GET | /v1/creditnotes/{id}/pdf | invoices:read | WORM PDF (document class) |
Note on status: overdue reflects the reminder engine having acted, not the calendar.
To gate services on “does this customer owe money”, use balanceOere (and the balance
endpoints arriving with the past-due milestone) — not status.
Reminders (dunning)
Section titled “Reminders (dunning)”Rieck runs the reminder engine for you on the cadence you configure — most integrations never call these endpoints. They exist for two things: reading the reminder history, and sending the next reminder now, outside the cadence.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/invoices/{id}/reminders | reminders:read | Sent/failed reminders (type: reminder / payment_reminder) with feeOere and sentAt; meta.maxReminders + meta.nextReminder (the engine’s next planned reminder and date, or null) |
| POST | /v1/invoices/{id}/reminders | reminders:write | Send the next reminder immediately. 200 + { reminderNumber, feeOere, collectionsReady } |
| GET | /v1/reminder-settings | reminders:read | Effective configuration (defaults filled in: cadence 7/14/21 days, fee at the statutory cap) |
| PUT | /v1/reminder-settings | reminders:write | Full replacement of the configuration (settings + cadence written atomically) |
The ad-hoc POST skips the cadence plan but never the Danish §9b floor, which is enforced in the database no matter who asks:
- The invoice must actually be overdue with a positive balance — otherwise
422 reminder_not_eligible. - At most 3 reminders per invoice, ever. After the last one
collectionsReadyistrue. - The reminder fee is only charged when the statutory interval since the previous
reminder has passed. Send sooner and the reminder still goes out — with
feeOere: 0. The floor cannot be bypassed; only the fee lapses. - A customer without an e-mail address yields
422 customer_email_missing(unless you enabledletterFallback, in which case a physical letter is sent instead).
PUT /v1/reminder-settings configures aggressiveness within the floor: fewer
reminders (maxReminders 0–3), a lower fee (feeOere, null = statutory cap, currently
10000 øre), a tighter or looser cadence (strictly increasing days after due date), the
free-of-charge paymentReminder before the fee-bearing reminders, letterFallback, and
autoCollections (hand the invoice to debt collection automatically after the final
reminder — the bridge described under invoices).
GET /v1 — discovery
Section titled “GET /v1 — discovery”Requires api:discovery. Returns API version, your organisation id and the key’s scopes.
POST /v1/cases — create a collection case
Section titled “POST /v1/cases — create a collection case”Requires cases:write and an Idempotency-Key header. Strict JSON body, max 64 KiB:
curl -X POST https://api.rieckflow.com/v1/cases \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: order-4711" \ -H "Content-Type: application/json" \ -d '{ "externalReference": "order-4711", "fordringer": [ { "hovedstolOere": 125000, "forfaldsdato": "2026-06-01" } ], "skyldnere": [ { "skyldnerType": "virksomhed", "navn": "Eksempel ApS", "cvr": "12345678" } ] }'201 on creation, 200 + meta.replayed: true on a safe replay. The response carries the
Rieck case id, case number, your externalReference, status and timestamps; Location
points at the status route.
The case is created in OPRETTET (created) state — the API never bypasses document
control, activation gates or compliance. Danish collection law (10-day demand letter,
fee caps, limitation periods) is enforced by the same engine the portal uses.
GET /v1/cases/{caseId} — case status
Section titled “GET /v1/cases/{caseId} — case status”Requires cases:read. Returns PII-free, event-sourced status: state, cross-cutting
status, event version and UTC timestamps. Unknown ids and other tenants’ ids both return
the same neutral 404 case_not_found.
Poll this — or better, subscribe to webhooks (below) and use polling only to reconcile.
POST /v1/terms-acceptance — terms-acceptance evidence
Section titled “POST /v1/terms-acceptance — terms-acceptance evidence”Requires terms:accept. Call it at the moment your customer accepts your terms
of sale; the record becomes admissible evidence if the claim is later disputed. Max 32 KiB:
curl -X POST https://api.rieckflow.com/v1/terms-acceptance \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: order-4711-terms" \ -H "Content-Type: application/json" \ -d '{ "eksternOrdreRef": "order-4711", "betingelseVersion": "2026-01-01", "betingelseHash": "<sha256-hex of the exact accepted terms text>", "accepteretKl": "2026-07-27T09:00:00+00:00", "idempotensnoegle": "order-4711-terms" }'Provide fakturaId (if you know the Rieck invoice) or eksternOrdreRef (your own
order reference — it is linked when the invoice/case is created later). betingelseHash
must be the SHA-256 of the exact terms text the customer saw — that is what makes the
evidence strong. 201 for a new record, 200 for an idempotent duplicate. The body’s
idempotensnoegle deduplicates the evidence record itself; simplest is to reuse the
same value as the Idempotency-Key header.
Subscriptions
Section titled “Subscriptions”Recurring invoicing (/v1/subscriptions): the subscription is a recipe; every
occurrence is an ordinary invoice in the same unbroken number series, with the same
hash-locked PDF, the same reminder flow and — because Rieck also owns collections — a
place for a failed renewal to end. The engine issues one period per run, never a
surprise pile.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/subscriptions | subscriptions:read | Cursor list. Filters: status (active/paused/cancelled), customerId, nextInvoiceBefore (YYYY-MM-DD), externalReference |
| POST | /v1/subscriptions | subscriptions:write | Create. 201 + Location |
| GET | /v1/subscriptions/{id} | subscriptions:read | Includes invoiceCount, periodsBehind, latestInvoice |
| PATCH | /v1/subscriptions/{id} | subscriptions:write | Partial: omitted fields kept. Never touches already-issued invoices |
| POST | /v1/subscriptions/{id}/pause | subscriptions:write | Only from active |
| POST | /v1/subscriptions/{id}/resume | subscriptions:write | Only from paused |
| POST | /v1/subscriptions/{id}/cancel | subscriptions:write | Final — a cancelled subscription cannot be revived |
| GET | /v1/subscriptions/{id}/invoices | subscriptions:read | The generated invoices, newest period first |
| GET | /v1/subscriptions/{id}/next_invoice | subscriptions:read | Preview of the next occurrence (date, lines, VAT, total) — nothing is issued. data: null when nothing is coming |
| POST | /v1/subscriptions/{id}/run | subscriptions:write | Issue the next due period now. document budget |
curl -X POST https://api.rieckflow.com/v1/subscriptions \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: sub-crm-77" \ -H "Content-Type: application/json" \ -d '{ "customerId": "…", "name": "Hosting monthly", "interval": { "unit": "month", "count": 1 }, "startDate": "2026-08-01", "dueDays": 14, "autoSend": true, "lines": [{ "description": "Hosting", "quantity": 1, "unitPriceOere": 100000 }] }'The contract’s guard-rails, all enforced server-side:
statusis never a writable field. Transitions are named endpoints because they are asymmetric —cancelis final; an invalid transition answers422 invalid_transitionwith an explanation, never a silent no-op.nextInvoiceDatecannot be set. It advances only through the run receipt (unique per period in the database) — that is what makes double-invoicing impossible./runrespects the engine’s lease and run receipt. Two calls for the same period produce one invoice; a future period cannot be run early (422 subscription_not_due).- Card data is never a write path — no PAN, token or agreement id anywhere in this resource. (A headless enrolment path for stored cards is on the roadmap.)
- Changing the recipe (
PATCH) affects future occurrences only; issued invoices are immutable bookkeeping records.
Subscriptions carry externalReference and metadata with the same contract as customers
and invoices (see External references & metadata): mirror your own subscription id from
day one, filter with ?externalReference=, duplicates answer 422 external_reference_in_use, and portal edits never silently clear your keys.
POST /v1/batch — bulk operations
Section titled “POST /v1/batch — bulk operations”Built for migration: up to 100 operations in one call (importing 5,000 invoices as single calls against the 300/min write budget takes three hours; as batches it takes minutes). Operations run sequentially, each in its own transaction — partial success is reported per operation and the batch never rolls back as a whole.
POST /v1/batch{ "operations": [ { "op": "customer.create", "params": { "type": "company", "name": "Acme ApS", "cvr": "12345678" } }, { "op": "invoice.create", "params": { "customer": { "type": "company", "name": "Acme ApS", "cvr": "12345678" }, "issue": true, "lines": [{ "description": "Migrated invoice", "quantity": 1, "unitPriceOere": 80000, "vatRateBasisPoints": 2500 }] } }, { "op": "payment.register", "params": { "invoiceId": "…", "amountOere": 100000, "paymentDate": "2026-01-15" } } ]}Response: 200 with data.results[] (one entry per operation: index, op, status,
and data or error in exactly the shape the single endpoint would have returned) plus
meta: { total, succeeded, failed }.
Rules worth knowing:
- Ops:
customer.create(needscustomers:write),invoice.create(needsinvoices:write;issue: trueissues in the same transaction; inlinecustomerdoes find-or-create and also needscustomers:write),payment.register(needspayments:write). Scopes are checked per operation — a missing scope fails that operation (403), never the whole batch. - Operations cannot reference each other’s results within one batch (ids are assigned at
execution). For customer-then-invoice, use the inline
customeron the invoice instead. - There is deliberately no send operation — a migration of historical invoices must never e-mail a thousand customers.
- Your
Idempotency-Keycovers the whole batch (replay returns the stored response), and eachpayment.registerbooks onkey:index— retries never double-register. - The batch call draws from the
documentbudget (120/min), since it may issue invoices.
Start links — things that need a human
Section titled “Start links — things that need a human”Four things can never become pure API calls. The API starts them and returns a link a
human opens in a browser (portal login first) — it never pretends to complete them. Call
the same endpoint again to read the outcome; all need settings:write:
| Method | Path | Notes |
|---|---|---|
| POST | /v1/verification-sessions | MitID Erhverv verification (the payout gate). Response: { url, status: "pending"|"verified", verifiedAt, cvr, payoutGateActive } — url is null once verified. 503 verification_unavailable if the broker is not configured |
| POST | /v1/card-setup-sessions | The card paying Rieck’s platform subscription. Card data is entered in Rieck’s own payment form only — no PAN or token ever passes this API. Response: { url, hasActiveCard } |
| POST | /v1/accounting-connections | Accounting OAuth (Dinero/e-conomic/Billy/Uniconta) — the human approves access at the provider. Response: { url, connection } |
The fourth — accepting terms on behalf of a creditor — is a legal question (it must bind a physical person unless the partner holds an explicit power of attorney) and is not exposed. Bank account changes are deliberately not startable via API at all.
Reminder flows (visual dunning graphs)
Section titled “Reminder flows (visual dunning graphs)”Creditors on the rykkerflow plan can manage their own pre-collections dunning graphs
(M23) headless — same package gate as the portal (403 rykkerflow_not_included without
it), same §9b validation, same four-eyes approval:
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/reminder-flows | reminders:read | Your flows + platform standards (usable as templates) |
| POST | /v1/reminder-flows | reminders:write | New draft version ({ key, name, graph }). The graph is the same machine format the portal builder produces; §9b/pre-collections validation runs before saving (422 flow_validation_failed with stable rule identifiers) |
| GET | /v1/reminder-flows/{key}/versions/{v} | reminders:read | The graph (?standard=true reads the platform standard as a template) |
| POST | …/versions/{v}/approve | reminders:write | Four-eyes: the API key that created the draft can never approve it — a different key or a human must (422 four_eyes_required) |
| POST | …/versions/{v}/activate | reminders:write | Body { "active": true }. Only approved versions; one active version per key |
Approved versions are immutable — a change is always a new version.
Members & roles
Section titled “Members & roles”| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/members | members:read | Active members with role |
| GET | /v1/roles | members:read | The role catalogue for your organisation type (stable keys) |
| PATCH | /v1/members/{userId} | members:manage | Body { "role": "…" }. The last admin is protected (422 last_admin_protected) |
members:manage is a privilege-escalation surface (a key can make a member admin) —
grant it only to integrations that genuinely administer users. Inviting and offboarding
users deliberately remain portal actions.
Branding, payment domain & customer portal
Section titled “Branding, payment domain & customer portal”Headless administration of what the portal’s branding page does (settings:read /
settings:write):
| Method | Path | Notes |
|---|---|---|
| GET | /v1/branding | Logo status (light + dark variant) |
| PUT | /v1/branding/logo | Body { "data": "<base64>", "mime": "image/png", "variant": "light"|"dark" }. PNG/JPEG/SVG/WebP, max 512 KB. Used on invoices, letters and the customer portal |
| DELETE | /v1/branding/logo?variant= | Remove a variant |
| GET / PUT | /v1/payment-domain | Your own payment host (CNAME → pay.rieckflow.com). Body { "domain": "pay.acme.example" }; null clears. Changing it resets verification |
| POST | /v1/payment-domain/verify | Run the live check (DNS → public IP → HTTPS health). Response status: verified / dns_missing / wrong_target / not_reachable_yet / invalid. Payment links only use the domain once verified |
| GET / PATCH | /v1/customer-portal | Subdomain, active, subscriptionSelfService, primaryColor (#rrggbb), palette (identifier, e.g. lys/moerk/midnat), customDomain. PATCH is partial; the login image is portal-managed and never touched |
| POST | /v1/customer-portal/verify-domain | Same live check for the portal’s custom domain |
Domain errors are stable codes: domain_taken, subdomain_taken, subdomain_reserved,
invalid_domain, no_domain_configured.
GET /v1/settings — organisation settings
Section titled “GET /v1/settings — organisation settings”Everything an integration should read instead of guessing (settings:read):
{ "data": { "invoiceNumbering": { "mode": "auto", "startNumber": null, "seriesStarted": true }, "tone": "neutral", "chartOfAccountsSource": "rieck", "paymentDomain": { "domain": "pay.acme.example", "verified": true }, "paymentMethods": { "card": true, "mobilepay": true, "alternatives": ["klarna"] } }}tone is the wording profile on invoices/reminders (friendly / neutral / firm /
custom); chartOfAccountsSource tells you whether invoice lines post to Rieck’s chart
of accounts or your connected accounting system; paymentMethods.alternatives lists the
consumer options (Klarna, MobilePay) available on your invoices — credit-based methods
are never offered on collections.
Read-only: numbering and tone are configured in the portal.
GET /v1/settlements — settlement history
Section titled “GET /v1/settlements — settlement history”Read-only list of what has been paid out to you (settlements:read), newest first,
covering both families:
"type": "collections"— debt-collection settlements: recovered funds minus Rieck’s fee and offset VAT (payoutOere,feeOere,vatOere,vatOffsetOere,lineCount)."type": "invoicing"— invoice-service settlements: customer payments collected on your invoices, minus the service fee.
The endpoint reports history; it makes no promise about when the next settlement runs. Line-level detail lives in the portal’s payout pages (and their PDF specifications).
Audit trail
Section titled “Audit trail”GET /v1/audit (audit:read) is your organisation’s security and supervision log: who did what,
to whom, with what outcome. Newest first, cursor-paged. Filters: category
(auth, autorisation, medlemskab, session, sikkerhed, gdpr, databrug), type,
outcome (succes, fejl, blokeret), caseId, correlationId.
⚠️ A blocked attempt is evidence too. outcome: "blokeret" means someone tried something they
were not allowed to do — usually the most interesting line in the file.
⚠️ This is not /v1/events. That is the webhook outbox: business events you can catch up on
after downtime. This is the supervision log — hash-chained, append-only, and written after an
action happened. They do not overlap, and neither replaces the other.
There is no write path, and there never will be. The database grants this API read access to
the log and nothing else: insert, update and delete are revoked, and an append-only trigger sits on
top. That is not a policy we apply in the API layer — it is a property of the table, and the
migration’s self-check keeps it that way. There is no audit:write.
GET /v1/audit/integrity answers the question that makes an audit trail more than a list: is it
still the same log? Every entry carries the hash of the one before it. The endpoint walks the
chain and returns intact, plus the sequence numbers where it does not link. A log whose
immutability you have to take on trust is not evidence; this is how you check instead.
Each entry gives you actorUserId — an id, not a name. Resolve it with /v1/members when you need
to show a person. IP addresses and user agents are deliberately not exposed: an audit trail should
prove what happened and who did it, not where a colleague was sitting. metadata carries the
context the domain wrote, and a database check guarantees it never contains credentials, tokens or
CPR numbers.
Dispatches
Section titled “Dispatches”/v1/dispatches (dispatches:read) answers the question every integration eventually asks:
did it arrive? One list across both tracks — letters and messages sent on a collection case,
and the e-mails and letters sent for an invoice. You should not have to know which of the two a
given document belongs to.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/dispatches | Cursor list. Filters: caseId, invoiceId (at most one), channel |
| GET | /v1/dispatches/{id} | One dispatch — looked up in both tracks |
| GET | /v1/dispatch-channels | Which channels are open for you, and why not when they are not |
Status vocabulary, in lifecycle order: queued, sent, pending_provider, delivered,
failed, skipped.
⚠️ queued and skipped are not failures. queued means the dispatch is waiting — or that
the provider gave no verdict and we froze the row rather than guess. skipped means no channel
could be reached for a soft step, which is a decision, not an error. Treating either as failed
would report a letter that is sitting in the recipient’s postbox as never sent.
⚠️ sent is not delivered. sent means we handed the document to the provider;
delivered requires actual proof coming back. deliveryProvable on each dispatch and on each
channel tells you whether proof is even possible: e-Boks has no receipt stream at all, so an
e-Boks dispatch never gets past sent. That is the channel’s limit, and we would rather say so
than let you wait for an event that cannot arrive.
⚠️ reference is always ours, never the provider’s id. A delivery proof is correlated through
our own reference echoed back to us; exposing the provider’s identifier as if it were our reference
would break the evidence chain silently. It is not in the payload and never will be.
There is no way to choose a channel, and there is no dispatches:write. What may be sent where
is decided by law, not preference: some documents must go on paper (Danish tenancy law § 13(2)),
and a recipient may have opted out of, or been exempted from, digital delivery (§ 13(1)). The
channel is derived and the rules are enforced fail-closed. An API that accepted channel as
input would move that decision to the integrator.
Dispatch events: dispatch.delivered and dispatch.failed, published from the proof tables — not
from our own record of having sent something. failed covers a hard bounce, an invalid recipient
or a provider rejection; a soft bounce fires nothing, because an attempt that can succeed next
time is not a failure.
Rental portfolio (properties and tenancies)
Section titled “Rental portfolio (properties and tenancies)”properties:read / properties:write — one scope pair for the whole portfolio: units,
tenancies and areas are one domain, and a scope per table would be dead surface on every key.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/properties | Cursor list. Filters: status, kind, areaId, postalCode |
| POST | /v1/properties | Create a unit |
| GET / PUT | /v1/properties/{id} | Read / replace the master data |
| GET / POST | /v1/property-areas | Your grouping of the portfolio |
| GET | /v1/tenancies | Cursor list. Filters: propertyId, customerId, status |
| GET | /v1/tenancies/{id} | One tenancy with tenants, rent components and deadlines |
⚠️ A unit’s status is derived, not set. It follows the tenancies: an active tenancy makes the
home UDLEJET even before the handover day, because it cannot be let to anybody else. There is
no status field in the write schema and there cannot be one — a status you could set would
eventually lie about whether the home is available.
⚠️ There is no write path on tenancies, and no rent-collection endpoint. Creating a tenancy
means issuing a tenancy agreement — form, deposit ceiling, statutory terms, signature — and the
row’s guards exist precisely so a tenancy cannot come into being without one. Rent is already
invoicing: rentSubscriptionId on the tenancy points at the recurring invoice, and
GET /v1/subscriptions/{id} is the way in. Terminations, rescissions, inspections and move-outs are
out for a sharper reason: several of their input dates are still settable where the rule engine
should compute them, and an API that accepted a statutory deadline would turn the law into a field.
⚠️ Every rent component is separate — heating, water, electricity, cooling, aerial, internet, residents’ representation. A single figure cannot answer “what was the heating charge in March”, which is exactly what a disputed increase turns on. Amounts are integer øre.
⚠️ claimType is derived from the unit’s kind. HUSLEJE means residential tenancy law applies
(a statutory demand after the third working day, with a 14-day period); ERHVERVSLEJE means
commercial law and three days. It tells you which track an arrears case would run on.
Two uniqueness rules, two different errors: the same address gives 409 address_already_exists,
the same unit number 409 unit_number_already_exists. ⚠️ The address key includes the label, so two
units at one address with different labels are legal. ⚠️ Changing an address on PUT clears the
property-register enrichment: the BFE number belonged to the old address, and a wrong one is worse
than none because it looks answered.
Values are the Danish domain terms (UDLEJET, BOLIGLEJLIGHED, OMKOSTNINGSBESTEMT). Tenancy law
has no agreed English vocabulary, and an invented one would look canonical without being it.
Rental events: tenancy.created, .activated, .terminated, .ended and
property.status_changed. ⚠️ tenancy.terminated carries kind — notice (a warning with a
period) or rescission (immediate, with none of the postponement a notice gives) — and deliberately
no end date: the tenancy’s own end day is the fixed term’s, not the notice’s. Never compute a
move-out date from a rescission as if it were a notice.
Customer checks (AML / KYC)
Section titled “Customer checks (AML / KYC)”customerchecks:read / customerchecks:write. The one surface that is itself a sellable API: an
accountant or a lawyer wants to run customer due diligence from their own case system, not from our
portal.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/customer-checks | Cursor list. Filters: customerId, status, risk |
| POST | /v1/customer-checks | Start a check on an existing customer |
| GET | /v1/customer-checks/legal-basis | The questions you must answer before starting one |
| GET | /v1/customer-checks/{id} | One check |
| GET | /v1/customer-checks/{id}/parties | Owners and management |
| GET | /v1/customer-checks/{id}/documents | Documentation on file — metadata only |
| POST | /v1/customer-checks/{id}/reassess | Start a periodic review |
⚠️ The scope is not the whole gate. Three checks run before any read, and they are about your
organisation, not your key: the module must be in your active package, it must be switched on, and
the organisation must be a creditor. You get 403 customer_checks_not_in_package or
customer_checks_not_enabled — never an empty list, which would read as “no customers”.
⚠️ Answer the legal-basis gate first. If your template carries one (it does for law and
accountancy), POST fails with 422 legal_basis_required until you send legalBasis. Fetch the
questions from /v1/customer-checks/legal-basis — they come with both Danish and English wording,
because the person answering them is at your end. An option whose outcome is UNDTAGET means the
assignment falls outside the act: the call returns 422 not_in_scope and nothing is created. A
check must not exist holding personal data when there is no basis for holding them.
⚠️ You cannot approve or reject over the API, and that is structural rather than a policy. The
risk classification is stored with the identity of the person who set it, and the database requires
the class, the actor and the timestamp to arrive together. An API key is not a person. This is
AMLR art. 76(5)(b) — meaningful human intervention must be identifiable. The API reads a decision;
it never makes one. riskCalculated (the system’s indication) and riskDecided (the human’s) are
two separate fields for the same reason.
⚠️ No identity numbers, no document bytes, no reasons. hasNationalId tells you whether an
identity number is on file; the number itself never leaves, not even masked. Documents come back as
metadata with a database-computed sha256 and there is deliberately no download — these are
passport and driving-licence scans, and the portal’s own download sits behind a named employee.
And a rejection arrives without a reason: a reason can reveal that a suspicious-activity report was
considered, and disclosing that is a criminal offence.
⚠️ nationalities is a list, and you should read it rather than nationality. Nationality is
what CLOSES a sanction hit, and a person can hold two.
Values are the Danish domain terms — UNDER_INDSAMLING, SKAERPET, REEL_EJER, HOEJ — and
are not translated. The act’s concepts have no agreed English vocabulary, and an invented one would
look canonical without being it. source + derived together are the beneficial-ownership
discrepancy you are required to report: the registry’s claim versus your own conclusion.
A review is not a repeat call. POST {id}/reassess inherits the previous template, carries the
customer’s answers across (except the ownership list and identity numbers, which must be obtained
afresh) and links the two checks. ⚠️ The legal-basis gate must be answered again — whether the work
is in scope is exactly the kind of thing a review exists to re-examine. Only from an approved check,
and only from the latest one.
Customer-check events: customer_check.created, .status_changed (from/to), .risk_decided
(riskClass, decidedBy, deviatedFromCalculated), .approved, .rejected, .ended. ⚠️ Nothing
fires when the system merely recalculates its own indication, and no payload carries a reason.
E-invoicing (EAN / NemHandel)
Section titled “E-invoicing (EAN / NemHandel)”Two endpoints, no scope of their own: the endpoint is an address on the customer
(customers:read / customers:write, like a department) and the outcome is a property of the
invoice (invoices:read).
| Method | Path | Notes |
|---|---|---|
| GET | /v1/customers/{id}/einvoicing | The endpoint, its Peppol scheme, and the departments’ own endpoints |
| PUT | /v1/customers/{id}/einvoicing | Set it, or send endpoint: null to clear it |
| GET | /v1/invoices/{id}/einvoicing | What happened to the e-invoice — one row per transmission |
⚠️ GET /v1/dispatches cannot answer this one. It reads our own send record, so it tells you we
handed the document to the access point. The receiving system replies afterwards — sometimes hours
later — and an invoice it rejected reads sent there forever. This endpoint carries the
recipient’s verdict: queued | sent | failed | rejected, with their own responseCode,
responseMessage and respondedAt. If you find out at the reminder, you found out too late.
⚠️ The check digit is validated server-side. A 13-digit number with a bad GS1 check digit is not
“maybe another type” — no Danish endpoint type is 13 digits except GLN — so we reject it with
422 invalid_endpoint rather than let a typo reach the network and come back two days later as a
rejection. The scheme (0088 GLN, 0184 CVR, 0096 P-number) is derived from the number’s own
form; you never send it.
⚠️ A department’s endpoint wins over the customer’s when we send, which is why the departments are listed alongside. A public authority often routes per department, and invoicing the head office is how an invoice disappears. Setting a department’s endpoint is still portal-only today.
⚠️ The document never leaves the system. Neither the UBL we sent nor the recipient’s
ApplicationResponse is exposed: the access point applies the eDelivery signature with its own
certificate, and we are not in that chain. documentHash is the sha256 of exactly the bytes we
sent, so you can prove the content without holding it.
E-invoice events: einvoice.delivered (the recipient’s system acknowledged) and einvoice.failed,
where stage separates the two ways it goes wrong — transport (it never left) and recipient
(their system threw it out). Subscribe rather than poll; rejected arrives after sent.
⚠️ sent alone publishes nothing — invoice.sent has already told you we dispatched it.
Administration: package, mandates, credit limits, price rules
Section titled “Administration: package, mandates, credit limits, price rules”Four small surfaces (settings:read / settings:write) that used to force a login at onboarding
and at every change to a customer’s framework.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/subscription-package | Which package you are on, whether terms were negotiated, and any trial |
| GET | /v1/mandates | Your three standing yeses |
| GET | /v1/credit-limits | Your organisation’s default limit and its history |
| GET | /v1/customers/{id}/credit-limit | One customer’s limit, where it comes from, and the exposure against it |
| GET/POST | /v1/price-rules | Read and write price rules |
| DELETE | /v1/price-rules/{id} | Remove a rule |
⚠️ Only price rules can be written with a key, and that is a property of the database rather
than a choice we made. Package, mandates and credit limits are all written through functions
that require the medlem.administrer permission, which resolves through a signed-in user. An API
key has no user by construction, so those routes would answer 403 every time — we would rather not
give you a door that never opens. The one exception already in place: autoCollections (mandate
one) can be changed with PUT /v1/reminder-settings.
The three mandates are the yeses you give in advance so a case handler need not ask in every
single case: autoCollections (hand an overdue invoice to collection automatically),
autoBailiff (file a payment order, with the court fee charged to your card) and
settlementMandate (how much of a debt may be written off on your behalf). ⚠️ In
settlementMandate, both fields null means no mandate at all — “no settlement without my
yes” — not a milder default.
Credit limits: forward in time, backward in basis
Section titled “Credit limits: forward in time, backward in basis”limitOere is null for no limit and 0 for a full stop; the difference is deliberate, and code
that treats them alike will either block everything or nothing.
⚠️ A limit gates new issuing only — but it is measured against the exposure you have already
accumulated. Nothing that is already issued changes: no invoice is cancelled, re-evaluated or
flagged. But the very next issue attempt compares the current limit against the current exposure,
which is made of those older debts. So lowering a limit can block a customer whose entire balance
predates the change. Issuing over the limit fails with error RI053.
Credit notes are never gated (they bring the balance down), drafts are not counted against the
limit, and debts handed to collection are excluded from it — they are shown separately under
exposure.collectionsOere.
Price rules
Section titled “Price rules”GET /v1/price-rules takes at most one anchor — campaignId, customerId or priceListId;
none returns the unanchored rules. ⚠️ Rules come back in the engine’s order, not alphabetically.
A rule that can never win, because a more specific one always beats it, has to be visible as such;
sorted by name the list looks like a set of prices in force, when half of them never apply.
The list is paged like every other /v1 list (?limit=, ?cursor=, meta.nextCursor), and
⚠️ the cursor pages ON the engine’s order — page 2 continues where the engine left off rather
than restarting in date order, so the “who beats whom” reading survives paging.
A rule sets either a fixed price (basis: "FAST" with priceOere) or a percentage off cost or
catalogue (basis: "KOST" / "KATALOG" with percentBasisPoints — basis points, so 12.5 % is
1250). It targets one product or one category, never both. fromQuantity makes it a volume
break. Send an id to update an existing rule.
⚠️ Your price floor is checked when the rule is saved, not when an invoice is written. A rule
that only fails on a document is a trap: it was written weeks earlier, and the person who hits it
is not the person who made it. If your floor is set to reject, you get 422 price_floor_breached
telling you how many products would go below cost — never their cost prices.
DELETE removes a rule outright. A rule that merely should not apply right now is deactivated in
the portal instead; the difference matters when someone later asks why an invoice got its price.
Contracts
Section titled “Contracts”/v1/contracts (contracts:read / contracts:write) is the contract module headless: draft,
send for signature, follow every party, cancel, and fetch the sealed document.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET/POST | /v1/contracts | read / write | Cursor list (status=draft|sent|signed|cancelled|expired); create a draft |
| GET/DELETE | /v1/contracts/{id} | read / write | The contract with every party; delete drafts only |
| POST | /v1/contracts/{id}/send | write | Freeze, hash and send. Returns each signer’s link |
| POST | /v1/contracts/{id}/cancel | write | Withdraw a draft or a sent contract |
| GET | /v1/contracts/{id}/pdf | read | The sealed, signed PDF |
| GET | /v1/contracts/{id}/timeline | read | The audit log |
| GET | /v1/contract-templates | read | Your own template library — paged |
Sending is the point of no return. POST …/send freezes the content and hashes it;
contentHash is the sha256 of exactly the wording the parties sign. After that the contract
cannot be edited — only cancelled. Sending an already-sent contract returns it unchanged rather
than failing, and never mints new links.
⚠️ GET /v1/contract-templates is paged (?limit=, ?cursor=, meta.nextCursor), newest
first. Not because the library is long, but because every row carries the full content — up to
200,000 characters. You get the latest version of each template; archived ones are left out.
⚠️ The links in meta.links are capabilities: the path IS the permission to sign. They exist
only in that one response — we store only their hash — so deliver each one to its own party, and
do not log them. With requireCode each signer also gets a six-character code, delivered the same
way.
Order matters. A party is not asked to sign until every lower order has signed. Give
everyone the same number to ask them all at once. role: "copy" receives the contract but never
signs, and is not counted in signerCount.
⚠️ There is no signing endpoint, and there will not be one. Signing is a person’s act, carried
out through NemLog-in/MitID in their own browser, and the evidence it produces — CPR, MitID serial
number, IP, the drawn signature — is special-category personal data. An API key cannot sign for a
party, and a POST …/sign would turn "status": "signed" into a claim without the proof the word
promises. You read the outcome and receive contract.signed. The evidence is never serialised,
not in the contract, not in the timeline, not in a webhook.
# Draft, then send.curl -X POST https://api.rieckflow.com/v1/contracts \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: contract-2026-0031" \ -H "Content-Type: application/json" \ -d '{ "title": "Consultancy agreement", "content": "...", "parties": [{ "name": "Anna Andersen", "email": "anna@example.com", "signatureMethod": "mitid", "order": 1 }] }'
curl -X POST https://api.rieckflow.com/v1/contracts/<uuid>/send \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: contract-send-<uuid>" \ -H "Content-Type: application/json" \ -d '{ "requireCode": true, "reminders": true }'Contract events: contract.sent (contentHash, signerCount, expiresOn),
contract.party_signed (partyId, signatureMethod, and signedCount of signerCount — the
“2 of 3” an integration waits on), contract.party_declined, contract.signed,
contract.cancelled and contract.expired. The payload carries the title, the hash and the
counts — never the content, the parties’ contact details or the signature evidence.
⚠️ A declined party does not decline the contract. When someone refuses, their party goes to
declined and the contract stays sent — it will simply never be signed. That is why
contract.party_declined exists: without it you would wait forever. Act on it — cancel, or send
a new contract.
GET /v1/contracts/{id}/pdf returns the PAdES-sealed document stored when the last party
signed — the thing to archive — and 422 contract_not_signed before that. It is deliberately not
the evidence report the portal renders on screen: that one is a human-facing view in a chosen
language, and we would rather give you nothing than a report that looks like a contract.
Open items
Section titled “Open items”GET /v1/open-items (finance:read) answers the question a finance system asks every morning:
what does each customer owe us right now, and how old is it? Cursor list, one row per customer
with anything outstanding. Customers who owe nothing are not listed; drafts and paid invoices do
not count.
| Field | Meaning |
|---|---|
outstandingOere | Everything still owed on issued, unpaid invoices |
overdueOere · overdueCount | The part whose due date has passed, and how many invoices |
oldestDueDate · daysOverdue | The age — the oldest overdue invoice and how long it has run |
collectionsOere | The subset already handed to collection — still owed, no longer yours to chase |
pastDue | The threshold-assessed state customer.past_due fires on |
Filters: overdue=true, minOutstandingOere=, minDaysOverdue=.
These are the same fields, from the same source, as GET /v1/customers/{id}/balance. One
customer or all of them, the numbers cannot disagree — they are one formula, not two. For the
invoice-level detail behind a row, use GET /v1/invoices?customerId=; for a full account
statement with opening and closing balances, GET /v1/customers/{id}/statement.
# Everyone more than 30 days late, owing at least 5.000 kr.curl "https://api.rieckflow.com/v1/open-items?overdue=true&minDaysOverdue=30&minOutstandingOere=500000" \ -H "Authorization: Bearer $RIECK_API_KEY"⚠️ The figures are point-in-time, and paging is ordered by when the customer was created — not by amount. An amount-ordered cursor looks better for a report, but amounts move while you page: a payment landing mid-run would shift a customer backwards and either repeat or drop a row. Every number you would sort by is in each row, so sort after you have the page you want.
Open items have no event of their own, on purpose: customer.past_due and customer.settled
already fire exactly when a customer’s open items cross the arrears threshold one way or the
other, and their payload carries these same fields. A second event about the same fact would only
raise the question of which one is true.
There is no finance:write. Money does not move from a reporting surface: register a payment
with POST /v1/payments, credit an invoice with POST /v1/creditnotes.
Tasks — and the approvals inside them
Section titled “Tasks — and the approvals inside them”/v1/tasks is what we are waiting for you to answer. Most of it is the approval queue: the
four GODKEND_* types are a claim that waits for your yes before the case is started or the
invoice is sent to collection ("approval": true). Nothing about them requires a login — which
is the point: a customer running their operation in their own system should never have to open
our portal to keep their cases moving.
Each task carries its own form. There is no fixed shape: form.fields lists the field ids,
their kind, whether they are required, and — for a choice — the exact values you may send. That is
what POST …/complete is validated against, fail-closed: an unknown field is rejected, a
required field must be present, and a choice outside the list is not an answer. Bind on the field
ids and the type code; those are stable. title, description and the labels are rendered in
lang for humans, with *Key alongside if you want to branch without parsing a sentence.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/tasks | tasks:read | Cursor list, newest first. Filters: status (pending, completed, cancelled), type, caseId, invoiceId, lang (da/en, default en) |
| GET | /v1/tasks/{id} | tasks:read | One task in any status — read your own answer back after a timeout instead of guessing whether the call landed |
| POST | /v1/tasks/{id}/complete | tasks:write | { "answer": { "<field id>": <value> } }. Single-use: a second call gets 409 task_not_pending |
# What is waiting for us?curl "https://api.rieckflow.com/v1/tasks?status=pending" \ -H "Authorization: Bearer $RIECK_API_KEY"
# Yes — send it to collection. The field ids come from the task's own form.fields.curl -X POST https://api.rieckflow.com/v1/tasks/<uuid>/complete \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: task-<uuid>" \ -H "Content-Type: application/json" \ -d '{ "answer": { "beslutning": "JA", "kommentar": "Approved by finance" } }'The response’s meta says what happened: outcome (approved / declined / answered),
executed — did your answer set something in motion — caseId, and blocked when we may not
carry it out yet. A “no” never executes anything, deliberately: declining a case start does
not withdraw the case, it just leaves it where it is with your answer on record.
⚠️ executed: false with blocked still means your answer is registered. It means we are not
allowed to carry it out — a claim that turned out to be time-barred, or documentation missing. Do
not call again; the task is answered, and the block is ours to clear.
⚠️ An answer given by an API key is recorded as such. The row keeps who answered and through
which channel (portal, mail link, or API), and task.completed carries via: "api". An approval
made by a machine can always be told apart from one made by a person — which is exactly what makes
it safe to give a key this scope.
Task events: task.created (approval tells you whether a yes sets something in motion,
dueDate when there is a deadline), task.completed (outcome and via) and task.cancelled
(the question became irrelevant before you answered — the invoice was paid, or the case was
withdrawn). The payload never carries your answer text or the task title: a comment field is your
own words about a debtor, and webhook payloads are free of personal data by contract.
There is no /v1/approvals, and that is on purpose. The portal page of that name is our
staff queue — the human-in-the-loop that GDPR art. 22 requires in front of an automated decision
about a debtor. It is not your queue, you cannot see it, and an API key that could clear it would
remove a safeguard rather than add a feature. Your approvals are the ones above.
Notifications
Section titled “Notifications”The notification centre, headless (/v1/notifications). Notifications are push by nature:
the webhook notification.created is the channel, and these endpoints are how you catch up after
downtime — not something to poll every minute.
You see your organisation’s notifications, not a colleague’s. A notification is addressed
either to the organisation (everyone) or to one person. An API key is a service account, so it
sees only the organisation-wide ones; a message addressed to Anna is hers. The same cut applies to
the webhook, so an event never points at something you would get a 404 for.
The read receipt is per API key. Acknowledging with your key never touches a colleague’s unread badge, and their reading never marks anything read for you. Receipts are append-only, so acknowledging twice is a no-op — but you cannot un-read.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /v1/notifications | notifications:read | Cursor list, urgent first then newest. Filters: unread=true, category (oekonomi, sager, opgaver, samarbejde, kunder), lang (da/en, default en) |
| GET | /v1/notifications/unread-count | notifications:read | data.unread — the badge number for this key |
| GET | /v1/notifications/{id} | notifications:read | One notification. 404 for one addressed to a person |
| POST | /v1/notifications/{id}/read | notifications:write | 204. Idempotent |
| POST | /v1/notifications/read-all | notifications:write | data.marked — how many were newly acknowledged |
| GET | /v1/notification-types | notifications:read | Every type with its category |
type is the domain’s own code (BETALING_IND, SAG_KRAEVER_HANDLING, …) — never a
translated alias. It is the same source the channel matrix uses, so the catalogue cannot drift
away from what actually gets sent. title is rendered in lang; titleKey is the underlying key,
so you can group or route on it without parsing a sentence. portalPath is where the portal would
take a human — a path, not a link you can hand to a customer.
# Catch up after downtime: everything this key has not acknowledged.curl "https://api.rieckflow.com/v1/notifications?unread=true&lang=en" \ -H "Authorization: Bearer $RIECK_API_KEY"
# Acknowledge one you have acted on.curl -X POST https://api.rieckflow.com/v1/notifications/<uuid>/read \ -H "Authorization: Bearer $RIECK_API_KEY" \ -H "Idempotency-Key: notif-ack-<uuid>"Notification events: notification.created carries notificationId, type, caseId and
urgent — not the title. Titles are rendered from a key with parameters, and those parameters
can carry a person’s or a debtor’s name; webhook payloads are free of personal data by contract.
Fetch the title with GET /v1/notifications/{id} when you need it. Only organisation-wide
notifications are published.