Skip to content

Endpoints

The customer resource (/v1/customers) is your debtor register. Fields are English; amounts and conventions as above.

MethodPathScopeNotes
GET/v1/customerscustomers:readCursor list. Filters: cvr, email (exact match), externalReference
POST/v1/customerscustomers:writeCreate. 201 + Location
GET/v1/customers/{id}customers:read
PATCH/v1/customers/{id}customers:writePartial: omitted fields kept, null clears. Last-write-wins
GET/v1/customers/{id}/departmentscustomers:readDepartments (contact person + reference)
POST/v1/customers/{id}/departmentscustomers:write
DELETE/v1/customers/{id}/departments/{deptId}customers:writeIdempotent 204
Terminal window
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).

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.

MethodPathScopeNotes
GET/v1/productsproducts:readFull list; ?active=true filters
POST/v1/productsproducts:writesku unique per organisation (case-insensitive)
GET/v1/products/{id}products:read
PATCH/v1/products/{id}products:writePartial; "active": false deactivates
GET/v1/product-categoriesproducts:readWhole tree in one response; no cursor
POST/v1/product-categoriesproducts:writeparentId for a subcategory; omit for top level
GET/v1/accountsproducts:read?active=true filters
POST/v1/accountsproducts: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.

Terminal window
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 }'

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.

MethodPathScopeNotes
GET/v1/quotesquotes:readCursor list. Filters: status (draft, sent, accepted, declined, expired, converted), customerId. expired is derived: a sent quote past validUntil lists as expired
POST/v1/quotesquotes:writeCreates a draft. 201 + Location. Same line shape as invoices; signatureMethod: none / simple / mitid
GET/v1/quotes/{id}quotes:readHeader + lines, acceptedBy (customer / creditor), orderId or invoiceId once converted
DELETE/v1/quotes/{id}quotes:writeDrafts only (422 quote_not_deletable) — a sent quote is a frozen record
POST/v1/quotes/{id}/sendquotes:writeFreezes 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}/orderquotes:writeThe 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}/invoicequotes:writeThe 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}/pdfquotes:readThe sealed quote (document rate class). Exists after sending only (404 quote_pdf_not_found for a draft)
GET/v1/quotes/{id}/attachmentsquotes:readMetadata only: filename, mime, sizeBytes, type (contract / terms / correspondence / other), sendWithQuote, sha256
POST/v1/quotes/{id}/attachmentsquotes: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:readThe raw file, always Content-Disposition: attachment
GET/v1/quotes/{id}/timelinequotes:readNewest first. kind: created, sent, resent, viewed, signed, accepted, declined, expired, converted; actor: customer / user / system
Terminal window
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.

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.

MethodPathScopeNotes
GET/v1/ordersorders:readCursor list. Filters: status (draft, confirmed, partially_invoiced, invoiced, cancelled), customerId
POST/v1/ordersorders:writeCreates a draft. 201 + Location. Same line shape as invoices
POST/v1/orders/from-quoteorders: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:readHeader + lines, each with invoicedQuantity / remainingQuantity
DELETE/v1/orders/{id}orders:writeDrafts only, and not drafts born from a quote (422 order_not_deletable) — cancel instead
POST/v1/orders/{id}/confirmorders:writeConfirm 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}/invoiceorders:writeCreates an invoice draft. { "lines": [{ "orderLineId", "quantity" }] } invoices a part; empty body invoices the remainder. 201 + Location: /v1/invoices/{id}
POST/v1/orders/{id}/cancelorders:writeCloses the open remainder; invoices already produced remain. Fully invoiced orders cannot be cancelled — credit instead
GET/v1/orders/{id}/invoicesorders:readThe invoices the order produced, with status and balance — the read path partial invoicing depends on
GET/v1/orders/{id}/timelineorders:readkind: created, confirmed, sent, resent, invoiced, invoice_released, cancelled
GET/v1/orders/{id}/pdforders:readThe sealed confirmation (document rate class). Exists after confirmation only
Terminal window
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.

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.

MethodPathScopeNotes
GET/v1/customers/{id}/payment-plan-termspaymentplans:readeligible, 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-planspaymentplans:readCursor list. Filters: status (proposed, active, fulfilled, defaulted, cancelled), customerId
POST/v1/payment-planspaymentplans: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:readCovered invoices (balance at start and now), the schedule, cumulative progress while active, remindersPaused
POST/v1/payment-plans/{id}/closepaymentplans: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-planpaymentplans:readThe proposed or active plan covering the invoice — the pause made readable per invoice. 404 payment_plan_not_found when the cadence runs normally
Terminal window
# 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.

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.

MethodPathScopeNotes
GET/v1/invoicesinvoices:readCursor list. Filters: status, customerId, externalReference
POST/v1/invoicesinvoices:writeCreates a draft. 201 + Location
GET/v1/invoices/{id}invoices:readHeader + lines
PUT/v1/invoices/{id}invoices:writeFull replace, drafts only (422 invoice_not_editable otherwise). Deliberately PUT: lines are always recalculated as a whole
DELETE/v1/invoices/{id}invoices:writeDrafts only
POST/v1/invoices/{id}/issueinvoices:writeOptional body { "issueDate", "dueDate" }. Returns the invoice with its number
POST/v1/invoices/{id}/sendinvoices:writeEmails the PDF to the customer; resend replays
GET/v1/invoices/{id}/pdfinvoices:readThe WORM-locked PDF (document rate class)
GET/v1/invoices/{id}/timelineinvoices:readStable event codes: created, issued, sent, delivered, opened, reminder, payment, collections_ready, collections, …
Terminal window
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 fastest integration in Danish invoicing — create the customer, the invoice, issue it (sequential number + locked PDF) and email it, in one call:

Terminal window
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) or customerId — never both. Inline customers are found-or-created (matched on externalReference, then CVR, then email) and never overwritten; inline creation also requires the customers:write scope.
  • send=true implies issue=true. Creation + issuing is atomic; sending runs after — a mail failure never rolls back an issued invoice. Check meta.send ({"status": "sent"} or {"status": "failed", "code": "customer_email_missing"}): 201 means “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.

MethodPathScopeNotes
GET/v1/invoices/{id}/paymentspayments:readThe invoice’s payments, newest first
POST/v1/invoices/{id}/paymentspayments:writeRegister a manual payment (bank transfer). Body { "amountOere", "paymentDate" }
GET/v1/paymentspayments:readOrg-wide cursor list — built for nightly reconciliation
GET/v1/payments/{id}/receiptpayments:readCreditor-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.

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 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.

MethodPathScopeNotes
POST/v1/invoices/{id}/creditinvoices:writeBody { "reason": "…" }. 201 + the credit note; 422 invoice_not_creditable if not issued / already credited / in collections
GET/v1/creditnotesinvoices:readCursor list; ?customerId= filter
GET/v1/creditnotes/{id}invoices:readHeader + lines; creditsInvoiceId points at the original
GET/v1/creditnotes/{id}/pdfinvoices:readWORM 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.

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.

MethodPathScopeNotes
GET/v1/invoices/{id}/remindersreminders:readSent/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}/remindersreminders:writeSend the next reminder immediately. 200 + { reminderNumber, feeOere, collectionsReady }
GET/v1/reminder-settingsreminders:readEffective configuration (defaults filled in: cadence 7/14/21 days, fee at the statutory cap)
PUT/v1/reminder-settingsreminders:writeFull 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 collectionsReady is true.
  • 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 enabled letterFallback, 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).

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:

Terminal window
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.

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:

Terminal window
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.

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.

MethodPathScopeNotes
GET/v1/subscriptionssubscriptions:readCursor list. Filters: status (active/paused/cancelled), customerId, nextInvoiceBefore (YYYY-MM-DD), externalReference
POST/v1/subscriptionssubscriptions:writeCreate. 201 + Location
GET/v1/subscriptions/{id}subscriptions:readIncludes invoiceCount, periodsBehind, latestInvoice
PATCH/v1/subscriptions/{id}subscriptions:writePartial: omitted fields kept. Never touches already-issued invoices
POST/v1/subscriptions/{id}/pausesubscriptions:writeOnly from active
POST/v1/subscriptions/{id}/resumesubscriptions:writeOnly from paused
POST/v1/subscriptions/{id}/cancelsubscriptions:writeFinal — a cancelled subscription cannot be revived
GET/v1/subscriptions/{id}/invoicessubscriptions:readThe generated invoices, newest period first
GET/v1/subscriptions/{id}/next_invoicesubscriptions:readPreview of the next occurrence (date, lines, VAT, total) — nothing is issued. data: null when nothing is coming
POST/v1/subscriptions/{id}/runsubscriptions:writeIssue the next due period now. document budget
Terminal window
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:

  • status is never a writable field. Transitions are named endpoints because they are asymmetric — cancel is final; an invalid transition answers 422 invalid_transition with an explanation, never a silent no-op.
  • nextInvoiceDate cannot be set. It advances only through the run receipt (unique per period in the database) — that is what makes double-invoicing impossible.
  • /run respects 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.

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 (needs customers:write), invoice.create (needs invoices:write; issue: true issues in the same transaction; inline customer does find-or-create and also needs customers:write), payment.register (needs payments: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 customer on the invoice instead.
  • There is deliberately no send operation — a migration of historical invoices must never e-mail a thousand customers.
  • Your Idempotency-Key covers the whole batch (replay returns the stored response), and each payment.register books on key:index — retries never double-register.
  • The batch call draws from the document budget (120/min), since it may issue invoices.

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:

MethodPathNotes
POST/v1/verification-sessionsMitID 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-sessionsThe 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-connectionsAccounting 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.

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:

MethodPathScopeNotes
GET/v1/reminder-flowsreminders:readYour flows + platform standards (usable as templates)
POST/v1/reminder-flowsreminders:writeNew 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:readThe graph (?standard=true reads the platform standard as a template)
POST…/versions/{v}/approvereminders:writeFour-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}/activatereminders:writeBody { "active": true }. Only approved versions; one active version per key

Approved versions are immutable — a change is always a new version.

MethodPathScopeNotes
GET/v1/membersmembers:readActive members with role
GET/v1/rolesmembers:readThe role catalogue for your organisation type (stable keys)
PATCH/v1/members/{userId}members:manageBody { "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):

MethodPathNotes
GET/v1/brandingLogo status (light + dark variant)
PUT/v1/branding/logoBody { "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-domainYour own payment host (CNAME → pay.rieckflow.com). Body { "domain": "pay.acme.example" }; null clears. Changing it resets verification
POST/v1/payment-domain/verifyRun 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-portalSubdomain, 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-domainSame 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).

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.

/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.

MethodPathNotes
GET/v1/dispatchesCursor list. Filters: caseId, invoiceId (at most one), channel
GET/v1/dispatches/{id}One dispatch — looked up in both tracks
GET/v1/dispatch-channelsWhich 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.

MethodPathNotes
GET/v1/propertiesCursor list. Filters: status, kind, areaId, postalCode
POST/v1/propertiesCreate a unit
GET / PUT/v1/properties/{id}Read / replace the master data
GET / POST/v1/property-areasYour grouping of the portfolio
GET/v1/tenanciesCursor 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.

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.

MethodPathNotes
GET/v1/customer-checksCursor list. Filters: customerId, status, risk
POST/v1/customer-checksStart a check on an existing customer
GET/v1/customer-checks/legal-basisThe questions you must answer before starting one
GET/v1/customer-checks/{id}One check
GET/v1/customer-checks/{id}/partiesOwners and management
GET/v1/customer-checks/{id}/documentsDocumentation on file — metadata only
POST/v1/customer-checks/{id}/reassessStart 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.

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

MethodPathNotes
GET/v1/customers/{id}/einvoicingThe endpoint, its Peppol scheme, and the departments’ own endpoints
PUT/v1/customers/{id}/einvoicingSet it, or send endpoint: null to clear it
GET/v1/invoices/{id}/einvoicingWhat 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.

MethodPathNotes
GET/v1/subscription-packageWhich package you are on, whether terms were negotiated, and any trial
GET/v1/mandatesYour three standing yeses
GET/v1/credit-limitsYour organisation’s default limit and its history
GET/v1/customers/{id}/credit-limitOne customer’s limit, where it comes from, and the exposure against it
GET/POST/v1/price-rulesRead 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.

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.

/v1/contracts (contracts:read / contracts:write) is the contract module headless: draft, send for signature, follow every party, cancel, and fetch the sealed document.

MethodPathScopeNotes
GET/POST/v1/contractsread / writeCursor list (status=draft|sent|signed|cancelled|expired); create a draft
GET/DELETE/v1/contracts/{id}read / writeThe contract with every party; delete drafts only
POST/v1/contracts/{id}/sendwriteFreeze, hash and send. Returns each signer’s link
POST/v1/contracts/{id}/cancelwriteWithdraw a draft or a sent contract
GET/v1/contracts/{id}/pdfreadThe sealed, signed PDF
GET/v1/contracts/{id}/timelinereadThe audit log
GET/v1/contract-templatesreadYour 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.

Terminal window
# 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.

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.

FieldMeaning
outstandingOereEverything still owed on issued, unpaid invoices
overdueOere · overdueCountThe part whose due date has passed, and how many invoices
oldestDueDate · daysOverdueThe age — the oldest overdue invoice and how long it has run
collectionsOereThe subset already handed to collection — still owed, no longer yours to chase
pastDueThe 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.

Terminal window
# 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.

/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.

MethodPathScopeNotes
GET/v1/taskstasks:readCursor list, newest first. Filters: status (pending, completed, cancelled), type, caseId, invoiceId, lang (da/en, default en)
GET/v1/tasks/{id}tasks:readOne task in any status — read your own answer back after a timeout instead of guessing whether the call landed
POST/v1/tasks/{id}/completetasks:write{ "answer": { "<field id>": <value> } }. Single-use: a second call gets 409 task_not_pending
Terminal window
# 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.

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.

MethodPathScopeNotes
GET/v1/notificationsnotifications:readCursor list, urgent first then newest. Filters: unread=true, category (oekonomi, sager, opgaver, samarbejde, kunder), lang (da/en, default en)
GET/v1/notifications/unread-countnotifications:readdata.unread — the badge number for this key
GET/v1/notifications/{id}notifications:readOne notification. 404 for one addressed to a person
POST/v1/notifications/{id}/readnotifications:write204. Idempotent
POST/v1/notifications/read-allnotifications:writedata.marked — how many were newly acknowledged
GET/v1/notification-typesnotifications:readEvery 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.

Terminal window
# 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.