Purchase to pay: order, receipt, matching, variance, payment
A company orders goods and services, records what arrives, enters the vendor's bill and pays it. In between, the system compares the bill with the order and with what was received, and refuses to pay a bill that asks for more than was ordered or delivered until a second person has looked at it. This walkthrough runs that cycle on the Swiss entity CH01 with two orders: office chairs (goods, checked against a receipt: a 3-way match) and consulting days (services, checked only against the order: a 2-way match). It is for procurement and accounts-payable integrations, and for the controller who decides about differences.
Before you start
- Scopes:
draftto create and submit orders, record receipts, enter bills and pay them;readfor cards and matches;approveto approve an order or a variance;adminto change the tolerances and to issue an API key. - Two people. Ordering and approving are different jobs.
$ALICE_KEY(scopesread,draft,post,approve,admin) is the buyer and accounts-payable clerk: she orders, records receipts, enters the bills and pays them.$BOB_KEY(read,draft,post,approve) is the controller who approves. An API key counts as the person who created it: Alice and Bob are users who were invited, signed in once and created their key with their own session; in this demo that is already done.$ERP_KEY, the organization's first key, belongs to no person: it can set up and create things, but it can never approve anything. It appears only in refusals. - The demo organization: CH01 (Demo AG, CHF) has the active vendor
V-BM(Büro Material AG, 30 payment days). September 2026 is open. - A purchase order, a receipt and the matching post nothing to the ledger: an order is a commitment, not a liability. The liability is the vendor's bill, posted as usual. What the order controls is paying the bill.
1. Create a purchase order
An order is a draft for an active vendor, numbered at once (PO-2026-000001 and so on, a sequence
per entity and year). Each line has a description, a line_type (goods or services), an
optional expense or asset account, a quantity, the expected net unit_price and a rate_type
(the tax code). requires_receipt says whether bills are matched against what was received
(default: yes for goods, no for services). Currency and payment terms default to the vendor's. The
call needs an Idempotency-Key.
curl -X POST "$ERP_URL/v1/entities/CH01/purchase-orders" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-order-chairs" \
-H "Content-Type: application/json" \
-d '{
"vendor": "V-BM",
"order_date": "2026-09-22",
"expected_date": "2026-09-30",
"lines": [
{"description": "Office chairs", "quantity": "10", "unit_price": "100", "account": "4000"}
]
}'
{
"number": "<any>",
"status": "draft",
"vendor": "V-BM",
"currency": "CHF",
"payment_terms_days": 30,
"net_total": "1000.00",
"approval_id": null,
"lines": [
{
"line_no": 1,
"description": "Office chairs",
"line_type": "goods",
"account": "4000",
"quantity": "10",
"unit_price": "100",
"net_amount": "1000.00",
"requires_receipt": true,
"received_quantity": "0",
"invoiced_quantity": "0",
"invoiced_amount": "0.00"
}
],
"receipts": [],
"bills": []
}
The answer is the order's card: the lines with their ordered, received and invoiced
quantities, the receipts and the bills matched to it. It is the same shape every later call
returns. The call captured the order number as $ORDER_NO.
2. Read the order, change the draft
GET /v1/purchase-orders/{order} takes the id or the number. The ETag response header names the
version; a draft is changed with PATCH and If-Match, so that nobody overwrites a change they
have not seen (lines replaces all lines). Only drafts change.
curl "$ERP_URL/v1/purchase-orders/$ORDER_NO" \
-H "Authorization: Bearer $ALICE_KEY"
{"status": "draft", "net_total": "1000.00"}
curl -X PATCH "$ERP_URL/v1/purchase-orders/$ORDER_NO" \
-H "Authorization: Bearer $ALICE_KEY" \
-H 'If-Match: $ETAG' \
-H "Content-Type: application/json" \
-d '{"notes": "Deliver to the warehouse, ramp 3"}'
{"status": "draft", "notes": "Deliver to the warehouse, ramp 3"}
3. Submit the order: it waits for a second person
POST /v1/purchase-orders/{order}/submit sends a draft for approval. The default approval policy
says that a purchase order is approved by a second person, so the order becomes
pending_approval and an approval request with the action approve_purchase_order is opened. The
call takes If-Match too. The version read before the change is stale now, and is refused with
412:
curl -X POST "$ERP_URL/v1/purchase-orders/$ORDER_NO/submit" \
-H "Authorization: Bearer $ALICE_KEY" \
-H 'If-Match: $ETAG'
{"error": {"code": "precondition_failed"}}
With the version the PATCH returned it works:
curl -X POST "$ERP_URL/v1/purchase-orders/$ORDER_NO/submit" \
-H "Authorization: Bearer $ALICE_KEY" \
-H 'If-Match: $ETAG_NEW'
{"status": "pending_approval", "approval_id": "<uuid>"}
The card now carries the pending approval_id, which the call captured. The request is decided
with POST /v1/approvals/{id}/approve (or reject; a rejected order goes back to a draft). Three
callers are refused, each for its own reason, all with 403. Alice created and submitted the order,
and four eyes compare people:
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"comment": "approving my own order"}'
{"error": {"code": "four_eyes", "action": "approve"}}
The organization's first key belongs to nobody, and nobody is not a second person:
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Content-Type: application/json" \
-d '{"comment": "the first key is nobody"}'
{"error": {"code": "forbidden"}}
An agent (an automation) may draft and submit orders but never approves them. Its key cannot even
hold the approve scope:
curl -X POST "$ERP_URL/v1/api-keys" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "purchasing bot", "actor_type": "agent", "scopes": ["read", "draft", "post"]}'
{"actor_type": "agent", "scopes": ["read", "draft", "post"]}
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{"comment": "an agent approves"}'
{"error": {"code": "policy_denied", "rule_id": "agents_never_approve"}}
4. A second person approves: the order is open
Bob did not create or submit the order. His approval opens it.
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $BOB_KEY" \
-H "Content-Type: application/json" \
-d '{"comment": "in budget"}'
{"object_type": "purchase_order", "action": "approve_purchase_order", "status": "approved"}
curl "$ERP_URL/v1/purchase-orders/$ORDER_NO" \
-H "Authorization: Bearer $BOB_KEY"
{"status": "open", "approval_id": null, "approved_by": {"type": "human"}}
An approved order can no longer be changed (PATCH answers 409). It can receive goods.
5. Record a partial receipt
Six of the ten chairs arrive. POST /v1/purchase-orders/{order}/receipts records what was received
per order line: the receipt_date, an optional note (the delivery note number, say) and lines
of line_no and quantity. The quantity of a line can be corrected with a negative receipt, but
the received total always stays between zero and the ordered quantity. Receipts are append-only and
need an approved order and a person (agents are refused). They post nothing. The call needs an
Idempotency-Key, answers 201 and returns the order card.
curl -X POST "$ERP_URL/v1/purchase-orders/$ORDER_NO/receipts" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-receipt-1" \
-H "Content-Type: application/json" \
-d '{"receipt_date": "2026-09-23", "note": "Delivery note 4711", "lines": [{"line_no": 1, "quantity": "6"}]}'
{
"status": "partially_received",
"received_amount": "600.00",
"lines": [{"line_no": 1, "quantity": "10", "received_quantity": "6", "invoiced_quantity": "0"}],
"receipts": [
{
"number": "<any>",
"receipt_date": "2026-09-23",
"note": "Delivery note 4711",
"lines": [{"line_no": 1, "description": "Office chairs", "quantity": "6"}]
}
]
}
The order is now partially_received. An order can be cancelled only while nothing was received or
invoiced, so cancelling this one is refused:
curl -X POST "$ERP_URL/v1/purchase-orders/$ORDER_NO/cancel" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-cancel-chairs" \
-H "Content-Type: application/json" \
-d '{"reason": "no longer needed"}'
{"error": {"code": "order_status"}}
6. The vendor's bill for all ten: a variance
The vendor bills all ten chairs. The bill is an ordinary purchase bill whose order_reference is
the order number (it can come from an e-invoice or from bill capture too: EN 16931 BT-13). When the
bill is submitted, the system matches it to the order of that number of the same vendor, pairs
each bill line with an order line, and measures the differences:
- Price variance: the bill line's net amount less the order price times the billed quantity.
- Quantity variance: what the bill invoices beyond the limit, at the order price. The limit is the quantity received (3-way, for lines that need a receipt) or the quantity ordered (2-way), and what earlier bills of the order invoiced counts first.
- A variance is allowed up to the lower of
tolerance_percentof the line's expected amount andtolerance_amount. By default that is 2% and 5.00 per line; per bill the overcharge may be at most 2% and 20.00. Only unfavourable differences count: a lower price is recorded but never blocks.
Here the bill invoices 10 chairs and only 6 were received: 4 chairs at 100.00 is 400.00 beyond the
limit, far above the 5.00 allowed. The outcome is variance. The bill itself posts, because
the company owes the money for what the vendor says it delivered, but it is blocked for
payment until a second person approves the variance.
curl -X POST "$ERP_URL/v1/documents" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-bill-chairs" \
-H "Content-Type: application/json" \
-d '{
"entity": "CH01",
"kind": "purchase_bill",
"party": "V-BM",
"document_date": "2026-09-24",
"external_number": "BM-6001",
"order_reference": "$ORDER_NO",
"lines": [
{"description": "Office chairs", "quantity": "10", "unit_price": "100", "supply_type": "goods", "account": "4000"}
]
}'
{
"document": {
"status": "posted",
"party_code": "V-BM",
"external_number": "BM-6001",
"total": "1081.00",
"open_amount": "1081.00"
},
"submit": {"status": "posted"}
}
GET /v1/documents/{id}/match shows how the bill matched: the outcome, whether payment is blocked,
the problems in words, the tolerances in force and, per bill line, the order line it was paired
with, how (reference, description, price or single), whether it is 2-way or 3-way and the
variances against what is allowed. It answers 404 for a bill that names no order.
curl "$ERP_URL/v1/documents/$BILL_ID/match" \
-H "Authorization: Bearer $ALICE_KEY"
{
"document_id": "<uuid>",
"order_number": "<any>",
"order_status": "partially_invoiced",
"status": "variance",
"payment_block": true,
"problems": ["<any>", "<any>"],
"tolerance": {"percent": "2", "amount": "5.00", "bill_amount": "20.00"},
"price_variance": "0.00",
"quantity_variance": "400.00",
"overcharge": "400.00",
"approval_status": "pending",
"lines": [
{
"document_line_no": 1,
"description": "Office chairs",
"quantity": "10",
"order_line_no": 1,
"ordered_quantity": "10",
"received_quantity": "6",
"paired_by": "description",
"three_way": true,
"excess_quantity": "4",
"price_variance": "0.00",
"quantity_variance": "400.00",
"allowed": "5.00",
"within_tolerance": false
}
]
}
There are two problems: the line (it reads like "10 invoiced, 6 received: 400.00 beyond it
(allowed 5.00)") and the bill as a whole (400.00 above the order in total, allowed 20.00). A
variance opens an approval request, action approve_match_variance, shown as approval_id and
approval_status. The order card now lists the bill with its match status and the block, and the
order is partially_invoiced.
curl "$ERP_URL/v1/purchase-orders/$ORDER_NO" \
-H "Authorization: Bearer $ALICE_KEY"
{
"status": "partially_invoiced",
"invoiced_amount": "1000.00",
"lines": [{"line_no": 1, "received_quantity": "6", "invoiced_quantity": "10"}],
"bills": [
{
"external_number": "BM-6001",
"status": "posted",
"match_status": "variance",
"payment_block": true
}
]
}
7. The blocked bill cannot be paid
Paying is refused wherever it can happen: a payment (POST /v1/payments), a payment file, a bank
line match, and the database itself. A payment proposal leaves the bill out, so a payment run never
picks it up. A payment against the bill answers 409:
curl -X POST "$ERP_URL/v1/payments" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-pay-chairs-early" \
-H "Content-Type: application/json" \
-d '{
"entity": "CH01",
"direction": "outgoing",
"party": "V-BM",
"payment_date": "2026-09-30",
"amount": "$BILL_TOTAL",
"allocations": [{"document": "$BILL_ID", "amount": "$BILL_TOTAL"}]
}'
{"error": {"code": "bill_payment_blocked"}}
The message names the bill, the order and the way out
(POST /v1/documents/{id}/match/approve). The payment proposal for the vendor, which covers
everything due until the end of the year, shows nothing to pay:
curl -X POST "$ERP_URL/v1/entities/CH01/payment-proposals" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"payment_date": "2026-09-30", "due_until": "2026-12-31", "vendors": ["V-BM"]}'
{"entity": "CH01", "lines": []}
8. The rest of the goods arrives: the block lifts by itself
The remaining four chairs are delivered. Every receipt matches the order's unpaid bills again, so the bill that came before the goods is released without anybody approving anything: it is now within the order, the variance request is withdrawn, and the order has everything received and invoiced.
curl -X POST "$ERP_URL/v1/purchase-orders/$ORDER_NO/receipts" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-receipt-2" \
-H "Content-Type: application/json" \
-d '{"receipt_date": "2026-09-27", "lines": [{"line_no": 1, "quantity": "4"}]}'
{
"status": "closed",
"received_amount": "1000.00",
"invoiced_amount": "1000.00",
"lines": [{"line_no": 1, "received_quantity": "10", "invoiced_quantity": "10"}],
"bills": [{"external_number": "BM-6001", "match_status": "matched", "payment_block": false}]
}
curl "$ERP_URL/v1/documents/$BILL_ID/match" \
-H "Authorization: Bearer $ALICE_KEY"
{
"status": "matched",
"payment_block": false,
"problems": [],
"overcharge": "0.00",
"approval_status": "rejected",
"lines": [{"received_quantity": "10", "excess_quantity": "0", "within_tolerance": true}]
}
approval_status: rejected is the withdrawn request: the system closed it with the comment that the
bill matched again without a variance. A receipt can also go the other way: a negative receipt
(goods sent back) blocks the order's unpaid bills again, and flags a bill that was already paid for
review (review on its match and on the order card, ?review=true on the order list). POST /v1/documents/{id}/match matches a bill again on request, for example a draft before it is
submitted; an approved variance stays approved.
9. Pay the bill
The proposal now offers the bill, and the payment goes through.
curl -X POST "$ERP_URL/v1/entities/CH01/payment-proposals" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"payment_date": "2026-09-30", "due_until": "2026-12-31", "vendors": ["V-BM"]}'
{
"lines": [
{"vendor": "V-BM", "external_number": "BM-6001", "open": "1081.00", "amount": "1081.00", "reason": "due"}
]
}
curl -X POST "$ERP_URL/v1/payments" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-pay-chairs" \
-H "Content-Type: application/json" \
-d '{
"entity": "CH01",
"direction": "outgoing",
"party": "V-BM",
"payment_date": "2026-09-30",
"amount": "$BILL_TOTAL",
"allocations": [{"document": "$BILL_ID", "amount": "$BILL_TOTAL"}]
}'
{"status": "posted", "submit": {"status": "posted"}}
Releasing the payment as a pain.001 file is covered in From a bill to a payment file.
10. Services and a price variance: 2-way matching
A second order, for five consulting days at 200.00. Services do not need a receipt, so bills are matched against the ordered quantity (2-way). It is created, submitted and approved as before.
curl -X POST "$ERP_URL/v1/entities/CH01/purchase-orders" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-order-consulting" \
-H "Content-Type: application/json" \
-d '{
"vendor": "V-BM",
"order_date": "2026-09-28",
"lines": [
{"description": "Consulting", "line_type": "services", "quantity": "5", "unit_price": "200", "account": "6500"}
]
}'
{
"status": "draft",
"net_total": "1000.00",
"lines": [{"line_type": "services", "requires_receipt": false}]
}
curl -X POST "$ERP_URL/v1/purchase-orders/$ORDER2_NO/submit" \
-H "Authorization: Bearer $ALICE_KEY"
{"status": "pending_approval", "approval_id": "<uuid>"}
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL2_ID/approve" \
-H "Authorization: Bearer $BOB_KEY" \
-H "Content-Type: application/json" \
-d '{"comment": "in budget"}'
{"object_type": "purchase_order", "status": "approved"}
The vendor then bills the five days at 230.00 instead of 200.00. The price is 30.00 higher per day, 150.00 more in all, against an allowed 5.00 for the line: another variance. The bill posts and is blocked.
curl -X POST "$ERP_URL/v1/documents" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-bill-consulting" \
-H "Content-Type: application/json" \
-d '{
"entity": "CH01",
"kind": "purchase_bill",
"party": "V-BM",
"document_date": "2026-09-29",
"external_number": "BM-6002",
"order_reference": "$ORDER2_NO",
"lines": [
{"description": "Consulting", "quantity": "5", "unit_price": "230", "account": "6500"}
]
}'
{"document": {"status": "posted", "total": "1243.15"}}
curl "$ERP_URL/v1/documents/$BILL2_ID/match" \
-H "Authorization: Bearer $ALICE_KEY"
{
"status": "variance",
"payment_block": true,
"price_variance": "150.00",
"quantity_variance": "0.00",
"overcharge": "150.00",
"approval_status": "pending",
"lines": [
{
"paired_by": "description",
"three_way": false,
"ordered_quantity": "5",
"received_quantity": "0",
"price_variance": "150.00",
"allowed": "5.00",
"within_tolerance": false
}
]
}
No receipt can release this one: the variance is a price, not a quantity. Someone must decide that
230.00 is acceptable (the vendor raised its day rate and the buyer agreed) and say so with a
reason: POST /v1/documents/{id}/match/approve with {"reason": "..."} (scope approve). It is the
same as approving the variance request with POST /v1/approvals/{id}/approve. Four eyes apply: the
approver is a known person who did not enter, change, submit or match the bill and did not
request the approval; agents never approve. Alice entered the bill and the first key is nobody, so
both are refused:
curl -X POST "$ERP_URL/v1/documents/$BILL2_ID/match/approve" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "the day rate was raised"}'
{"error": {"code": "four_eyes", "action": "approve"}}
curl -X POST "$ERP_URL/v1/documents/$BILL2_ID/match/approve" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "the day rate was raised"}'
{"error": {"code": "forbidden"}}
Bob approves with a reason. The reason is kept on the match and in the audit trail. The match keeps
its outcome (variance: the bill does differ from the order) but the payment block is gone.
curl -X POST "$ERP_URL/v1/documents/$BILL2_ID/match/approve" \
-H "Authorization: Bearer $BOB_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "day rate raised to 230 as agreed with the vendor on 25 September"}'
{
"status": "variance",
"payment_block": false,
"approval_status": "approved",
"approved_by": {"type": "human"},
"approval_reason": "day rate raised to 230 as agreed with the vendor on 25 September"
}
What the approval is bound to: the bill and the order as they were, and at most the overcharge approved (150.00 here). Changing the bill (a new price or quantity on a draft, reopening it) clears the approval, and a later match that finds a larger overcharge asks again; the bill is blocked until a person approves it once more. Now the payment goes through:
curl -X POST "$ERP_URL/v1/payments" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-pay-consulting" \
-H "Content-Type: application/json" \
-d '{
"entity": "CH01",
"direction": "outgoing",
"party": "V-BM",
"payment_date": "2026-09-30",
"amount": "$BILL2_TOTAL",
"allocations": [{"document": "$BILL2_ID", "amount": "$BILL2_TOTAL"}]
}'
{"status": "posted", "submit": {"status": "posted"}}
11. Cards, history, tolerances and cancelling
The first order is closed: everything is received and invoiced. Its card shows what was ordered, received and invoiced per line, the receipts and the bills with their match status.
curl "$ERP_URL/v1/purchase-orders/$ORDER_NO" \
-H "Authorization: Bearer $BOB_KEY"
{
"status": "closed",
"net_total": "1000.00",
"received_amount": "1000.00",
"invoiced_amount": "1000.00",
"lines": [
{
"line_no": 1,
"quantity": "10",
"received_quantity": "10",
"invoiced_quantity": "10",
"invoiced_amount": "1000.00"
}
],
"receipts": [
{"lines": [{"line_no": 1, "quantity": "6"}]},
{"lines": [{"line_no": 1, "quantity": "4"}]}
],
"bills": [{"external_number": "BM-6001", "match_status": "matched", "payment_block": false}]
}
The order moved through open, partially_received, partially_invoiced and closed as the
receipts and bills came in; an order is closed once every line is invoiced and, where it needs a
receipt, received. The history lists the audit events of the order, oldest first: creation,
changes, submission, approval, each receipt and each status change.
curl "$ERP_URL/v1/purchase-orders/$ORDER_NO/history" \
-H "Authorization: Bearer $BOB_KEY"
[
{"action": "purchase_order.create"},
{"action": "purchase_order.update"},
{"action": "approval.request"},
{"action": "approval.approved"},
{"action": "purchase_order.approve"},
"..."
]
GET /v1/entities/CH01/purchase-orders lists the orders of an entity, newest first, filtered by
status (repeat the parameter), vendor, start and end (order date), q and review:
curl "$ERP_URL/v1/entities/CH01/purchase-orders?status=closed&vendor=V-BM" \
-H "Authorization: Bearer $BOB_KEY"
[{"status": "closed", "vendor": "V-BM"}, {"status": "closed", "vendor": "V-BM"}]
The tolerances belong to the entity. Until someone sets them, the built-in values apply:
curl "$ERP_URL/v1/entities/CH01/purchase-settings" \
-H "Authorization: Bearer $BOB_KEY"
{
"entity": "CH01",
"default": true,
"tolerance_percent": "2",
"tolerance_amount": "5.00",
"tolerance_bill_amount": "20.00"
}
PUT sets them (scope admin; audited with the previous and the new values). A variance is
allowed up to tolerance_percent of a line's expected amount and at most tolerance_amount, the
stricter of the two; tolerance_bill_amount limits the total overcharge of a bill. The limits are
10%, 1,000.00 per line and 10,000.00 per bill. Tolerances apply to bills matched from then on.
curl -X PUT "$ERP_URL/v1/entities/CH01/purchase-settings" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"tolerance_percent": "3", "tolerance_amount": "10.00", "tolerance_bill_amount": "30.00"}'
{
"default": false,
"tolerance_percent": "3",
"tolerance_amount": "10.00",
"tolerance_bill_amount": "30.00"
}
An order that has not moved can be cancelled, with a reason that stays on the order (a pending
approval request is withdrawn). Idempotency-Key and, optionally, If-Match apply.
curl -X POST "$ERP_URL/v1/entities/CH01/purchase-orders" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-order-spare" \
-H "Content-Type: application/json" \
-d '{"vendor": "V-BM", "order_date": "2026-09-30", "lines": [{"description": "Desk lamps", "quantity": "4", "unit_price": "45", "account": "4000"}]}'
{"status": "draft", "net_total": "180.00"}
curl -X POST "$ERP_URL/v1/purchase-orders/$SPARE_NO/cancel" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-cancel-spare" \
-H "Content-Type: application/json" \
-d '{"reason": "ordered from another vendor"}'
{"status": "cancelled", "cancel_reason": "ordered from another vendor"}
What can go wrong
Goods cannot be received beyond the ordered quantity (or below zero). The first order already has all ten chairs, so one more is refused with 422:
curl -X POST "$ERP_URL/v1/purchase-orders/$ORDER_NO/receipts" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-receipt-too-many" \
-H "Content-Type: application/json" \
-d '{"receipt_date": "2026-09-28", "lines": [{"line_no": 1, "quantity": "1"}]}'
{"error": {"code": "received_more_than_ordered"}}
Only a draft can be changed. The approved order is fixed, and a changed commitment is a new order (or a cancelled one):
curl -X PATCH "$ERP_URL/v1/purchase-orders/$ORDER_NO" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"notes": "too late"}'
{"error": {"code": "order_status"}}
Tolerances have limits (at most 10% of the line):
curl -X PUT "$ERP_URL/v1/entities/CH01/purchase-settings" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"tolerance_percent": "11", "tolerance_amount": "10.00"}'
{}
A cancelled order does not change: cancelling it again is refused with 409.
curl -X POST "$ERP_URL/v1/purchase-orders/$SPARE_NO/cancel" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: p2p-cancel-spare-again" \
-H "Content-Type: application/json" \
-d '{"reason": "again"}'
{"error": {"code": "order_status"}}
Reference
POST /v1/entities/{entity}/purchase-ordersGET /v1/entities/{entity}/purchase-ordersGET /v1/purchase-orders/{order}PATCH /v1/purchase-orders/{order}POST /v1/purchase-orders/{order}/submitPOST /v1/purchase-orders/{order}/cancelPOST /v1/purchase-orders/{order}/receiptsGET /v1/purchase-orders/{order}/historyGET /v1/documents/{document_id}/matchPOST /v1/documents/{document_id}/matchPOST /v1/documents/{document_id}/match/approveGET /v1/entities/{entity}/purchase-settingsPUT /v1/entities/{entity}/purchase-settingsPOST /v1/approvals/{approval_id}/approvePOST /v1/documentsPOST /v1/paymentsPOST /v1/entities/{entity}/payment-proposals