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:
- keep the ProfiBrew catalog of items and partners in sync with the
e-shop (two-way mapping via
external_ref),
- create an order from an e-shop order with correct quantities and
units,
- drive it through the workflow (confirm → stock issue → invoice →
payment) using catalog actions, the same way a salesperson would in
the UI,
- 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
| Code | When | What to do |
|---|
401 unauthenticated | missing/invalid key | check the Authorization: Bearer … header |
403 insufficient_scope | the 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_enabled | the account doesn't have the module in its subscription | this is on the customer to fix (upgrade) |
404 not_found | wrong id, or another account's record | check the id belongs to the same livemode (test/live) |
409 external_ref_conflict | external_ref already in use | retry with ?on_conflict=return, or use existing_id from the error body / the Location header |
409 duplicate_ico | a partner with the same IČO exists | look the partner up via GET /partners?search=, attach the reference |
409 invalid_transition | the action isn't in allowed_actions | read allowed_actions from the error body — someone moved the document elsewhere in the meantime |
412 stale_version | If-Match doesn't match the current version | re-read the record, retry the PATCH with the new ETag |
422 unit_mismatch | the line's unit ≠ the item's unit | send quantity in the unit from GET /items/{id} |
422 quantity_scale | more decimal places than the unit allows | round to the unit's decimals yourself — the server never rounds silently |
422 shop_required | several active shops, warehouse_id missing | send warehouse_id |
422 account_required | mark_paid without a default account | list GET /finance-accounts and send account_id |
429 rate_limited | plan rate limit exceeded | wait per Retry-After, watch the RateLimit-* headers |
12. Checklist before going live
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:
- regularly pull new and changed issued invoices, received invoices and
cash-journal documents (
updated_since + cursor),
- create a received invoice manually (one that arrived by post, not by
scan),
- record a document's payment against a specific account,
- 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
| Code | When | What to do |
|---|
401 unauthenticated | missing/invalid key | check the Authorization header |
403 insufficient_scope | sales:read missing on an account with Sales | add the scope, see step 2 above |
403 module_not_enabled | the account doesn't have Finance/Sales in its subscription | outside the integration's control |
404 not_found | wrong id, or another livemode | check test/live |
409 invalid_transition (EXPORTED) | the received invoice was already exported to accounting | the 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 duplicate | POST /received-invoices/isdoc for a document with the same ISDOC UUID | safe to call again, the invoice from the first import already exists |
412 stale_version | If-Match on a received-invoice PATCH doesn't match | re-read and retry with the current ETag |
413 payload_too_large | multipart POST /inbox/submissions over 4 MB | upload via POST /attachments → PUT → finalize, then send { attachment_id } |
422 account_required | mark_paid with no default account and no account_id | list GET /finance-accounts and send account_id, or have the customer set a default account |
422 no_lines | confirm on a received invoice with no lines | add 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_field | the body doesn't match the schema | check the OpenAPI document — write bodies are strict |
429 rate_limited | plan rate limit | slow down per Retry-After |
9. Checklist before going live
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:
- post a goods receipt as a draft + lines → confirm (stock only appears
ON confirm, never before),
- post a stock issue, optionally with a manually picked FIFO layer
(lot),
- keep a local copy of stock levels in sync via
updated_since,
- 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
| Code | When | What to do |
|---|
401 unauthenticated | missing/invalid key | check the Authorization header |
403 insufficient_scope | stock:write missing | add the scope, see step 2 above |
404 not_found | the id belongs to the opposite family (receipt vs. issue), or another livemode | GET /goods-receipts/{id} for an issue's id returns 404, not the document |
409 external_ref_conflict | external_ref already used by a document of the opposite family | read the response's Location header, see section 7 |
409 invalid_transition | confirm/cancel outside the current status | only draft → confirm, confirmed → cancel |
409 inventory_locked | the warehouse is locked by a running inventory count | wait for the count to finish |
409 excise_reported | the movement falls into an already-filed excise report | out of the API's reach, handled in the app |
409 transfer_blocked | the transfer's destination warehouse can't accept the item | check warehouse categories via GET /warehouses |
409 receipt_has_allocations | cancelling a receipt whose lines have already been issued from | cancel the downstream issue first |
422 validation_failed (invalid_reference) | item_id/warehouse_id/partner_id/lot_id doesn't exist for this account | check the id via the matching GET |
422 unit_mismatch | the line's unit ≠ the item's unit | send quantity in the unit from GET /items/{id} |
422 quantity_scale | more decimal places than the unit allows | round to the unit's decimals |
429 rate_limited | plan rate limit | slow down per Retry-After |
9. Checklist before going live
Updated 2026-09-15