E-shop orders
Push an e-shop order into WinShop as an internet order, follow and change its state, cancel it, and make retries safe.
Goal: when a customer places an order in the e-shop, create it in WinShop so that the warehouse can pick, pack and invoice it. Then keep both sides informed about its state.
In WinShop an order that comes from an e-shop is an internet order. It is a document like any other: a header, items, and per item the variants (colour and size combinations). Its routes start with /api/documents/orders/internet.
Before you start
Collect these ids. All of them are data of the customer's instance, so read them from the API and keep them in your configuration; do not hard-code them.
| You need | Field on the order | Where it comes from |
|---|---|---|
| The warehouse the order ships from | warehouseId | GET /api/item-lists/warehouses |
| The customer | customerId | A company, see below |
| The payment method | paymentTypeId | GET /api/item-lists/payment-types |
| The shipping method | transportTypeId | GET /api/item-lists/transport-types |
| Which e-shop the order came from | eshopSourceId | GET /api/item-lists/orders/eshop-sources |
| The states you will set and watch | customDocumentStateId | GET /api/item-lists/documents/orders/states |
curl https://<your-instance>/api/item-lists/payment-types \
-H "Authorization: Bearer $ACCESS_TOKEN"[
{ "id": 1, "name": "Cash on delivery", "isCash": true },
{ "id": 2, "name": "Card online", "isPaidInAdvance": true }
]The customer. WinShop keeps customers as companies, and customerId is the id of a company. Agree with the customer how e-shop buyers map to companies: one company per registered buyer, or one collective company for all e-shop orders with the buyer's details in the delivery address. You can search companies with POST /api/companies/filter-paginated and create them with POST /api/companies; see Companies.
The products. Order items refer to WinShop productIds, units and variant ids. The Product catalog sync guide shows how to get them into the e-shop.
Setup
The snippets build on each other, in page order. In C#, keep the record declarations of all snippets at the end of your file. GetAccessTokenAsync() and getAccessToken() are the token cache from Authentication.
using System.Net.Http.Headers;
using System.Net.Http.Json;
var http = new HttpClient { BaseAddress = new Uri("https://<your-instance>") };
async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, object? body = null)
{
using var request = new HttpRequestMessage(method, path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessTokenAsync());
if (body is not null) request.Content = JsonContent.Create(body);
var response = await http.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
// the body is an ErrorResult, see the Errors page
throw new HttpRequestException(
$"{method} {path} failed: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
}
return response;
}
record Page<T>(List<T> Data, int TotalCount, bool HasNextPage);The flow, step by step
Check whether the order already exists
Do this first, every time. The API has no idempotency key: if you send the same order twice, you get two orders. A timeout or a dropped connection does not tell you whether the first attempt was stored, so a blind retry is not safe.
The fix is to give every order a number you can look up:
- Put your e-shop order number into
formNumber. It is a free-text field of up to 150 characters, and by WinShop convention it holds the number a document has in the outside world. - Before you create, and before every retry, search for that number with
filter.basic.formNumber. - The search is a substring match: searching for
E2026-1001also findsE2026-10015. CompareformNumberfor exact equality in your own code. A short number can match many orders, so read every page of the result before you conclude that the order does not exist.
Do not use orderAppNumber or eshopMetadata for this
orderAppNumber holds at most 10 characters, too short for most order numbers. eshopMetadata is a free-text field
on the order items and cannot be searched. formNumber is the field to use.
async Task<int?> FindOrderIdAsync(string eshopOrderNumber)
{
for (var pageNumber = 1; ; pageNumber++)
{
using var response = await SendAsync(HttpMethod.Post, "/api/documents/orders/internet/filter-paginated", new
{
filter = new { basic = new { formNumber = eshopOrderNumber } },
pageNumber,
pageSize = 100,
orderBy = new[] { "id" },
limitFields = new[] { "id", "formNumber" },
});
var page = (await response.Content.ReadFromJsonAsync<Page<OrderRow>>())!;
// the filter is a substring match: compare exactly here
var match = page.Data.FirstOrDefault(row => row.FormNumber == eshopOrderNumber);
if (match is not null) return match.Id;
if (!page.HasNextPage) return null;
}
}
record OrderRow(int Id, string FormNumber);Create the delivery address
An order does not carry the address itself. The address is a separate record that the order points to with deliveryAddressId. Create it first.
The endpoint answers 201 Created with an empty body. The id of the new address is the last segment of the Location header.
async Task<int> CreateDeliveryAddressAsync()
{
using var response = await SendAsync(HttpMethod.Post, "/api/documents/orders/delivery-address", new
{
deliveryAddress = new
{
name = "Jana Novakova",
address1 = "Dlouha 12",
city = "Praha",
zipCode = "11000",
country = "CZ",
phone = "+420600000000",
email = "[email protected]",
},
invoicingAddress = new
{
name = "Jana Novakova",
address1 = "Dlouha 12",
city = "Praha",
zipCode = "11000",
country = "CZ",
},
note = "Ring twice",
});
// Location: https://<your-instance>/api/documents/orders/delivery-address/9917
return int.Parse(response.Headers.Location!.OriginalString.TrimEnd('/').Split('/')[^1]);
}Create the internet order
POST /api/documents/orders/internet with the header and the items.
Header. Only customerId and warehouseId are required by the API. For a useful e-shop order also send formNumber, deliveryAddressId, paymentTypeId, transportTypeId and eshopSourceId.
Items. Every item needs:
productIdunitIdIssued, the unit the quantity is in. Use the product's ownunitIdunless you sell in another unit.price(with VAT),priceWoVat(without) andvatRate. The three must agree with each other, otherwise the order is rejected.- the quantity, in one of two ways:
- for a product without variants:
quantityIssuedon the item; - for a product with variants: one entry in
variants[]per colour and size combination, each with its ownquantity. Do not setquantityIssued; if you do, it must equal the sum of the variants.
- for a product without variants:
curl -X POST https://<your-instance>/api/documents/orders/internet \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"customerId": 1042,
"warehouseId": 1,
"formNumber": "E2026-1001",
"deliveryAddressId": 9917,
"paymentTypeId": 2,
"transportTypeId": 3,
"eshopSourceId": 1,
"note": "Gift, please wrap",
"items": [
{
"productId": 733,
"unitIdIssued": 1,
"quantityIssued": 2,
"priceWoVat": 100,
"price": 121,
"vatRate": 21
},
{
"productId": 5120,
"unitIdIssued": 1,
"priceWoVat": 200,
"price": 242,
"vatRate": 21,
"variants": [
{ "colorId": 4, "sizeId": 12, "quantity": 1 },
{ "colorId": 4, "sizeId": 13, "quantity": 2 }
]
}
]
}'The response is the stored order, with its id, the internalNumber WinShop assigned and the ids of the items. Store the id with your e-shop order; every later call uses it.
If the request is invalid you get 400, and messages lists every problem at once:
{
"messages": ["CustomerId is required for new order.", "WarehouseId is required for new order."],
"statusCode": 400
}A 400 is a problem with the data, so do not retry it unchanged. For a timeout or a 5xx, go back to step 1: look the order up by formNumber before you send it again.
Read the order back
GET /api/documents/orders/internet/{documentId} returns the full order: header, items and variants.
curl https://<your-instance>/api/documents/orders/internet/8841 \
-H "Authorization: Bearer $ACCESS_TOKEN"To follow many orders, do not poll them one by one. Search for the orders that changed since your last check with filter.basic.lastModifyFrom, or for the orders in the states you care about with filter.basic.customDocumentStateId, and read the detail only of those. The search rows carry the name of the state in customDocumentStateName.
Change the state
The state of an order is whatever the customer has defined: "new", "paid", "packed", "shipped", or something else entirely. WinShop does not prescribe the list. The states are rows of the item list /api/item-lists/documents/orders/states, and the order refers to one of them with customDocumentStateId.
curl https://<your-instance>/api/item-lists/documents/orders/states \
-H "Authorization: Bearer $ACCESS_TOKEN"[
{ "id": 1, "name": "New", "colorInteger": 255, "isInvisible": false },
{ "id": 2, "name": "Paid", "colorInteger": 65280, "isInvisible": false }
]To set a state, for example after the payment gateway confirms the payment, PUT the order with the new customDocumentStateId. Header fields you leave out keep their value.
curl -X PUT https://<your-instance>/api/documents/orders/internet/8841 \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "customDocumentStateId": 2 }'Two situations make the update fail with 400:
- Someone has the order open in WinShop. The message is
Document is locked by <user>. Document needs to be closed.Try again later. See Locks. - You tried to change a fixed field.
customerIdandwarehouseIdcannot be changed after the order is created.
Cancel an order
What "cancel" means is a business decision, so agree it with the customer. There are two ways:
- Set a cancelled state. If the customer has such a state, set it as in the previous step. The order stays in WinShop as a record.
- Delete the order. Only possible while nothing in WinShop refers to it yet. Always ask first with
check-delete, and fall back to the state when the answer is no.
async Task CancelAsync(int orderId, int cancelledStateId)
{
var route = $"/api/documents/orders/internet/{orderId}";
using var checkResponse = await SendAsync(HttpMethod.Get, $"{route}/check-delete");
var check = await checkResponse.Content.ReadFromJsonAsync<DeleteCheck>();
if (check!.CanBeDeleted)
{
using var deleted = await SendAsync(HttpMethod.Delete, route);
}
else
{
// HEADER_BLOCKED or ITEMS_BLOCKED: something already depends on the order
await SetStateAsync(orderId, cancelledStateId);
}
}
record DeleteCheck(bool CanBeDeleted, List<string> BlockingReasons);See Deleting documents for the blocking reasons and for deleting single items.
Checklist
- Every order carries the e-shop order number in
formNumber. - Before a create and before every retry, the order is looked up by
formNumberand compared exactly. - The WinShop
idis stored with the e-shop order. - Warehouse, payment, transport, source and state ids come from configuration, read from the item lists of that instance.
- A
400with a lock message is retried later; any other400is reported, with itsmessages. - Cancellation tries
check-deletefirst and falls back to a state.