# Order writes

Confirm an offline payment, cancel an unpaid order, add internal notes and record shipments with tracking, without moving money.

Section: Commerce · REST API
Canonical URL: https://docs.omniretailconnect.nl/commerce/api/order-writes

## Overview

Five endpoints let an integration work an order the way staff do on the order page, without ever moving money. They need `commerce.write.orders` together with `commerce.read` (edit permission **Orders**, *Bestellingen*, in **Settings** > **API tokens**; a token with it lives at most 30 days). Each is the MCP tool of the same name, described in [Order tools](https://docs.omniretailconnect.nl/commerce/mcp/order-tools.md), with the same `data`, the same change log (channel `api`) and the same [write limit](https://docs.omniretailconnect.nl/commerce/getting-started/limits.md#write-limit). The order's activity trail (the *Activiteit* tab in the admin) names the token. The order id is the internal `id` of `GET /orders`; it is in the URL and must not be in the body.

> **Security — No refunds, no paid cancellations:** These endpoints never move money. There is no refund and no cancellation of a paid order in this API. Internal notes and tracking codes can hold personal data: they are only returned with `commerce.read.pii` and are never written to the change log. See [Scopes and masking](https://docs.omniretailconnect.nl/commerce/reference/scopes-and-masking.md).

## Confirm an offline payment

`POST /api/v1/orders/{id}/confirm-payment` — Needs commerce.write.orders. Equivalent MCP tool: confirm_offline_payment.

No body. Records that the full amount of an unpaid draft order (status `awaiting-payment`) was received outside the payment provider, for example by bank transfer. The order is placed (status `payment-offline`), its stock is committed and, after the commit, the customer gets the order confirmation and the shop its notification. The order's activity trail names the token as approver.

- Result: `id`, `reference`, `fulfilment_state`, `status`, `placed_at`, `payment_state`, `changed`, `changes`.
- An order this already confirmed answers `200` with `changed: false` and sends no second mail.
- Any other order (paid through the provider, cancelled, held for payment review) is `409`. So is a draft whose online payment is still open at the provider; staff in the admin may still confirm a bank transfer next to an abandoned online attempt, a token may not.

## Cancel an unpaid order

`POST /api/v1/orders/{id}/cancel` — Needs commerce.write.orders. Equivalent MCP tool: cancel_unpaid_order.

Body: `reason`, one of `customer_request`, `payment_not_received`, `duplicate_order`, `suspected_fraud` or `other`. Cancels an unpaid draft order and releases its stock hold. This cannot be undone. The result is the same as for confirming.

- An already cancelled order answers `200` with `changed: false`.
- A draft held for payment review (money may have arrived: settle it on the order page), a draft whose online payment is still open, and a placed or paid order are `409`.

## Add an internal note

`POST /api/v1/orders/{id}/notes` — Needs commerce.write.orders. Equivalent MCP tool: add_order_note.

Body: `body`, up to 2000 characters. Adds an internal note for staff, shown on the order's activity trail and never to the customer or in a customer mail. It is stored as the text that was sent: never interpreted as HTML (angle brackets stay as they are, every view escapes them), control characters removed, line breaks kept. Result: `order` (`id`, `reference`, `fulfilment_state`), `note` (`id`, `body` as stored, `author`, `author_type` `token`, `created_at`), `changed` (always `true`), `changes`.

- **Not idempotent.** Every call adds a note. Do not repeat a call that succeeded.
- Read notes back in `internal_notes` of [`GET /orders/{reference}`](https://docs.omniretailconnect.nl/commerce/api/orders.md); that needs `commerce.read.pii`.
- Notes may hold personal data. They are part of a data-subject access export and are deleted when the customer's data is erased. The change log keeps the note id, never the text.

**Add an internal note**

```bash
curl -sS -X POST "https://<your-shop-domain>/api/v1/orders/1042/notes" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body": "Customer called: deliver after 17:00."}'
```

## Record a shipment

`POST /api/v1/orders/{id}/shipments` — Needs commerce.write.orders. Equivalent MCP tool: add_shipment.

Records a shipment with track and trace for an order that is paid in full, through the staff rules. Without `lines` everything still to ship goes out. The order must not be cancelled, held for review or fully refunded, and a quantity may not exceed what is left (`409` otherwise). The shipment names no staff member; the order's activity trail names the token.

*Body fields of POST /orders/{id}/shipments*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `idempotency_key` | string | Required | 1 to 64 of A-Z a-z 0-9 . _ : -. The same key with the same carrier, tracking code and lines answers `changed: false` and sends no second mail. The same key with other data, or on another order, is `409`. |
| `carrier` | string | Required | A plain carrier name. |
| `tracking_code` | string | Optional | A plain code. Leave it out for letter post without track and trace. |
| `tracking_url` | string | Optional | `https` only, at most 500 characters, and only together with a `tracking_code`. |
| `shipped_at` | string | Optional | ISO 8601 with an offset, not in the future, not before the order was placed. Default now. |
| `lines` | array | Optional | `[{line_id, quantity}]`, physical lines of this order, each once. Omitted: everything still to ship. |
| `notify_customer` | boolean | Optional | Default `true`: the customer gets the shipping mail after the commit. `false` records it silently. |

**Record a shipment**

```bash
curl -sS -X POST "https://<your-shop-domain>/api/v1/orders/1042/shipments" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"idempotency_key": "agent-1042-1", "carrier": "PostNL", "tracking_code": "3SABCD1234567", "tracking_url": "https://track.example.com/3SABCD1234567"}'
```

Result: `order`, `shipment` (`id`, `carrier`, `tracking_code`, `tracking_url`, `status`, `shipped_at`, `notify_customer`, `lines`), `changed`, `changes`. `tracking_code` and `tracking_url` are `null` without `commerce.read.pii`, as in `GET /orders/{reference}`. The change log keeps a fingerprint (`sha256:...`) of a changed code or link, never the value.

## Correct the tracking of a shipment

`PATCH /api/v1/orders/{id}/shipments/{shipmentId}` — Needs commerce.write.orders. Equivalent MCP tool: update_shipment_tracking.

Send at least one field. Only those fields change; status, lines and dates stay, and no mail is sent. Equal values answer `200` with `changed: false`. A shipment of another order is `404`; a cancelled shipment, or one created through a carrier integration (the carrier owns its tracking), is `409`. `shipmentId` is `fulfilment.shipments[].id` of the order.

*Body fields of PATCH /orders/{id}/shipments/{shipmentId}*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `carrier` | string | Optional | A plain carrier name. |
| `tracking_code` | string\|null | Optional | A plain code. `null` removes it. |
| `tracking_url` | string\|null | Optional | `https` only, at most 500 characters, only together with a tracking code. `null` removes it. |

## Errors

*Errors of the order write endpoints*

| Status | error.code | When |
| --- | --- | --- |
| `403` | `forbidden_scope` | The token does not have `commerce.write.orders`. |
| `404` | `not_found` | Unknown order, or a shipment that belongs to another order. |
| `409` | `conflict` | The order is not in a state the action allows (see the rules above); an `idempotency_key` already used with other data or on another order; a quantity above what is left to ship; a cancelled shipment or one made through a carrier integration on `PATCH`. |
| `422` | `invalid_argument` | An empty note, an `http` tracking link, a tracking link without a tracking code, a reason that is not in the list, a body field that does not exist, or no field at all on `PATCH`. |
| `429` | `too_many_requests` | The write limit. The write did not happen. |
