Back

Profi API

Contract version 2026-09-17

One REST API for the whole Profi family. Your integration drives the same actions a person clicks in the app — so it inherits rights, numbering, workflow and stock valuation.

This portal is in English: integrations are usually built by a developer, and the API speaks English throughout. The brewery-facing help stays in Czech inside the app.

Create a key in the app

Quickstart

Everything below works with a test key against the shadow demo account, so nothing here touches production data.

  1. 1. Create a key

    In the app go to Settings → API and create a key. Start with a test key (pb_test_…): it runs against the shadow demo account and every response carries livemode: false. The secret is shown once — put it in an environment variable, never in a URL or your repository.

  2. 2. Pick scopes

    Scopes are per module and have two levels, read and write. Take a preset (E-shop, Accounting, Warehouse, Read-only) unless you know exactly what you need. Effective scopes are the key's scopes intersected with the modules the account subscribes to today, so dropping an add-on drops the scope without anyone editing the key. Deleting records through the API is never possible.

  3. 3. Call the API

    Send the key as a bearer token. Every response carries X-Request-Id — quote it when you ask us about a specific call.

    export K=pb_test_…
    
    curl -s https://www.profibrew.com/api/v1/me \
      -H "Authorization: Bearer $K"
  4. 4. Receive events

    Register a webhook endpoint instead of polling. Every event stays readable through GET /events as well, so you can catch up after an outage without losing anything.

Concepts

The nine things worth knowing before you write the first line of integration code.

Authentication

A bearer token in the Authorization header. Keys are either live (pb_live_…) or test (pb_test_…); both work on every host of the Profi family. Keys can be restricted to a list of addresses and can carry an expiry — an expiring key is announced by e-mail before it stops working.

Sandbox

A test key talks to the shadow demo account of the same brewery: real structure, disposable data. Requests carry X-Profi-Sandbox: 1 and responses livemode: false. Build the whole integration against it — the recipes below are written to run there unchanged.

Quantities and units

Every quantity travels with its unit and the API NEVER converts it. On reads you get quantity plus a unit object (id, code, symbol, dimension, decimals); on writes you send quantity plus exactly one of unit_id or unit_code. Document lines and stock levels are in the unit of the item.

{
  "item_id": "…",
  "quantity": "24",
  "unit_code": "ks"
}

Ask GET /units first — it returns the dictionary this account may use (system units for its unit system plus its own). Sending a unit the account does not have gives 422 unit_not_available, not a silent conversion.

Pagination and incremental sync

Lists take limit (1–200, default 50) and an opaque cursor, and return data, next_cursor and has_more. For incremental sync pass updated_since or created_since instead of walking every page — both take an ISO 8601 instant.

curl -s "https://www.profibrew.com/api/v1/items?limit=100&updated_since=2026-09-01T00:00:00Z" \
  -H "Authorization: Bearer $K"

Idempotency

Send an Idempotency-Key header on writes. Repeating the same key with the same body replays the stored response for 24 hours, so a network timeout can be retried without creating a document twice. The same key with a different body is refused with 422 idempotency_key_reused; a request still in flight gives 409 idempotency_in_progress.

curl -s -X POST https://www.profibrew.com/api/v1/partners \
  -H "Authorization: Bearer $K" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8c2e5a8e-1b2f-4c3d-9e0f-1a2b3c4d5e6f" \
  -d '{"name":"Hostinec U Dvou koček","external_ref":"eshop:10547","is_customer":true}'

Your own identifiers

Instead of keeping a mapping table, stamp records with external_ref in the form <system>:<id>, for example eshop:10547. It is unique per account, so a duplicate create is refused with 409 external_ref_conflict and the response names the record that won.

Concurrent edits

Reads return an ETag. Send it back as If-Match on an update and a change someone made meanwhile is refused with 412 stale_version instead of quietly overwriting their work.

Errors

Every error is an RFC 9457 problem document with content type application/problem+json. Branch on the machine-readable code, never on title or detail. Validation failures list the offending rows in errors[], and type links straight to the matching entry in the Errors table below.

Rate limits

Every response carries the IETF RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy headers. Over the limit you get 429 rate_limited with Retry-After in seconds — wait that long rather than retrying immediately. The ceiling comes from the account's plan.

Versioning

The contract is dated, not semver. The version travels in the X-Profi-Api-Version response header and in the api_version field of every event. Additive changes — a new field, a new endpoint, a new event type — happen without a new version, so parse defensively and ignore fields you do not know.

Scopes

A key carries the scopes below. Reading needs :read, creating, updating and running workflow actions need :write. In the OpenAPI document, x-scopes lists the scopes of which the key needs at least one; x-additional-scopes lists scopes it needs on top — recording a POS sale also posts the payment, so it needs finance:write, and stock:write when the cash desk moves stock.

  • core:read
  • core:write
  • stock:read
  • stock:write
  • sales:read
  • sales:write
  • finance:read
  • finance:write
  • brewery:read
  • brewery:write
  • pos:read
  • pos:write
  • events:read
  • webhooks:manage
  • workflow:write

Webhooks

Register an https endpoint and we deliver domain events to it as they happen. Delivery is at-least-once and unordered — treat both as given.

Verifying the signature

Every delivery is signed per the Standard Webhooks specification, the same scheme Svix and Resend use, so an off-the-shelf library verifies it. Three headers travel with the request: webhook-id, webhook-timestamp and webhook-signature. The signed content is id.timestamp.body — HMAC-SHA256 with the decoded part of your whsec_ secret, base64 encoded, sent as v1,<signature>.

import { Webhook } from "standardwebhooks";

// Ověřuj SUROVÉ tělo, teprve pak ho parsuj.
app.post("/profi-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const wh = new Webhook(process.env.PROFI_WEBHOOK_SECRET); // whsec_…
  let event;
  try {
    event = wh.verify(req.body, {
      "webhook-id": req.header("webhook-id"),
      "webhook-timestamp": req.header("webhook-timestamp"),
      "webhook-signature": req.header("webhook-signature"),
    });
  } catch {
    return res.sendStatus(400);
  }

  res.sendStatus(204);        // potvrď hned…
  void enqueue(event);        // …a teprve pak dělej práci
});

Verify against the RAW body before parsing it, reject a timestamp more than five minutes from now (that is the replay window we allow), and compare in constant time. During a rotation the header can carry several space-separated signatures — one match is enough.

Retries

A delivery counts as successful on any 2xx. Anything else is retried: the first attempt runs immediately, then seven more with growing gaps, about 58 hours in total, after which the delivery is marked dead and your admins are notified. A redirect is never followed and 410 Gone retires the endpoint immediately.

1 min · 5 min · 30 min · 2 h · 8 h · 24 h · 24 h

50 consecutive failures disable the endpoint, and the brewery sees a card about it in the app. Re-enable it in Settings → API once your receiver is healthy again.

Writing the receiver

Answer 2xx as soon as you have stored the event and do the real work afterwards — we time out after 10 seconds. Deduplicate on webhook-id, because at-least-once means the same event can arrive twice. Order is not guaranteed either: use the sequence field, which is monotonic per account, to decide what is newer.

Avoiding loops

An endpoint can be told to skip events its own key caused (exclude own events). Without it, writing through the API produces an event that your own receiver picks up and acts on again.

Catching up

Events are readable through GET /events with since_sequence, so a receiver that was down does not need a redelivery from us. Going back further than your plan's retention returns 410 events_expired — do a full sync rather than silently skipping the gap.

curl -s "https://www.profibrew.com/api/v1/events?since_sequence=4812&types=invoice.*" \
  -H "Authorization: Bearer $K"

Event types

Subscribe to exact types, to a whole prefix such as invoice.*, or to everything with *.

  • partner.created
  • partner.updated
  • item.created
  • item.updated
  • order.created
  • order.confirmed
  • order.shipped
  • order.delivered
  • order.invoiced
  • order.cancelled
  • stock_issue.created
  • stock_issue.confirmed
  • stock_issue.cancelled
  • stock_issue.reverted_to_draft
  • goods_receipt.created
  • goods_receipt.confirmed
  • goods_receipt.cancelled
  • goods_receipt.reverted_to_draft
  • stock_level.changed
  • invoice.created
  • invoice.issued
  • invoice.paid
  • invoice.payment_reverted
  • invoice.cancelled
  • received_invoice.created
  • received_invoice.confirmed
  • received_invoice.paid
  • received_invoice.payment_reverted
  • received_invoice.cancelled
  • cashflow.created
  • cashflow.paid
  • cashflow.payment_reverted
  • cashflow.cancelled
  • inbox.submission_received
  • workflow.step_called
  • workflow.step_waiting_external
  • workflow.step_completed_external
  • batch.phase_changed
  • batch.measurement_added

Recipes

Complete integrations end to end, with the call order, the error codes you will actually hit and runnable curl examples.

E-shop → order → stock issue → invoice

This recipe shows how to connect an e-shop to ProfiBrew: syncing the catalog and partners, creating an order from an e-shop sale and taking it all the way to a paid invoice — the exact same path a person would click through in the app, just over the API.

1. Goal

By the end of this recipe your integration can:

  1. keep the ProfiBrew catalog of items and partners in sync with the e-shop (two-way mapping via external_ref),
  2. create an order from an e-shop order with correct quantities and units,
  3. drive it through the workflow (confirm → stock issue → invoice → payment) using catalog actions, the same way a salesperson would in the UI,
  4. check stock availability before accepting an order.

2. Key, sandbox and scopes

Before integrating against live data, create a test key (pb_test_…) in /settings/api — it runs against the shadow DEMO account, so you can try the whole recipe without risking production data. Test and live keys work the same on both Profi-family hosts, the only difference is the X-Profi-Sandbox: 1 header and livemode: false in responses.

Pick the predefined E-shop scope preset:

core:read, core:write, stock:read, sales:read, sales:write, events:read

This preset does NOT include finance:write — it's still enough to invoice, because POST /invoices and actions/create_invoice also accept sales:write (an invoice created from an order belongs to Sales). The scope each call requires is in the OpenAPI document (x-scopes) and in the tables below.

Keep the key in an environment variable, never in a URL:

export K=pb_test_…

3. Base URL

https://www.profibrew.com/api/v1

(ProfiEkonom-branded customers use https://app.profifirma.cz/api/v1 — the same key works on both hosts).

4. Step 0 — who am I, and what does this account measure in

curl -s -H "Authorization: Bearer $K" \
  https://www.profibrew.com/api/v1/me | jq '{tenant, scopes, modules, measure_profile}'

curl -s -H "Authorization: Bearer $K" \
  https://www.profibrew.com/api/v1/units | jq '.data[] | {id, code, dimension, decimals}'

GET /v1/units returns the unit dictionary available to this account (system units per its unit system, plus its own custom units). Every quantity in the API references one of these via unit_id or unit_code — the API NEVER converts a quantity, so it pays off to know the account's offering up front.

5. Step 1 — catalog sync

For every e-shop item, first try to find the existing record by your reference:

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/items/by-external-ref/shoptet/10234"

404 not_found → the item doesn't exist yet, create it with external_ref and ?on_conflict=return (so a concurrent retry with the same reference doesn't fail on 409 but returns the existing record instead):

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: 8f14e45f-ceea-467e-bd3f-b7a1f1a2a3b4" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Lager 12° 0.5 l",
    "item_type": "purchased_sale_item",
    "unit": { "unit_code": "ks" },
    "sale_price": "34.90",
    "external_ref": { "system": "shoptet", "id": "10234" }
  }' \
  "https://www.profibrew.com/api/v1/items?on_conflict=return" | jq '{id, unit, external_ref}'

For an existing item, send only the changed fields via PATCH (the body is strict — an unknown field returns 422 unknown_field, it is never silently ignored):

curl -s -X PATCH -H "Authorization: Bearer $K" \
  -H "Content-Type: application/json" \
  -H "If-Match: $ETAG" \
  -d '{"sale_price": "36.90"}' \
  "https://www.profibrew.com/api/v1/items/$ITEM_ID"

If-Match (the ETag from a previous GET, header ETag: W/"…") is optional — without it, last-write-wins applies just like in the app; with it, a mismatch returns 412 stale_version so the integration knows to re-read the record.

Resolve the price for a given partner and quantity (partner → price list → discount → base price cascade, same as the app) with:

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/items/$ITEM_ID/prices?partner_id=$PARTNER_ID&quantity=6" \
  | jq '{unit_price, currency, price_source}'

quantity is always in the item's unit — the endpoint does not accept a unit for the query (the unit is derived from the item itself).

6. Step 2 — partner sync

Same pattern as items — by-external-ref first, then POST with ?on_conflict=return:

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: 3d3f0b3a-8b0b-4e9a-9a3a-2d3f0b3a8b0b" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "The Green Tree pub",
    "is_customer": true,
    "ico": "12345678",
    "email": "orders@pub.example",
    "external_ref": { "system": "shoptet", "id": "cust-9981" }
  }' \
  "https://www.profibrew.com/api/v1/partners?on_conflict=return" | jq '{id, external_ref}'

If a partner can also be looked up by IČO (business id) without your own reference, expect 409 duplicate_ico when ProfiBrew already has a partner with the same IČO under a different external_ref — the response carries existingName. In that case look the partner up via GET /v1/partners?search=<ico> and attach the reference yourself (PATCH the partner).

7. Step 3 — stock availability

Before accepting the order, check stock:

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/stock-levels?item_id=$ITEM_ID&warehouse_id=$WAREHOUSE_ID" \
  | jq '.data[] | {quantity, available_quantity, unit}'

available_quantity = quantity − reserved_quantity (never negative). The quantity is always in the item's unit, as everywhere in this API.

8. Step 4 — creating the order

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: 3fa85f64-5717-4562-b3fc-2c963f66afa6" \
  -H "Content-Type: application/json" \
  -d '{
    "partner_id": "'"$PARTNER_ID"'",
    "warehouse_id": "'"$WAREHOUSE_ID"'",
    "external_ref": { "system": "shoptet", "id": "ORD-2044" },
    "lines": [
      { "item_id": "'"$ITEM_ID"'", "quantity": "6", "unit": { "unit_id": "'"$UNIT_ID"'" } }
    ]
  }' \
  "https://www.profibrew.com/api/v1/orders?on_conflict=return" | jq '{id, number, status, allowed_actions}'

Notes:

  • unit on a line MUST be the item's own unit — a different one returns 422 unit_mismatch with expected_unit in the error body. The API never converts a quantity (S107 spec §3.4); send quantity in the unit that the item returned in step 1.
  • unit_price on a line can be omitted — it is resolved through the same price cascade as in step 1.
  • warehouse_id is only required when the account has more than one active shop (otherwise 422 shop_required — the order's shop is derived from the warehouse).
  • The order is created as a DRAFT (status: "draft") with origin: "api" — the app shows a "via API (key name)" badge on it.

9. Step 5 — driving the workflow through actions

State transitions do NOT go through a direct PATCH status — only through POST …/actions/{key}, with keys from the same catalogue that drives the buttons in the app. The document always carries allowed_actions — rely on that instead of a hardcoded sequence:

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/orders/$ORDER_ID/actions/confirm" \
  | jq '{status, allowed_actions}'

Fulfil the order (creates a draft stock issue — the order itself stays in the same status; confirming the issue is a separate step):

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/orders/$ORDER_ID/actions/create_stock_issue" \
  | jq '.created'
# → { "object": "stock_issue", "id": "…" }

STOCK_ISSUE_ID=…  # from the response above

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/stock-issues/$STOCK_ISSUE_ID/actions/confirm" \
  | jq '{status}'

Stock only changes on this confirm (CLAUDE.md: no movement without a confirmed document) — until then the issue is just a draft.

Invoicing (switches the order to invoiced):

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/orders/$ORDER_ID/actions/create_invoice" \
  | jq '.created'
# → { "object": "invoice", "id": "…" }

INVOICE_ID=…

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/invoices/$INVOICE_ID/actions/issue" \
  | jq '{status, number, allowed_actions}'

(Equivalent shortcut without the order action: POST /v1/invoices with body { "order_id": "…" } — creates and issues in the same two steps.)

Payment is OUTSIDE the workflow catalogue (payment engine, S89), but still hangs off actions/:

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"paid_at": "2026-09-15"}' \
  "https://www.profibrew.com/api/v1/invoices/$INVOICE_ID/actions/mark_paid" \
  | jq '{status, amount_paid, amount_remaining}'

Without account_id the account's default finance account for the invoice's payment method is used — if there is none, you get 422 account_required and need to send account_id. List the available accounts with:

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/finance-accounts" \
  | jq '.data[] | {id, type, name, currency}'

10. Incremental sync

For regularly pulling changes (prices, stock, order statuses), use updated_since + a cursor instead of a full re-scan:

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/orders?updated_since=2026-09-14T00:00:00Z&limit=100" \
  | jq '{next_cursor, has_more}'

When has_more: true, call again with the same updated_since and cursor=<next_cursor>. Store the last processed updated_at (or directly next_cursor) as your checkpoint for the next run.

11. Errors you'll actually hit in this flow

CodeWhenWhat to do
401 unauthenticatedmissing/invalid keycheck the Authorization: Bearer … header
403 insufficient_scopethe preset lacks a required scope (required_scopes in the body)add the scope to the key in /settings/api, or switch to a Custom preset
403 module_not_enabledthe account doesn't have the module in its subscriptionthis is on the customer to fix (upgrade)
404 not_foundwrong id, or another account's recordcheck the id belongs to the same livemode (test/live)
409 external_ref_conflictexternal_ref already in useretry with ?on_conflict=return, or use existing_id from the error body / the Location header
409 duplicate_icoa partner with the same IČO existslook the partner up via GET /partners?search=, attach the reference
409 invalid_transitionthe action isn't in allowed_actionsread allowed_actions from the error body — someone moved the document elsewhere in the meantime
412 stale_versionIf-Match doesn't match the current versionre-read the record, retry the PATCH with the new ETag
422 unit_mismatchthe line's unit ≠ the item's unitsend quantity in the unit from GET /items/{id}
422 quantity_scalemore decimal places than the unit allowsround to the unit's decimals yourself — the server never rounds silently
422 shop_requiredseveral active shops, warehouse_id missingsend warehouse_id
422 account_requiredmark_paid without a default accountlist GET /finance-accounts and send account_id
429 rate_limitedplan rate limit exceededwait per Retry-After, watch the RateLimit-* headers

12. Checklist before going live

  • Tested against pb_test_… on the sandbox (DEMO account), not just locally.
  • Switched to a pb_live_… key with the SAME scope preset you tested with — verify via GET /v1/me after switching (scopes, modules).
  • Every POST carries a unique Idempotency-Key (one UUID per request, not per operation type) — safe retries without duplicates.
  • Item and partner sync goes through external_ref + ?on_conflict=return, not name lookups.
  • Quantities are sent in the unit that GET /items/{id} returned — no local unit conversion before sending.
  • State transitions read allowed_actions from the response, no hardcoded step sequence.
  • 429 is handled with exponential backoff per Retry-After.
  • X-Request-Id from the headers is logged for every call — makes support much easier when something goes wrong.
  • Verified the behaviour when a scope/module is revoked (a removed add-on immediately removes the effective scope even though the key still has it stored).

Updated 2026-09-15

Accounting software — issued and received invoices, cash journal

This recipe shows how to connect an accounting/ERP system to ProfiBrew as a reader of documents (issued invoices, received invoices, cash journal) and a writer of payments — the typical flow for an external bookkeeper or accounting office that pulls documents and reports back what has been paid.

1. Goal

By the end of this recipe your integration can:

  1. regularly pull new and changed issued invoices, received invoices and cash-journal documents (updated_since + cursor),
  2. create a received invoice manually (one that arrived by post, not by scan),
  3. record a document's payment against a specific account,
  4. import a scanned or ISDOC invoice through an attachment and the digital mailroom (or the small-file shortcut directly).

2. Key, sandbox and scopes

Create a test key (pb_test_…) with the Accounting scope preset:

core:read, core:write, finance:read, finance:write, sales:read, events:read

sales:read is in the preset because issued invoices (S82) belong under Sales for accounts with the Sales module — without this scope, GET /invoices on such an account would return 403 insufficient_scope even with finance:read on the key. core:write is needed for uploading the attachment and the mailroom in step 3 (POST /attachments, /attachments/finalize, /inbox/submissions) — without it those three calls return 403 insufficient_scope, even though the rest of the recipe runs on finance:* alone.

export K=pb_test_…

Base URL: https://www.profibrew.com/api/v1 (or https://app.profifirma.cz/api/v1 for accounting offices working with ProfiEkonom-branded customers — the same key works on both).

3. Step 1 — pulling issued invoices

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/invoices?updated_since=2026-09-01T00:00:00Z&limit=100" \
  | jq '{next_cursor, has_more, count: (.data | length)}'

The list does NOT carry invoice lines (to keep the page small) — call the detail for lines:

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/invoices/$INVOICE_ID" \
  | jq '{number, status, totals, amount_paid, amount_remaining, lines}'

amount_remaining is always a read-only computed field (totals.incl_vat − amount_paid) — don't recompute it yourself, it saves you rounding discrepancies.

Keep paging while has_more is true:

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/invoices?updated_since=2026-09-01T00:00:00Z&cursor=$NEXT_CURSOR&limit=100"

Store the last processed updated_at as your checkpoint for the next run — don't persist the cursor itself between runs (it's only valid within a single updated_since paging sequence).

4. Step 2 — pulling received invoices

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/received-invoices?updated_since=2026-09-01T00:00:00Z&limit=100" \
  | jq '.data[] | {id, supplier_invoice_number, status, totals, paid_amount}'

Unlike an issued invoice, a received invoice stays editable after confirm — only export to accounting or cancellation locks it (Finance 3.0 R3). If your integration also creates received invoices manually (a document that didn't arrive as a scan):

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: 6b2f6a2e-1c3d-4e5f-8a9b-0c1d2e3f4a5b" \
  -H "Content-Type: application/json" \
  -d '{
    "supplier_invoice_number": "2026-441",
    "partner_id": "'"$PARTNER_ID"'",
    "issue_date": "2026-09-10",
    "due_date": "2026-09-24",
    "external_ref": { "system": "pohoda", "id": "PF-441" },
    "lines": [
      { "description": "Packaging material purchase", "amount": "4200.00", "vat_rate_pct": "21" }
    ]
  }' \
  "https://www.profibrew.com/api/v1/received-invoices?on_conflict=return" | jq '{id, status}'

A received-invoice line carries NO quantity or unit — it's a plain accounting split (description, amount excl. VAT, vat_rate_pct, an optional category_id), not a stock movement.

Confirm it (assigns the internal number, requires at least one line):

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/received-invoices/$RECEIVED_INVOICE_ID/actions/confirm" \
  | jq '{status, number, allowed_actions}'

5. Step 3 — scan / ISDOC import

Importing a scanned or ISDOC invoice takes a different path than the manual POST /received-invoices above — through an attachment and the digital mailroom.

5.1 Upload the file via a signed URL

Request an upload ticket. Create the attachment WITHOUT entity_type/entity_id — the file doesn't belong to a specific invoice yet, it's just waiting in the mailroom:

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Content-Type: application/json" \
  -d '{
    "file_name": "invoice-2026-441.isdoc",
    "mime_type": "application/xml",
    "file_size": 18432
  }' \
  "https://www.profibrew.com/api/v1/attachments" \
  | jq '{path, upload_url, upload_method, upload_headers, expires_at}'

The response carries path (opaque, send it back unchanged to finalize below), upload_url and upload_headers — there is no attachment id yet at this step. Upload the bytes with PUT exactly per the returned headers (no other authentication — the token in the URL authorizes exactly this path once):

curl -s -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/xml" \
  -H "x-upsert: false" \
  --data-binary @invoice-2026-441.isdoc

Create the attachment record (entity_type/entity_id must match what you sent to the first call — again both omitted here):

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "path": "'"$PATH"'",
    "file_name": "invoice-2026-441.isdoc",
    "mime_type": "application/xml"
  }' \
  "https://www.profibrew.com/api/v1/attachments/finalize" \
  | jq '{id, purpose, created_at}'

Only now do you have the attachment id (ATTACHMENT_ID below). The actual size is re-verified from storage right here — a mismatch or a plan quota breach fails on this call, not the first one. GET /v1/attachments/{id}/content returns a 302 to the current signed URL for the bytes at any time.

5.2 Direct ISDOC import

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "attachment_id": "'"$ATTACHMENT_ID"'",
    "file_name": "invoice-2026-441.isdoc"
  }' \
  "https://www.profibrew.com/api/v1/received-invoices/isdoc" \
  | jq '{received_invoice_id, supplier_name, document_number, pdf_attached}'

file_name must end in .isdoc/.isdocx — parsing is governed by the extension, independent of the declared MIME type. A duplicate document (matched by its ISDOC UUID) returns 409 duplicate; any other rejection reason returns 422 with that reason as code (unsupported_document_type, invalid_xml, no_invoice_in_archive, file_too_large, no_finance_module, …).

5.3 Or through the general mailroom

Instead of a direct import, you can submit the same attachment to the general digital mailroom — ISDOC gets recognized and imported automatically, other document types wait in Files for manual sorting (or go straight to the AI document inbox, depending on the account's setting):

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Content-Type: application/json" \
  -d '{"attachment_id": "'"$ATTACHMENT_ID"'"}' \
  "https://www.profibrew.com/api/v1/inbox/submissions" \
  | jq '{outcome, attachment_id, received_invoice_id, note, duplicate}'

outcome says what happened: isdoc_imported (a received invoice was created), isdoc_rejected (looked like ISDOC but the import failed — see note), files (waiting for manual sorting), scan (handed straight to the AI document inbox). The resulting document's source metadata will show "via API", same as the e-mail mailroom.

5.4 Small-file shortcut

Files up to 4 MB don't need a signed-URL upload — send them directly as multipart straight to the mailroom and skip step 5.1:

curl -s -X POST -H "Authorization: Bearer $K" \
  -F "file=@invoice-2026-441.isdoc;type=application/xml" \
  "https://www.profibrew.com/api/v1/inbox/submissions" \
  | jq '{outcome, received_invoice_id}'

Over 4 MB the endpoint returns 413 payload_too_large — above that limit, the only path is 5.1 (POST /attachments → PUT → finalize).

Idempotency-Key is safe on multipart uploads too — the server fingerprints the request from the raw bytes of the body, not decoded text, so the binary content of the attachment is not corrupted on a matching repeat call, and POST /inbox/submissions with a multipart body can be retried exactly like any other write call.

6. Step 4 — recording a payment

When you need a specific account (the account has no default set for the document's payment method, or you want the payment attributed elsewhere), list the available accounts:

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/finance-accounts" \
  | jq '.data[] | {id, type, name, currency, is_active}'

Balances are not exposed via the API — the endpoint only exists to look up an id for account_id below.

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"paid_at": "2026-09-15", "account_id": "'"$ACCOUNT_ID"'"}' \
  "https://www.profibrew.com/api/v1/invoices/$INVOICE_ID/actions/mark_paid" \
  | jq '{status, paid_at: .paid_at, amount_paid}'

The same action exists for received invoices (POST /received-invoices/{id}/actions/mark_paid) and for cash-journal documents (below). Without amount, the full remaining balance is used; without account_id, the account's default finance account for the document's payment method — if there is none, you get 422 account_required.

Reverting a payment (returns the document to its pre-paid status):

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/invoices/$INVOICE_ID/actions/revert_payment"

7. Step 5 — cash journal (simplified / internal documents)

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/cashflows?updated_since=2026-09-01T00:00:00Z&type=expense" \
  | jq '.data[] | {id, number, type, status, amount, paid_amount}'

Create a new document (confirmed by default — status can force draft/cancelled):

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: 1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809" \
  -H "Content-Type: application/json" \
  -d '{
    "cashflow_type": "expense",
    "amount": "1500.00",
    "date": "2026-09-15",
    "description": "Parking and a small purchase",
    "payment_method": "cash"
  }' \
  "https://www.profibrew.com/api/v1/cashflows" | jq '{id, number, status}'

A cash-journal document has NO external_ref (the DB column is a legacy one-off import artifact, the API neither exposes nor writes it), no PATCH, and no lines — just a header and two actions (mark_paid, cancel) derived directly from status, not from the workflow catalogue.

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/cashflows/$CASHFLOW_ID/actions/cancel"

8. Errors you'll actually hit in this flow

CodeWhenWhat to do
401 unauthenticatedmissing/invalid keycheck the Authorization header
403 insufficient_scopesales:read missing on an account with Salesadd the scope, see step 2 above
403 module_not_enabledthe account doesn't have Finance/Sales in its subscriptionoutside the integration's control
404 not_foundwrong id, or another livemodecheck test/live
409 invalid_transition (EXPORTED)the received invoice was already exported to accountingthe API refuses edits after export, same as the app
409 invalid_transition (other)the action isn't in allowed_actions (e.g. mark_paid on a draft)read allowed_actions from the error body
409 duplicatePOST /received-invoices/isdoc for a document with the same ISDOC UUIDsafe to call again, the invoice from the first import already exists
412 stale_versionIf-Match on a received-invoice PATCH doesn't matchre-read and retry with the current ETag
413 payload_too_largemultipart POST /inbox/submissions over 4 MBupload via POST /attachments → PUT → finalize, then send { attachment_id }
422 account_requiredmark_paid with no default account and no account_idlist GET /finance-accounts and send account_id, or have the customer set a default account
422 no_linesconfirm on a received invoice with no linesadd at least one line via POST …/lines
422 <reason>the ISDOC/ISDOCX could not be imported (unsupported_document_type, invalid_xml, no_invoice_in_archive, file_too_large, no_finance_module, …)code/detail in the error body describes why
422 validation_failed / unknown_fieldthe body doesn't match the schemacheck the OpenAPI document — write bodies are strict
429 rate_limitedplan rate limitslow down per Retry-After

9. Checklist before going live

  • Verified against pb_test_…, only then switched to pb_live_… with the same scope preset.
  • Syncing all three document types (invoices, received-invoices, cashflows) runs independently, each with its own updated_since checkpoint — cursors are not combined across resources.
  • Invoice/received-invoice detail is fetched separately whenever the integration needs lines (the list does not carry them).
  • account_id for mark_paid is either omitted (default account) or looked up via GET /finance-accounts — never hardcoded from a guess.
  • 409 invalid_transition with code EXPORTED is handled as a permanent state, not something to retry.
  • Scan/ISDOC import (step 3) distinguishes the direct path (POST /received-invoices/isdoc) from the general mailroom (POST /inbox/submissions) and knows which outcome/error code means what.
  • Every write POST carries an Idempotency-Key — including multipart POST /inbox/submissions, since the server fingerprints from the raw bytes.
  • X-Request-Id is logged for traceability when reconciling discrepancies.

Updated 2026-09-15

Warehouse system (WMS) — receipts, issues, stock levels, transfers

This recipe shows how to connect an external warehouse system (barcode scanners, a WMS terminal) to ProfiBrew's stock: posting goods receipts, issuing stock with a specific lot picked, keeping stock levels in sync, and transferring stock between warehouses.

1. Goal

By the end of this recipe your integration can:

  1. post a goods receipt as a draft + lines → confirm (stock only appears ON confirm, never before),
  2. post a stock issue, optionally with a manually picked FIFO layer (lot),
  3. keep a local copy of stock levels in sync via updated_since,
  4. transfer stock between two of the account's warehouses.

2. Key, sandbox and scopes

Test key (pb_test_…), the Warehouse / WMS scope preset:

core:read, stock:read, stock:write, sales:read, events:read
export K=pb_test_…

Base URL: https://www.profibrew.com/api/v1.

Before you start, look up warehouse and unit ids:

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/warehouses" | jq '.data[] | {id, code, name, categories}'

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/units" | jq '.data[] | {id, code, dimension}'

3. Step 1 — goods receipt (draft → lines → confirm)

Stock never appears without a confirmed goods receipt (CLAUDE.md, S104) — until you confirm the receipt it's just a draft and nothing is reflected on stock, even if it already has lines.

Create the draft, optionally with lines right away:

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: 2c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": "'"$WAREHOUSE_ID"'",
    "partner_id": "'"$SUPPLIER_ID"'",
    "movement_purpose": "purchase",
    "date": "2026-09-15",
    "external_ref": { "system": "wms", "id": "GR-8831" },
    "lines": [
      {
        "item_id": "'"$ITEM_ID"'",
        "quantity": "500",
        "unit": { "unit_code": "kg" },
        "unit_price": "28.50",
        "lot_number": "2026-K37",
        "expiry_date": "2027-03-01"
      }
    ]
  }' \
  "https://www.profibrew.com/api/v1/goods-receipts?on_conflict=return" | jq '{id, number, status, allowed_actions}'

Or add lines later, after the draft exists:

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "item_id": "'"$ITEM_ID"'",
    "quantity": "500",
    "unit": { "unit_code": "kg" },
    "unit_price": "28.50",
    "lot_number": "2026-K37"
  }' \
  "https://www.profibrew.com/api/v1/goods-receipts/$RECEIPT_ID/lines"

unit on a line must be the item's own unit (otherwise 422 unit_mismatch) — the API never converts a quantity. lot_number on a receipt line creates a NEW lot with this label; leave it out if lots don't matter to this account.

Confirm — only now does the stock actually appear:

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/goods-receipts/$RECEIPT_ID/actions/confirm" \
  | jq '{status, allowed_actions}'

The receipt's action catalogue only has confirm (from draft) and cancel (from confirmed) — a draft cannot be deleted via the API (no DELETE for the whole document, unlike order lines). Leave an unwanted draft alone, or confirm and immediately cancel it.

4. Step 2 — stock issue with lot selection

First check the available layers (remaining quantity per receipt line):

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/stock-levels/lots?item_id=$ITEM_ID&warehouse_id=$WAREHOUSE_ID" \
  | jq '.data[] | {id, lot_number, quantity, unit, expiry_date}'

id in the response IS the receipt_line_id — send it back as lot_id on an issue line to consume exactly that layer instead of the default FIFO:

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: 9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": "'"$WAREHOUSE_ID"'",
    "movement_purpose": "sale",
    "lines": [
      {
        "item_id": "'"$ITEM_ID"'",
        "quantity": "120",
        "unit": { "unit_code": "kg" },
        "lot_id": "'"$LOT_ID"'"
      }
    ]
  }' \
  "https://www.profibrew.com/api/v1/stock-issues" | jq '{id, number, status}'

Omit lot_id when you don't care which layer is consumed — confirm then picks the oldest available layer automatically, same as in the app.

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/stock-issues/$ISSUE_ID/actions/confirm"

Same as the receipt: confirm only from draft, cancel only from confirmed — and a draft still cannot be deleted.

5. Step 3 — syncing stock levels

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/stock-levels?warehouse_id=$WAREHOUSE_ID&updated_since=2026-09-14T00:00:00Z" \
  | jq '.data[] | {item: .item.code, quantity, available_quantity, unit}'

A row exists for every item × warehouse combination that has EVER had stock — even zero. quantity is always in the item's unit (never converted); available_quantity = quantity − reserved_quantity (never negative).

6. Step 4 — transferring between warehouses

A transfer is a stock issue with movement_purpose: "transfer" and target_warehouse_id set:

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: 4d5e6f70-8192-a3b4-c5d6-e7f809102a3b" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": "'"$WAREHOUSE_FROM"'",
    "target_warehouse_id": "'"$WAREHOUSE_TO"'",
    "movement_purpose": "transfer",
    "lines": [
      { "item_id": "'"$ITEM_ID"'", "quantity": "50", "unit": { "unit_code": "kg" } }
    ]
  }' \
  "https://www.profibrew.com/api/v1/stock-issues" | jq '{id}'

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/stock-issues/$TRANSFER_ID/actions/confirm"

Confirming removes stock from the source warehouse AND automatically creates a draft goods receipt at the DESTINATION warehouse (find it via GET /goods-receipts?warehouse_id=$WAREHOUSE_TO&status=draft) — that one needs to be confirmed separately for the stock to show up at the destination too:

curl -s -H "Authorization: Bearer $K" \
  "https://www.profibrew.com/api/v1/goods-receipts?warehouse_id=$WAREHOUSE_TO&status=draft" \
  | jq '.data[0].id'

curl -s -X POST -H "Authorization: Bearer $K" \
  -H "Idempotency-Key: $(uuidgen)" \
  "https://www.profibrew.com/api/v1/goods-receipts/$INCOMING_RECEIPT_ID/actions/confirm"

If the destination warehouse cannot hold the item at all (e.g. warehouse categories don't match), confirming the issue returns 409 transfer_blocked — check warehouse categories up front via GET /warehouses.

7. External reference shared across receipts and issues

external_ref on receipts and issues shares ONE column at the account level — the same {system, id} value cannot belong to both a receipt and an issue. If you send an external_ref already used by a document of the OPPOSITE family, you get 409 external_ref_conflict with a Location header pointing at the ACTUAL family of the found document (/goods-receipts/… or /stock-issues/…) — read the Location header, not just the path you called.

8. Errors you'll actually hit in this flow

CodeWhenWhat to do
401 unauthenticatedmissing/invalid keycheck the Authorization header
403 insufficient_scopestock:write missingadd the scope, see step 2 above
404 not_foundthe id belongs to the opposite family (receipt vs. issue), or another livemodeGET /goods-receipts/{id} for an issue's id returns 404, not the document
409 external_ref_conflictexternal_ref already used by a document of the opposite familyread the response's Location header, see section 7
409 invalid_transitionconfirm/cancel outside the current statusonly draft → confirm, confirmed → cancel
409 inventory_lockedthe warehouse is locked by a running inventory countwait for the count to finish
409 excise_reportedthe movement falls into an already-filed excise reportout of the API's reach, handled in the app
409 transfer_blockedthe transfer's destination warehouse can't accept the itemcheck warehouse categories via GET /warehouses
409 receipt_has_allocationscancelling a receipt whose lines have already been issued fromcancel the downstream issue first
422 validation_failed (invalid_reference)item_id/warehouse_id/partner_id/lot_id doesn't exist for this accountcheck the id via the matching GET
422 unit_mismatchthe line's unit ≠ the item's unitsend quantity in the unit from GET /items/{id}
422 quantity_scalemore decimal places than the unit allowsround to the unit's decimals
429 rate_limitedplan rate limitslow down per Retry-After

9. Checklist before going live

  • Verified against pb_test_…, only then switched to pb_live_….
  • The terminal/scanner never writes stock any other way than through a confirmed receipt/issue — no "quick fixes" to stock levels elsewhere.
  • Every POST carries an Idempotency-Key — especially important for barcode-scanning issues (unstable warehouse network, repeated retries).
  • external_ref for receipts and issues comes from one namespace (your WMS document id) — don't reuse the same number for a receipt and an issue.
  • Manual lot selection (lot_id) reads id from GET /stock-levels/lots RIGHT BEFORE creating the line — the layer may run out in between.
  • A transfer also confirms the automatically created receipt at the destination warehouse — without it, stock is missing at the destination even though the source-side issue went through.
  • Stock-level sync runs via updated_since, not a full listing of every item on each run.
  • X-Request-Id is logged for traceability.

Updated 2026-09-15

Reference

The full endpoint reference, generated from the same schemas the API validates against — it cannot drift from the implementation.

Open the OpenAPI document

Loading reference…

Errors

The type field of every problem document links to the matching row here. Branch on code — the wording of title and detail may change.

CodeStatusMeaning
invalid_idempotency_key400Idempotency-Key is malformed.
unauthenticated401Missing, malformed, expired or revoked API key.
account_locked403The account is locked.
addon_required403The account has no API add-on.
excise_not_enabled403The account keeps no excise tax records.
forbidden403The key's owner lacks the rights for this operation.
insufficient_scope403The key does not carry the scope this endpoint requires.
ip_not_allowed403The request came from an address outside the key's allow-list.
module_not_enabled403The module is not in the account's subscription.
not_found404No such record — also for records of another account.
unknown_action404Unknown workflow action for this document.
conflict409The request collides with the current state.
duplicate_ico409A partner with this IČO already exists.
equipment_occupied409The vessel the batch moves into is occupied by another batch.
excise_reported409The movement is already in a filed excise report.
external_ref_conflict409Another record already carries this external_ref.
idempotency_in_progress409A request with this Idempotency-Key is still running.
inventory_locked409A running inventory count locks the warehouse.
invalid_transition409The action is not allowed in the document's current status.
invoiced_period409The brew date falls into a period that was already invoiced.
item_not_in_stock409The cash desk sells only items in stock and this one is out.
no_keg_tapped409No keg of this draft beer is tapped at the cash desk.
receipt_has_allocations409Lines of the receipt were already issued.
repack_pair_cancel_required409Cancel both documents of a repack pair together.
reserved_by_other409The stock is reserved for another order.
sale_kegs_partially_returned409Some kegs of the sale were already returned.
settlement_failed409The sale was recorded but its settlement did not finish; `existing_id` carries it — do not resend.
step_handed_over409The wait timed out and the step was handed over to a person as a task.
step_not_external409The workflow step does not wait for an external system.
step_not_waiting409The workflow step is no longer waiting — it was completed, stopped or cancelled.
submission_blocked409The submission is blocked.
transfer_blocked409The transfer cannot be confirmed; `blockers` lists why.
events_expired410Events before this sequence fell out of the plan retention — do a full sync.
stale_version412If-Match does not match the current version of the record.
payload_too_large413Body or file is larger than the plan allows.
account_required422No default finance account — send account_id.
cash_desk_required422The shop has more than one cash desk (or none) — send cash_desk_id.
currency_not_supported422The currency is not supported here.
endpoint_limit422The plan's webhook endpoint limit is reached.
idempotency_key_reused422The same Idempotency-Key arrived with a different body.
invalid_amount422The amount is invalid.
invalid_url422The webhook URL is not a public https address.
item_not_available_at_pos422The item is not sold at this cash desk.
measurement_type_ambiguous422The measurement carries several values — send one, or set `type`.
measurement_value_required422The measurement has no value for its type.
missing_exchange_rate422A foreign-currency document needs an exchange rate.
no_file422No file in the request.
no_lines422The document has no lines.
overpay422The payment exceeds the remaining amount.
packaging_return_exceeds_balance422The return exceeds the partner's packaging balance.
partner_limit422The plan's partner limit is reached.
payment_amount_mismatch422The payment does not match the total of the sale; `expected_amount` carries it.
payment_method_not_supported422The payment method cannot be recorded for a POS sale.
quantity_scale422The quantity has more decimal places than the unit allows.
recipe_unit_dimension_mismatch422The recipe unit must share the dimension of the stock unit.
shop_required422The account has several shops — send warehouse_id.
storage_limit_exceeded422The plan's storage limit is exceeded.
system_warehouse422A system warehouse cannot be used on a manual document.
unit_mismatch422The unit does not match the unit of the item or line.
unit_not_available422The unit is not available to this account.
unknown_field422The body carries fields the API does not know.
unsupported_format422The file format is not supported.
validation_failed422The body failed validation; `errors[]` carries the rows.
warehouse_required422warehouse_id is required for this account.
rate_limited429Rate limit exceeded — back off per Retry-After.
internal_error500Unexpected error on our side; the request id is in `instance`.
upload_failed502Storing the file failed — retry later.
api_unavailable503The API is temporarily unavailable.
webhooks_not_configured503This deployment has no webhook signing key.

Codes inside errors[]

These describe one offending field of a validation_failed response; they never appear as the code of the response itself.

  • entity_pair_requiredThe pair of fields must be filled together.
  • exactly_one_ofExactly one of the listed fields is allowed.
  • invalid_referenceThe referenced record does not exist or is not usable.
  • not_allowedThe value is not allowed for this field.
  • requiredThe field is required.
  • unexpected_fieldThe field does not belong to this body.
  • unit_requiredThe quantity is missing its unit.
  • unknown_event_typeAn unknown event type or pattern in the subscription.
  • unknown_fieldAn unknown key among otherwise valid fields.

Glossary

The API speaks English, the app speaks Czech to the brewery. When you discuss a record with your customer, these are the two names for the same thing.

APIIn the appNote
partnerpartner (odběratel i dodavatel)One record for both roles; `is_customer` and `is_supplier` say which.
itempoložka (artikl)Materials and products share one catalogue.
unitměrná jednotkaQuantities always travel with a unit; document lines use the item unit without conversion.
orderobjednávka (přijatá)A customer order, not a purchase order.
goods receiptpříjemkaStock document with `movement_type: receipt`; its line is also the valuation layer (FIFO).
stock issuevýdejkaStock document with `movement_type: issue`; picks a layer (manual lot) or FIFO.
lotšarže (příjmová vrstva)`lot_id` on an issue line is the receipt line the quantity leaves from.
stock levelstav zásobyQuantity, reserved and available per item and warehouse, in the item unit.
invoicevydaná fakturaIssued to a customer; `issue` turns a draft into an issued document with a number.
received invoicedošlá fakturaSupplier invoice; lines carry no units.
cash flow documentzjednodušený (interní) dokladMoney movement; status is lifecycle only, payment state is `payment_status`.
warehouseskladStock location; system warehouses are not usable on manual documents.
shopprovozovnaBusiness location; derived automatically when the account has exactly one active.
excisespotřební daňReported in hectolitres and degrees Plato by law.
batchvárkaBrewery production run (public API coverage comes in F3).
inbox submissionpodání v podatelněA file entering the gateway from e-mail, upload or the API.
scan inboxschránka dokladůAI document extraction queue; needs the document scan add-on.
external referenceexterní reference`external_ref` is the integrator's own id as `<system>:<id>`, unique per account.
idempotency keyklíč idempotenceHeader on POST; the same key with the same body replays the stored response for 24 hours.
sandboxsandbox (demo účet)A `pb_test_` key works against the shadow demo account; responses carry `livemode: false`.
scopeoprávnění klíče`<module>:read` or `<module>:write`; effective scopes are the key's scopes intersected with the subscription.
domain eventdoménová událostSomething that happened in the account; ordered per account by `sequence`.
webhook endpointwebhook endpointYour https URL plus a signing secret; events are delivered there.
deliverydoručeníOne attempt trail per event and endpoint, with retries and a final dead state.

Changelog

Additive changes ship without a new version. A new dated version means a breaking change, and it is announced ahead of time.

2026-09-17

current

Missing domain events: goods receipts get their own event family, payment reversals cover every paid document, and reverting a stock document to draft is announced. Workflow steps can now call a webhook or wait for your system to approve, brewery data — batches, measurements, recipes, equipment and excise returns — is available, and sales from your register or e-shop can be recorded.

  • Breaking for subscribers of `stock_issue.*` who relied on it for goods receipts: a goods receipt now emits `goods_receipt.created` and `goods_receipt.cancelled` instead of `stock_issue.created` and `stock_issue.cancelled`. `goods_receipt.confirmed` is unchanged, so the whole goods receipt lifecycle is now under `goods_receipt.*`.
  • New `received_invoice.payment_reverted` and `cashflow.payment_reverted`. Until now only an issued invoice (`invoice.payment_reverted`) announced that its payment was cancelled; a received invoice or a cash flow document kept looking paid. A `*.payment_reverted` event is emitted when a payment is cancelled on the document — in the app, or through the `revert_payment` action on invoices and received invoices — and when the bank movement that paid it is deleted (for a transfer, both legs). Other changes that can leave a paid document only partially paid, such as raising the amount of a paid cash flow document, do not emit it yet; use `updated_since` on the resource as the safety net.
  • New `goods_receipt.reverted_to_draft` and `stock_issue.reverted_to_draft` when a confirmed document goes back to draft. Reverting a goods receipt also emits `stock_level.changed` for the stock it takes back; reverting a stock issue does not, because only an issue with no stock movements can be reverted.
  • `<document>.paid` is now also emitted when an existing free-standing payment is matched to a document and pays it in full. Before, only paying the document directly or in bulk emitted it.
  • Version field: events carry the contract version they were emitted under, so events emitted before this release keep `api_version: "2026-09-14"` and new ones carry `2026-09-17`. A webhook endpoint created earlier still shows `api_version: "2026-09-14"` in `GET /webhooks/endpoints` — that is the version it was created with; it does not change what it receives.
  • New `workflow.step_called`, `workflow.step_waiting_external` and `workflow.step_completed_external`, emitted by the workflow steps **Call a webhook** and **Wait for an external system**. The payload (`object: "workflow_step"`) carries the step, the workflow and the document it runs on, in the same shape as the document's GET. When the account chose a specific endpoint for the step, the event goes only to that endpoint, whether or not it subscribes to `workflow.*`. `workflow.step_waiting_external` is emitted by the system (`caused_by.kind: "system"`), so an endpoint with `exclude_own_events` still receives it when your own key moved the workflow to the waiting step.
  • New `POST /workflow-steps/{step_instance_id}/actions/complete` with `result: approved | rejected` and an optional `note` (scope `workflow:write`). `approved` continues the workflow; `rejected` stops it and shows `note` to the person as the reason. `data.object.actions.complete` in the waiting event holds the path while the step still waits. Errors: 404 `not_found`, 409 `step_not_external`, `step_not_waiting`, and `step_handed_over` once the wait timed out and the step became a task for a person, and 403 `account_locked` when the account is scheduled for deletion. The steps that follow an approval run right away under the same key: an action step (confirming an order, issuing an invoice…) needs the key's scope for its module, otherwise the workflow stops. The response carries `instance_status` so you can tell. Any key of the account with `workflow:write` can complete a waiting step; the key that did is stored on the step and returned as `completed_by_api_key_id` in the response and in the `workflow.*` event payload (`null` when the step was not completed through the API).
  • New brewery resources (scopes `brewery:read` / `brewery:write`): `GET /batches`, `GET /batches/{id}`, `GET /batches/{id}/measurements`, `POST /batches/{id}/measurements`, `POST /batches/{id}/actions/advance_phase`, `GET /recipes`, `GET /recipes/{id}` and `GET /equipment`. Physical values are canonical with the unit in the field name (`volume_l`, `og_plato`, `temperature_c`, `gravity_sg`, `pressure_bar`).
  • Record a measurement in the unit you measured in — `{ "temperature": { "value": "64.4", "unit": "f" }, "gravity": { "value": "1.048", "unit": "sg" } }` — and it is stored canonically, with the original kept in `entered`. `advance_phase` moves a batch only to its next phase (409 `invalid_transition` otherwise); writing off or blending a batch stays in the app.
  • New events `batch.phase_changed` and `batch.measurement_added`, emitted for changes made in the app (including cancelling or deleting a batch, reverting a phase and transferring a batch into its parent batch), through the API, and — for measurements — by sensor devices linked to the batch's vessel.
  • Recipe ingredients carry `quantity` with the unit of the recipe line; the API does not convert them to grams.
  • New `GET /excise/monthly-reports` (scope `brewery:read`): monthly excise tax returns, always in hectolitres and whole degrees Plato. 403 `excise_not_enabled` when the account keeps no excise records.
  • New `POST /pos/sales` and `GET /pos/sales/{id}`: record a completed sale and settle it like a POS tab — prices and VAT from the POS price list, stock written off from the cash desk's warehouse, a simplified sales document and the payment. Cash or card in CZK, one payment per sale; `payment.amount` must match the total (a difference of up to 0.50 CZK is rounding, otherwise 422 `payment_amount_mismatch` with `expected_amount`), and every line `quantity` must be greater than 0. The key needs `pos:write` and `finance:write`, plus `stock:write` when the cash desk moves stock — listed in the operation's `x-additional-scopes` (scopes needed on top of one of `x-scopes`). `external_ref` protects against recording the same sale twice. If the sale is recorded but its settlement does not finish, the response is 409 `settlement_failed` with `existing_id` and a `Location` header: the sale stays open and keeps its `external_ref`, so resending it cannot record it twice. Part of the settlement may already be written, so check the sale's documents in the app before settling it again there, or cancel it. `?on_conflict=return` then answers 200 with the sale in `status: "open"` — check `status` before treating it as done.
  • `POST /cashflows` no longer accepts `status: cancelled` — a cash flow document can only be created as `draft` or `confirmed`; cancel it with `actions/cancel`. Sending `cancelled` returns 422 `validation_failed`.

2026-09-14

First public version of the API (beta).

  • Keys, scopes and the sandbox: `pb_live_` / `pb_test_` keys, per-module scopes, test keys running against the shadow demo account.
  • Catalog and documents: partners, contacts, items, units, orders, goods receipts, stock issues, stock levels, invoices, received invoices, cash flow documents and attachments.
  • Workflow through actions: documents move with the same actions as in the app, so the API inherits rights, numbering, notifications and FIFO valuation.
  • Quantities always travel with their unit; the API never converts a quantity.
  • `external_ref` for mapping your own identifiers, `Idempotency-Key` on writes and `If-Match` on updates.
  • Domain events with a per-account `sequence`, `GET /events` and webhooks signed per Standard Webhooks.

Terms

Using the API means accepting the API terms of use; the account administrator confirms them once, when the first key is created.

Fair use in short: keep your key secret and rotate it if it leaks, respect the rate limits and back off when asked, do not use the API to mirror the whole database of an account you do not operate, and remember the customer's data stays the customer's — you process it on their behalf. The full legal terms, privacy policy and data processing agreement live with the rest of our legal documents.

API terms version v0.1

Legal documents