# Genel Kurye POS API (v1)

This API lets a POS or order system send a restaurant's orders to the
**Genel Kurye** courier pool for delivery.

It is published by S.S. Genel Kurye Kooperatifi (the cooperative).

Terms used in this document:

- **Order**: the order in the POS. **Delivery**: the courier job Genel Kurye
  opens for an order. The Turkish messages say *sipariş* for order and
  *teslimat* for delivery.
- **Store**: one restaurant's connection to your POS, identified by its Store
  ID.
- **Keys**: a restaurant's Store ID, API Key and API Secret, together.
- **Pool**: where open deliveries wait for a courier.
- **Platform courier**: a courier of the marketplace the order came from,
  for example a Yemeksepeti courier, who delivers the order instead of a
  Genel Kurye courier.

Contact: iletisim@genelkuryerkooperatifi.org

Plain Markdown: https://www.genelkuryerkooperatifi.org/developers.md

## Basics

### How it works

A restaurant connects its POS with three values, and from then on the POS sends
its orders to Genel Kurye.

1. In the Genel Kurye panel, the restaurant opens the page for your POS under
   **Ayarlar → Entegrasyonlar** (Settings → Integrations) and creates its
   keys: a **Store ID**, an **API Key** and an **API Secret**.
2. The restaurant enters the keys in the Genel Kurye fields in the POS. When
   the POS saves them, it can check them with `GET /store`.
3. When the restaurant sends an order to Genel Kurye, the POS calls
   `POST /deliveries`. The delivery appears in the cooperative's courier pool.
4. As the delivery's status changes, Genel Kurye sends signed **webhook**
   events to the webhook URL registered for the POS. The POS can also read the
   status with `GET /deliveries/{id}`.
5. If the restaurant cancels the order, the POS calls
   `POST /deliveries/{id}/cancel`.

If the POS sends every order of the restaurant, including the ones a
platform courier delivers, it marks those with `"delivery_by": "platform"`.
Genel Kurye then records the order and sends no courier (see
[Platform deliveries](#platform-deliveries)).

The pickup address is always the address saved in the restaurant's Genel Kurye
profile. The POS sends only the drop-off address.

### Getting access

The cooperative sets up your access by hand.

Write to iletisim@genelkuryerkooperatifi.org. The cooperative sends you these
through a secure channel:

- a **test store**: a Store ID, an API Key and an API Secret,
- your **partner key**,
- your **webhook secret**.

Send the cooperative your webhook URL. It must start with `https://` and must
not contain a user name or password. One URL serves all your stores. Events are
not sent to any other kind of URL.

### Base URL and format

Every request goes to one base URL, over HTTPS, with JSON bodies.

| Setting | Value |
| --- | --- |
| Base URL | `https://api.genelkuryerkooperatifi.org/api/pos/v1` |
| Protocol | HTTPS only |
| Body | JSON, UTF-8, `Content-Type: application/json` |
| Time | ISO 8601. Times in responses are UTC (`2026-10-17T10:30:00.000Z`). Always include a time zone (`+03:00` or `Z`) in the times you send |

Paths in the endpoint reference are relative to the base URL. The examples show
the full request. A path or method that is not in the reference gets
`404 unknown_endpoint`. The one exception is a body that is not valid JSON
or is larger than 64 KB: it gets the `400` or `413` described at the end of
[Errors](#errors), whatever the path.

### Authentication

Every request carries four headers: one from the POS company and three from the
restaurant.

Every example in this reference shows all four.

| Header | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Partner-Key` | string | Yes | One key per POS company, the same for all its restaurants. The cooperative gives it to the POS company. Keep it on your server and never show it to the restaurant. Never put it in a desktop or mobile build. When the cooperative changes the key, the old key and the new key both work until the cooperative turns the old one off. Example: `5f0c…` |
| `X-Store-Id` | string | Yes | The restaurant's Store ID: 8 digits, not starting with 0. Example: `48213907` |
| `X-Api-Key` | string | Yes | The restaurant's API Key: `gk_` followed by 24 letters or digits. Example: `gk_XXXXXXXXXXXXXXXXXXXXXXXX` |
| `X-Api-Secret` | string | Yes | The restaurant's API Secret: `gks_` followed by 40 letters or digits |

- The partner key makes sure a restaurant's keys work only from this POS. If it
  is missing or wrong, the response is `401 unauthorized`.
- The restaurant's three values are checked together. If any one is wrong, the
  response is `401 unauthorized`, and it does not say which.
- The restaurant can see its API Secret again at any time in the Genel Kurye
  panel. Genel Kurye stores the secret encrypted and checks each request
  against its SHA-256 hash.
- If the restaurant creates new keys in the panel, the old API Key and API
  Secret stop working immediately. The Store ID does not change.
- If the restaurant removes the connection, its keys are deleted and every
  later request gets `401`. A restaurant that reconnects gets a new Store ID.
  Order IDs are unique within a store, so the same `external_order_id` opens a
  new delivery under the new Store ID.

### Testing

Test against the same base URL, with a test store.

There is no separate test server. The cooperative opens a test store for your
engineering team and sends you its keys.

Every order sent to the test store must include `"test": true`. An order
without it is rejected with `403 test_order_required` and nothing about its
customer is stored, so if the test keys are ever entered in a real
restaurant's POS by mistake, no real customer data is accepted. A real store rejects
`"test": true` with `400 invalid_request`.

Use drop-off addresses within 50 km of the test store's pickup address. An
order farther away is rejected with `422 address_too_far`.

Deliveries opened by the test store are visible only to test couriers, so no
real courier is sent. The cooperative's test couriers can accept these
deliveries and move them through every status, so you receive every status
event. Ask the cooperative to move a test delivery forward when you want to see
a status event.

### Errors

Errors share one JSON shape, with one exception described at the end of this
section.

```http
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{
  "error": {
    "code": "invalid_request",
    "message": "payment.method şunlardan biri olmalı: online, cash, card, meal_card.",
    "field": "payment.method"
  }
}
```

- `code` is stable. Branch on it in your code.
- `message` is Turkish text that you can show to the restaurant unchanged. Do
  not parse it.
- `field` is present only for errors about a field in the body or about the
  `external_order_id` query parameter.

#### Error codes

The Message column shows one sentence the server sends with each code. Some
codes also have other sentences.

| HTTP | Code | When | Message |
| --- | --- | --- | --- |
| 400 | `invalid_request` | The body or a field is invalid (`field` says which) | payment.method şunlardan biri olmalı: online, cash, card, meal_card. |
| 401 | `unauthorized` | Headers are missing, the partner key is wrong, or the Store ID, API Key and API Secret do not match | Store ID, API Key ya da API Secret yanlış. |
| 403 | `account_closed` | The restaurant's Genel Kurye account is closed. Normally the keys are removed with the account, and requests get `401` instead | İşletmenin Genel Kurye hesabı kapalı. |
| 403 | `paused` | The restaurant paused the connection in the panel. `POST /deliveries` is refused; the other endpoints still work | İşletme Genel Kurye bağlantısını duraklatmış; sipariş alınmadı. |
| 403 | `not_approved` | The cooperative has not approved this restaurant for order transfer yet | Kooperatif bu işletme için sipariş aktarımını henüz açmadı; sipariş alınmadı. |
| 403 | `profile_incomplete` | The restaurant's profile is missing a pickup address, province, location or business type | İşletmenin Genel Kurye profilinde alış adresi, ili, konumu ya da işletme türü eksik; sipariş alınmadı. |
| 403 | `connection_stopped` | The connection was stopped. The restaurant must create new keys | Bu bağlantı durdurulmuş. İşletme Genel Kurye panelinden yeni anahtar oluşturmalı. |
| 403 | `test_order_required` | An order was sent to a test store without `"test": true` | Test mağazası yalnızca "test": true işaretli siparişleri alır; sipariş kaydedilmedi. |
| 404 | `not_found` | There is no such delivery for this restaurant | Bu mağazada böyle bir teslimat yok. |
| 404 | `unknown_endpoint` | The path is not an endpoint of this API, or the endpoint does not take this method. The headers do not change this response, but a body that is not valid JSON gets the `400` described below first. Check the base URL, the path and the method | Geçersiz endpoint: istek gönderdiğiniz yol ya da HTTP yöntemi POS API'de yok. API dokümantasyonunu kontrol edin. |
| 409 | `not_cancellable` | The delivery is `delivered`, so it cannot be cancelled | Teslimat tamamlanmış; iptal edilemez. |
| 409 | `not_assigned` | The package cannot be handed over: no courier is assigned yet (`pending`), the delivery is `cancelled`, `returning` or `returned`, or a platform courier delivers it (`platform`) | Bu teslimata henüz kurye atanmadı. |
| 409 | `not_platform` | A platform status was sent for a delivery that is not `platform` | Bu teslimatı platform kuryesi götürmüyor; platform durumu yazılamaz. |
| 422 | `address_not_found` | `address.lat` and `address.lng` are missing or outside Turkey, and the address lookup found nothing or did not answer (see [Location](#location)) | Teslim adresi haritada bulunamadı; teslimat açılmadı. Teslimatı Genel Kurye panelindeki Yeni teslimat formundan elle açın. |
| 422 | `address_too_far` | The drop-off point is more than 50 km from the pickup address, and the address lookup found no point within 50 km (see [Location](#location)). `field` is `address`. The distance is in `message` | Teslim adresi alış adresine 50 km'den uzak (yaklaşık 530 km); teslimat açılmadı. Adresi ve işletmenin Genel Kurye profilindeki alış adresini kontrol edin. |
| 422 | `refused` | The delivery could not be opened. The reason is in `message` | İşletme türün artık kullanılmıyor. Profil'de güncelle. |
| 429 | `rate_limited` | The per-minute request limit was exceeded (see the `Retry-After` header) | Çok fazla istek gönderildi. Biraz sonra tekrar deneyin. |
| 429 | `hourly_limit` | The hourly delivery limit was exceeded (`Retry-After`) | Bu mağaza için son bir saatte açılabilecek teslimat sınırına ulaşıldı. Biraz sonra tekrar deneyin. |
| 429 | `geocode_limit` | The store used up its address lookups for orders that need one (`Retry-After`) | Bu mağaza için adres arama sınırı doldu; teslimat açılmadı. Biraz sonra tekrar deneyin ya da teslimatı Genel Kurye panelinden elle açın. |
| 500 | `internal` | Unexpected error. Send the same request again | Beklenmeyen bir hata oldu. Biraz sonra tekrar deneyin. |
| 503 | `busy` | The address lookup is busy (`Retry-After`) | Adres şu an konuma çevrilemiyor. Birkaç saniye sonra tekrar deneyin. |

> A `POST /deliveries` that fails can be sent again with the same
> `external_order_id`. The same order never opens two deliveries.

> If the body is not valid JSON, the response is `400`. If it is larger than
> 64 KB, the response is `413`. Both use the server's own format,
> `{ "data": null, "error": { "status", "name", "message" } }`, not the format
> above, and both come before the keys are checked. No delivery is created. The
> restaurant can open the delivery by hand in the Genel Kurye panel (the
> **Yeni teslimat** form).

### Limits

Limits apply to each store. The body size applies to every request.

| Limit | Value | When exceeded |
| --- | --- | --- |
| Request body | 64 KB | `413`, in the server's own format (see the end of [Errors](#errors)) |
| Requests | 120 per minute | `429 rate_limited` |
| New deliveries | 100 per hour | `429 hourly_limit` |
| Address lookups | For orders that need one ([Location](#location)): 20 per 10 minutes, retries included | `429 geocode_limit`. `503 busy` if the store already has a lookup running, or if the wait for a free lookup slot would pass 3 seconds |

## Endpoints

### Check the connection

`GET /store`

Confirms that the keys work and tells you whether the store is accepting orders
right now.

Call it when the restaurant saves the keys on your settings screen.

```http
GET /api/pos/v1/store HTTP/1.1
Host: api.genelkuryerkooperatifi.org
X-Partner-Key: <partner key>
X-Store-Id: 48213907
X-Api-Key: gk_XXXXXXXXXXXXXXXXXXXXXXXX
X-Api-Secret: gks_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{
  "store_id": "48213907",
  "name": "Örnek Restoran",
  "provider": "ornek-pos",
  "accepting_orders": true,
  "reason": null,
  "test": false
}
```

`name` is the restaurant's name in Genel Kurye, or `null`. `provider` is the ID
of your POS at Genel Kurye. `test` is `true` for a test store.

If `accepting_orders` is `false`, `reason` is one of `paused`, `not_approved` or
`profile_incomplete`. These are the codes `POST /deliveries` returns in the
same situations.

### Create a delivery

`POST /deliveries`

Opens a delivery for an order.

```http
POST /api/pos/v1/deliveries HTTP/1.1
Host: api.genelkuryerkooperatifi.org
X-Partner-Key: <partner key>
X-Store-Id: 48213907
X-Api-Key: gk_XXXXXXXXXXXXXXXXXXXXXXXX
X-Api-Secret: gks_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json

{
  "external_order_id": "SP-100234",
  "platform": "yemeksepeti",
  "platform_order_code": "Y7K2",
  "ordered_at": "2026-10-17T10:30:00+03:00",
  "prep_minutes": 15,
  "customer": {
    "name": "Ayşe Yılmaz",
    "phone": "0532 111 22 33"
  },
  "address": {
    "line": "Caferağa Mah. Moda Cad. No:12",
    "neighborhood": "Caferağa",
    "district": "Kadıköy",
    "city": "İstanbul",
    "building": "12",
    "floor": "2",
    "apartment": "5",
    "directions": "Eczanenin üstü",
    "lat": 40.98712,
    "lng": 29.02631
  },
  "payment": {
    "method": "cash",
    "amount_to_collect": 245.5
  },
  "items": [
    { "name": "Lahmacun", "quantity": 2 },
    { "name": "Ayran", "quantity": 2 }
  ],
  "note": "Zile basmayın"
}
```

```http
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{
  "id": "b1tqwff2g8n4kpg36kbhhmcf",
  "code": "KRY-6BEJZ3",
  "store_id": "48213907",
  "external_order_id": "SP-100234",
  "status": "pending",
  "test": false,
  "courier": null,
  "pickup_code": null,
  "cancel_reason": null,
  "fail_reason": null,
  "created_at": "2026-10-17T07:56:33.953Z",
  "assigned_at": null,
  "picked_up_at": null,
  "delivered_at": null,
  "cancelled_at": null,
  "returned_at": null,
  "delivery_by": "restaurant",
  "platform_status": null,
  "platform_status_at": null
}
```

#### Body parameters

Free text longer than its limit is cut. An identifier or a code that is too
long is rejected with `400 invalid_request`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `external_order_id` | string | Yes | The order's ID in the POS: 1–64 printable ASCII characters, no spaces. Turkish letters and other non-ASCII characters are rejected. Unique within a store |
| `test` | boolean | Yes, in a test store | `true` marks a test order. Send it only to a test store: a real store returns `400 invalid_request` |
| `platform` | string | No | Where the order came from: `yemeksepeti`, `trendyol`, `getir`, `migros`, `tiklagelsin`, `phone`, `web`, or another short name of up to 40 characters (a–z, digits, `-`, `_`). Turkish letters are converted to a–z |
| `platform_order_code` | string | No | A short code the courier uses to match the package: the platform's order code or the receipt number. Up to 20 printable ASCII characters. Turkish letters are rejected |
| `brand` | string | No | The brand the order was sold under, when one restaurant sells under several brands from one kitchen, for example `Beast Burger`. Couriers see it next to the order code and use it to find the right package at the counter. Up to 60 characters |
| `ordered_at` | string | No | When the order was placed, with a time zone: `2026-10-17T10:30:00+03:00` |
| `prep_minutes` | integer | No | The kitchen's estimated preparation time, 0–240. The courier sees it as "about 15 minutes" |
| `package_count` | integer | No | Number of packages, 1–50. Default 1 |
| `delivery_by` | string | No | Who delivers the order: `restaurant` (a Genel Kurye courier) or `platform` (a platform courier, see [Platform deliveries](#platform-deliveries)). Default `restaurant`; an empty string counts as not sent. Upper and lower case are the same. Any other value returns `400 invalid_request` with `field` set to `delivery_by` |
| `customer.name` | string | No | Shown to the courier as "Ayşe Y." once the courier accepts the delivery. Up to 120 characters |
| `customer.phone` | string | No | 7–15 digits, up to 30 characters in all. `+` is allowed only at the start; spaces, `-` and parentheses are allowed. A masked number from a platform is accepted, also as one line with the code to dial after a slash: `0212 365 34 03 / 117 678 795 07`. A phone that is not a number does not refuse the order: the courier sees it as sent, once the package is picked up |
| `customer.phone_code` | string | No | A code to dial after a masked number connects: up to 20 digits, `*` and `#`. Ignored without `customer.phone` |
| `address.line` | string | Yes, unless `delivery_by` is `platform` | Street and door number, for example `Moda Cad. No:12`. Up to 300 characters |
| `address.neighborhood` | string | No | Neighborhood. Up to 80 characters |
| `address.district` | string | No | District. Up to 60 characters |
| `address.city` | string | No | City or province. Up to 60 characters |
| `address.building` | string | No | Building. Up to 30 characters |
| `address.floor` | string | No | Floor. Up to 30 characters |
| `address.apartment` | string | No | Apartment. Up to 30 characters |
| `address.directions` | string | No | Directions to the address. Up to 300 characters |
| `address.lat` | number | Recommended | Latitude of the drop-off point. Send it together with `address.lng`: sending only one returns `400 invalid_request` |
| `address.lng` | number | Recommended | Longitude of the drop-off point. A latitude and longitude of `0` and `0` count as no location |
| `payment.method` | string | No | `online` (already paid), `cash` (cash at the door), `card` (card at the door) or `meal_card` (meal card at the door) |
| `payment.amount_to_collect` | number | No | Amount to collect at the door, in ₺: from 0 to less than 1,000,000. Ignored for `online`. An amount above 0 requires `payment.method` |
| `items` | array | No | The order's lines. Only the first 30 are stored. Item names are visible to couriers while the delivery is in the pool. A `note` inside an item is ignored |
| `items[].name` | string | Yes | The item's name. Up to 120 characters |
| `items[].quantity` | integer | No | 1–99. Default 1 |
| `note` | string | No | The customer's note. Up to 300 characters |

#### Location

Always send the location that the platform gives you. A location outside
Turkey (latitude 35–43, longitude 25–45) counts as no location.

Genel Kurye looks the address up on a map when there is no location, or when
the location is more than 50 km in a straight line from the pickup address.
The lookup is slow and does not find every address. It is limited to 20
orders per store in 10 minutes. Every order that needs a lookup counts,
retries included.

- If there is no location, and the lookup finds nothing or does not answer,
  the response is `422 address_not_found`.
- If the location you sent, or the point the lookup found, is more than 50 km
  from the pickup address, and the lookup found no point within 50 km, the
  response is `422 address_too_far`. `message` gives the distance in whole
  kilometres, rounded up: the distance of the location you sent if it was too
  far, otherwise that of the first point the lookup found. Check the drop-off
  address. Also check the pickup address in the restaurant's Genel Kurye
  profile.

Genel Kurye remembers what a lookup found for 10 minutes and does not look the
same address up again during that time. The remembered point is compared with
the restaurant's current pickup address on every request, so a corrected
pickup address takes effect at once.

#### Response

A new delivery returns `201 Created`. The body is a
[delivery object](#delivery-object).

#### Sending the same order again

A second request with the same `external_order_id` does not open a new
delivery. It returns the delivery the first request opened, with `200 OK`, and
ignores the other fields in the body. If a request times out or fails on the
network, send the same request again. If the order changes, cancel the delivery
and send it again with a new `external_order_id`.

#### Platform deliveries

Some orders are delivered by a platform courier, not by Genel Kurye. If your
POS sends all of a restaurant's orders, send these with
`"delivery_by": "platform"`.

```json
{
  "external_order_id": "SP-100235",
  "platform": "yemeksepeti",
  "platform_order_code": "Q4M8",
  "delivery_by": "platform",
  "payment": { "method": "online" },
  "items": [{ "name": "Lahmacun", "quantity": 2 }]
}
```

- The response is `201 Created` with `status` `platform` and `delivery_by`
  `platform`. No courier is sent, the address is not looked up, and the
  delivery never appears in the pool.
- Only the order's own fields are read: `external_order_id`, `test`,
  `platform`, `platform_order_code`, `brand`, `ordered_at`, `prep_minutes`,
  `package_count`, `payment`, `items`, `address.district` and
  `address.city`. `customer`, the rest of `address` and `note` are not
  needed. If you send them, they are ignored and not stored.
- The restaurant sees the order in its Genel Kurye panel, marked as a
  platform delivery.
- `platform` is final. No webhook is sent for it.
- To pass on what the platform courier does, call
  [`POST /deliveries/{id}/platform-status`](#report-platform-progress).
- An order sent without `delivery_by` gets a Genel Kurye courier. If the
  restaurant tells the cooperative that a platform courier took it, the
  cooperative can move the delivery to `platform` while it is still
  `pending`. Your POS sees the change on its next `GET /deliveries/{id}`.
  No webhook is sent.
- The new-delivery limit (100 per hour) counts platform deliveries too.

### Get a delivery

`GET /deliveries/{id}`

Returns one delivery by its ID.

You can only read deliveries that this restaurant opened through your POS. The
ID of any other delivery returns `404 not_found`.

```http
GET /api/pos/v1/deliveries/b1tqwff2g8n4kpg36kbhhmcf HTTP/1.1
Host: api.genelkuryerkooperatifi.org
X-Partner-Key: <partner key>
X-Store-Id: 48213907
X-Api-Key: gk_XXXXXXXXXXXXXXXXXXXXXXXX
X-Api-Secret: gks_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{
  "id": "b1tqwff2g8n4kpg36kbhhmcf",
  "code": "KRY-6BEJZ3",
  "store_id": "48213907",
  "external_order_id": "SP-100234",
  "status": "assigned",
  "test": false,
  "courier": { "name": "Ahmet Kaya", "phone": "0532 123 45 67" },
  "pickup_code": "482913",
  "cancel_reason": null,
  "fail_reason": null,
  "created_at": "2026-10-17T07:56:33.953Z",
  "assigned_at": "2026-10-17T08:03:12.418Z",
  "picked_up_at": null,
  "delivered_at": null,
  "cancelled_at": null,
  "returned_at": null,
  "delivery_by": "restaurant",
  "platform_status": null,
  "platform_status_at": null
}
```

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The `id` that `POST /deliveries` returned |

### Find a delivery by order ID

`GET /deliveries`

Looks up a delivery by the POS's own order ID.

The response is a list with at most one item. The example is shortened: each
item in the list is a full delivery object. If there is no such order, the
response is `{ "data": [] }`.

```http
GET /api/pos/v1/deliveries?external_order_id=SP-100234 HTTP/1.1
Host: api.genelkuryerkooperatifi.org
X-Partner-Key: <partner key>
X-Store-Id: 48213907
X-Api-Key: gk_XXXXXXXXXXXXXXXXXXXXXXXX
X-Api-Secret: gks_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{ "data": [ { "id": "b1tqwff2g8n4kpg36kbhhmcf", "status": "assigned" } ] }
```

#### Query parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `external_order_id` | string | Yes | The order's ID in the POS, as sent to `POST /deliveries`. Without a valid ID the response is `400 invalid_request` with `field` set to `external_order_id` |

### Cancel a delivery

`POST /deliveries/{id}/cancel`

Cancels a delivery. If the courier already has the package, they bring it back
to the restaurant. On a `platform` delivery, it records that the order was
cancelled on the platform.

```http
POST /api/pos/v1/deliveries/b1tqwff2g8n4kpg36kbhhmcf/cancel HTTP/1.1
Host: api.genelkuryerkooperatifi.org
X-Partner-Key: <partner key>
X-Store-Id: 48213907
X-Api-Key: gk_XXXXXXXXXXXXXXXXXXXXXXXX
X-Api-Secret: gks_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json

{ "reason": "Müşteri vazgeçti" }
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{
  "id": "b1tqwff2g8n4kpg36kbhhmcf",
  "code": "KRY-6BEJZ3",
  "store_id": "48213907",
  "external_order_id": "SP-100234",
  "status": "cancelled",
  "test": false,
  "courier": null,
  "pickup_code": null,
  "cancel_reason": "Sipariş Örnek POS'ta iptal edildi (Müşteri vazgeçti).",
  "fail_reason": null,
  "created_at": "2026-10-17T07:56:33.953Z",
  "assigned_at": null,
  "picked_up_at": null,
  "delivered_at": null,
  "cancelled_at": "2026-10-17T07:58:02.771Z",
  "returned_at": null,
  "delivery_by": "restaurant",
  "platform_status": null,
  "platform_status_at": null
}
```

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The `id` that `POST /deliveries` returned |

#### Body parameters

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | string | No | Why the order was cancelled. Genel Kurye puts it into a fixed sentence, which the courier and the restaurant see: `Sipariş Örnek POS'ta iptal edildi (Müşteri vazgeçti).` (in English: "Order cancelled in Örnek POS (customer changed their mind)."). The sentence names your POS. Your `reason` goes between the brackets unchanged, so send a short Turkish phrase, not a code such as `customer_cancelled`. Without a `reason` the brackets are left out. Text longer than 160 characters is cut |

#### Result by status

| Delivery status | Result |
| --- | --- |
| `pending`, `assigned` | `cancelled`. If a courier is assigned, they are notified |
| `picked_up`, `in_transit` | `returning`. The courier already has the package, so they bring it back to the restaurant, and then the delivery becomes `returned` |
| `cancelled`, `returning`, `returned` | No change. The response is `200` with the unchanged delivery |
| `delivered` | `409 not_cancellable` |
| `platform` | `status` stays `platform` and `platform_status` becomes `cancelled`, as with [Report platform progress](#report-platform-progress). `reason` is not stored. If `platform_status` is already `delivered` or `cancelled`, nothing changes. The response is `200` either way |

### Hand over a package

`POST /deliveries/{id}/handover`

Tells Genel Kurye that the restaurant gave the package to the courier.

This is the same as the "I gave the package to the courier" button in the
restaurant's panel. Use it when the courier cannot enter the pickup code, for
example because their phone is off.

```http
POST /api/pos/v1/deliveries/b1tqwff2g8n4kpg36kbhhmcf/handover HTTP/1.1
Host: api.genelkuryerkooperatifi.org
X-Partner-Key: <partner key>
X-Store-Id: 48213907
X-Api-Key: gk_XXXXXXXXXXXXXXXXXXXXXXXX
X-Api-Secret: gks_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{
  "id": "b1tqwff2g8n4kpg36kbhhmcf",
  "code": "KRY-6BEJZ3",
  "store_id": "48213907",
  "external_order_id": "SP-100234",
  "status": "picked_up",
  "test": false,
  "courier": { "name": "Ahmet Kaya", "phone": "0532 123 45 67" },
  "pickup_code": null,
  "cancel_reason": null,
  "fail_reason": null,
  "created_at": "2026-10-17T07:56:33.953Z",
  "assigned_at": "2026-10-17T08:03:12.418Z",
  "picked_up_at": "2026-10-17T08:12:40.120Z",
  "delivered_at": null,
  "cancelled_at": null,
  "returned_at": null,
  "delivery_by": "restaurant",
  "platform_status": null,
  "platform_status_at": null
}
```

```http
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{
  "error": {
    "code": "not_assigned",
    "message": "Bu teslimata henüz kurye atanmadı."
  }
}
```

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The `id` that `POST /deliveries` returned |

#### Result by status

| Delivery status | Result |
| --- | --- |
| `assigned` | `picked_up` |
| `picked_up`, `in_transit`, `delivered` | No change. The response is `200` with the unchanged delivery |
| `pending` | `409 not_assigned`: no courier is assigned yet |
| `cancelled`, `returning`, `returned` | `409 not_assigned` |
| `platform` | `409 not_assigned`: a platform courier delivers it, so no Genel Kurye courier is assigned. The message is `Bu teslimatı platform kuryesi götürüyor; paketin alındığını POST /deliveries/:id/platform-status ile bildirin.` |

### Report platform progress

`POST /deliveries/{id}/platform-status`

Tells Genel Kurye what the platform courier did with a `platform` delivery.

The restaurant sees it in its panel. Nothing else changes: the delivery
stays `platform`, and no webhook is sent. Calling it is optional.

```http
POST /api/pos/v1/deliveries/c7rmx2kq9w4hd8tjz3pn5vfa/platform-status HTTP/1.1
Host: api.genelkuryerkooperatifi.org
X-Partner-Key: <partner key>
X-Store-Id: 48213907
X-Api-Key: gk_XXXXXXXXXXXXXXXXXXXXXXXX
X-Api-Secret: gks_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json

{ "status": "picked_up", "occurred_at": "2026-10-17T10:52:00+03:00" }
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{
  "id": "c7rmx2kq9w4hd8tjz3pn5vfa",
  "code": "KRY-Q2W7ZA",
  "store_id": "48213907",
  "external_order_id": "SP-100235",
  "status": "platform",
  "test": false,
  "courier": null,
  "pickup_code": null,
  "cancel_reason": null,
  "fail_reason": null,
  "created_at": "2026-10-17T07:41:09.512Z",
  "assigned_at": null,
  "picked_up_at": null,
  "delivered_at": null,
  "cancelled_at": null,
  "returned_at": null,
  "delivery_by": "platform",
  "platform_status": "picked_up",
  "platform_status_at": "2026-10-17T07:52:00.000Z"
}
```

```http
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{
  "error": {
    "code": "not_platform",
    "message": "Bu teslimatı platform kuryesi götürmüyor; platform durumu yazılamaz."
  }
}
```

#### Path parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The `id` that `POST /deliveries` returned |

#### Body parameters

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | Yes | `picked_up` (the platform courier took the package), `delivered` (the platform courier delivered it) or `cancelled` (the order was cancelled on the platform). Upper and lower case are the same. A missing or unknown value returns `400 invalid_request` with `field` set to `status` |
| `occurred_at` | string | No | When it happened, with a time zone: `2026-10-17T10:52:00+03:00`. Without it, the time Genel Kurye received the request. A time in the future counts as now. A value that is not a time returns `400 invalid_request` with `field` set to `occurred_at` |

#### Result by status

The platform status only moves forward: `picked_up`, then `delivered` or
`cancelled`. You can skip `picked_up`. `delivered` and `cancelled` are final.

| Delivery | Result |
| --- | --- |
| `platform`, and the new status moves forward | `platform_status` and `platform_status_at` are set. The response is `200` |
| `platform`, and the new status does not move forward (the same status again, or `picked_up` after `delivered`) | No change. The response is `200` with the unchanged delivery |
| Any other status | `409 not_platform` |

## Reference

### Delivery object

The delivery as every endpoint and every webhook event returns it.

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | The delivery's ID in Genel Kurye |
| `code` | string | The delivery's code (`KRY-…`). The courier and the restaurant see this code |
| `store_id` | string | The Store ID, the same value as `X-Store-Id` |
| `external_order_id` | string | The ID the POS sent |
| `status` | string | One of the [statuses](#statuses) |
| `test` | boolean | Whether the delivery belongs to a test store |
| `courier` | object \| null | `{ "name", "phone" }`. `phone` is the courier's own number. See [Personal data](#personal-data). `null` until a courier is assigned |
| `pickup_code` | string \| null | The 6-digit pickup code. `null` unless the status is `assigned` |
| `cancel_reason` | string \| null | Why the delivery was cancelled, including a cancel after pickup |
| `fail_reason` | string \| null | Why a package came back undelivered: `not_home`, `wrong_address`, `refused`, `phone_unreachable` or `other`. Today it is always `null` for deliveries opened through this API |
| `created_at` | string | When the delivery was opened |
| `assigned_at` | string \| null | When a courier accepted the delivery |
| `picked_up_at` | string \| null | When the courier took the package |
| `delivered_at` | string \| null | When the package was delivered |
| `cancelled_at` | string \| null | When the delivery was cancelled |
| `returned_at` | string \| null | When the package was brought back to the restaurant |
| `delivery_by` | string | `platform` when a platform courier delivers the order (status `platform`), otherwise `restaurant` |
| `platform_status` | string \| null | The last platform status you reported: `picked_up`, `delivered` or `cancelled`. `null` until you report one, and on every delivery that is not `platform` |
| `platform_status_at` | string \| null | When that platform status happened |

A time is `null` until its step happens. There is no time field for
`in_transit` or `returning`.

#### Pickup code

When the courier arrives at the restaurant, the restaurant tells them this
6-digit code, and they enter it in their app to take the package. The Genel
Kurye panel shows the code to the restaurant. The POS receives it in the
`delivery.assigned` event and should show it to the restaurant staff.

### Statuses

A delivery moves through these statuses.

| `status` | Meaning | Suggested status in your POS |
| --- | --- | --- |
| `pending` | In the pool, waiting for a courier | Hazırlanıyor (preparing) |
| `assigned` | A courier accepted the delivery and is coming to the restaurant | Hazırlanıyor (preparing) |
| `picked_up` | The courier has the package | Yolda (on the way) |
| `in_transit` | The courier is heading to the customer | Yolda (on the way) |
| `delivered` | The package was handed to the customer | Teslim Edildi (delivered) |
| `cancelled` | Cancelled before pickup. The package is still at the restaurant | İptal (cancelled) |
| `returning` | Cancelled after pickup, by the restaurant or by the cooperative. The package is on its way back to the restaurant | İade (returned), or İptal if there is no equivalent |
| `returned` | The package was brought back to the restaurant | İade (returned), or İptal if there is no equivalent |
| `platform` | A platform courier delivers the order. Genel Kurye sends no courier. Never sent as a webhook; read it with [Get a delivery](#get-a-delivery) | The platform's own status, or `platform_status` if you report it |

A delivery moves through `pending → assigned → picked_up → in_transit →
delivered`. It can become `cancelled` only before the package is picked up, and
`returning` or `returned` only after.

A delivery is `platform` from the start when you send
`"delivery_by": "platform"`. A `pending` delivery can also become `platform`
when the cooperative learns that a platform courier took the order. A
`platform` delivery never changes status again.

### Webhooks

Genel Kurye sends a signed `POST` to your webhook URL when the status of one of
your deliveries changes.

Not every change is sent: see [Sending and retries](#sending-and-retries). There
is one URL for all stores, and the `delivery.store_id` field in the body says
which store a delivery belongs to. The cooperative creates the webhook secret,
the key that signs each request, and sends it to the POS through a secure
channel.

#### Event format

| Header | Description |
| --- | --- |
| `Content-Type` | Always `application/json` |
| `User-Agent` | `GenelKurye-Webhook/1` |
| `X-GK-Event` | The event, for example `delivery.picked_up` |
| `X-GK-Event-Id` | The event ID, for example `evt_b1tqwff2g8n4kpg36kbhhmcf_picked_up_17` |
| `X-GK-Timestamp` | When the request was signed, in Unix seconds, for example `1792224770` |
| `X-GK-Signature` | The signature, for example `sha256=5d41402abc4b2a76…` |

`X-GK-Event` and `X-GK-Event-Id` are not signed. Use `type` and `event_id`
from the body. The two headers repeat them for routing and logs.

The body:

```json
{
  "event_id": "evt_b1tqwff2g8n4kpg36kbhhmcf_picked_up_17",
  "type": "delivery.picked_up",
  "occurred_at": "2026-10-17T08:12:40.120Z",
  "delivery": {
    "id": "b1tqwff2g8n4kpg36kbhhmcf",
    "code": "KRY-6BEJZ3",
    "store_id": "48213907",
    "external_order_id": "SP-100234",
    "status": "picked_up",
    "courier": { "name": "Ahmet Kaya", "phone": "0532 123 45 67" },
    "pickup_code": null,
    "picked_up_at": "2026-10-17T08:12:40.120Z"
  }
}
```

The example is shortened: `delivery` is always a full delivery object, as it
was when the event was sent.

**Events:** `delivery.assigned`, `delivery.picked_up`, `delivery.in_transit`,
`delivery.delivered`, `delivery.cancelled`, `delivery.returning`,
`delivery.returned`. There is no event for `platform`.

**Event ID.** `evt_<delivery ID>_<status>`, with `_<courier ID>` added at the
end when the delivery has a courier. The courier ID is an internal number that
is not in the delivery object, so treat the whole event ID as one opaque
string. Every attempt to send the same event uses the same event ID. If the
cooperative moves a delivery to another courier, the status does not change,
but the same status is sent once more with the new courier's details and a new
event ID.

**Event time.** `occurred_at` is when the delivery reached that status. For
`in_transit` and `returning` it is the time of the delivery's last update,
because the delivery object has no time field for them. When a status is sent
again after a courier change, `occurred_at` is the same as in the first event,
so tell such events apart by `event_id`.

#### Verifying the signature

Genel Kurye signs every request with the webhook secret. To check a request,
compute the value below and compare it with `X-GK-Signature`:

```
signature = "sha256=" + hex(HMAC_SHA256(webhook_secret, X-GK-Timestamp + "." + raw_body))
```

- Compute it over the bytes you received, before you parse the body.
- Compare in constant time.
- Reject the request if `X-GK-Timestamp` is more than 5 minutes away from your
  clock.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, timestamp, signature, secret) {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;
  const expected = "sha256=" + createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(String(signature));
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```python
import hashlib
import hmac
import time


def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    try:
        sent_at = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - sent_at) > 300:
        return False
    message = timestamp.encode() + b"." + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), (signature or "").encode())
```

```php
<?php

function verify(
    string $rawBody,
    string $timestamp,
    string $signature,
    string $secret,
): bool {
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}
```

```java
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class WebhookSignature {
    public static boolean verify(byte[] rawBody, String timestamp,
            String signature, String secret) throws GeneralSecurityException {
        long sentAt;
        try {
            sentAt = Long.parseLong(timestamp);
        } catch (NumberFormatException e) {
            return false;
        }
        long now = Instant.now().getEpochSecond();
        if (sentAt < now - 300 || sentAt > now + 300) return false;
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
        String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(rawBody));
        return signature != null && MessageDigest.isEqual(
                expected.getBytes(StandardCharsets.UTF_8),
                signature.getBytes(StandardCharsets.UTF_8));
    }
}
```

```csharp
using System;
using System.Security.Cryptography;
using System.Text;

static bool Verify(byte[] rawBody, string timestamp, string signature, string secret)
{
    var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    if (!long.TryParse(timestamp, out var sentAt) ||
        sentAt < now - 300 || sentAt > now + 300) return false;
    using var hmac = IncrementalHash.CreateHMAC(
        HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret));
    hmac.AppendData(Encoding.UTF8.GetBytes(timestamp + "."));
    hmac.AppendData(rawBody);
    var expected = "sha256=" +
        Convert.ToHexString(hmac.GetHashAndReset()).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature ?? ""));
}
```

```go
package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"time"
)

func Verify(rawBody []byte, timestamp, signature, secret string) bool {
	sentAt, err := strconv.ParseInt(timestamp, 10, 64)
	now := time.Now().Unix()
	if err != nil || sentAt < now-300 || sentAt > now+300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}
```

```cpp
#include <openssl/crypto.h>
#include <openssl/evp.h>
#include <openssl/hmac.h>

#include <charconv>
#include <chrono>
#include <string>

bool verify(const std::string& raw_body, const std::string& timestamp,
            const std::string& signature, const std::string& secret) {
    long long sent_at = 0;
    const char* end = timestamp.data() + timestamp.size();
    const auto [stop, error] = std::from_chars(timestamp.data(), end, sent_at);
    const long long now = std::chrono::duration_cast<std::chrono::seconds>(
        std::chrono::system_clock::now().time_since_epoch()).count();
    if (error != std::errc() || stop != end ||
        sent_at < now - 300 || sent_at > now + 300) return false;

    const std::string message = timestamp + "." + raw_body;
    unsigned char digest[EVP_MAX_MD_SIZE];
    unsigned int length = 0;
    if (!HMAC(EVP_sha256(), secret.data(), static_cast<int>(secret.size()),
              reinterpret_cast<const unsigned char*>(message.data()),
              message.size(), digest, &length)) return false;

    static const char hex[] = "0123456789abcdef";
    std::string expected = "sha256=";
    for (unsigned int i = 0; i < length; ++i) {
        expected += hex[digest[i] >> 4];
        expected += hex[digest[i] & 0x0f];
    }
    return expected.size() == signature.size() &&
           CRYPTO_memcmp(expected.data(), signature.data(), expected.size()) == 0;
}
```

#### Sending and retries

- Respond with a `2xx` within 20 seconds. Only the status code counts. If
  you pass the event on before answering, answer first: a late answer is a
  failure and the event comes again 30 seconds later.
  Redirects (3xx) are not followed and count as a failure.
- Events are sent in rounds, about every 20 seconds. If a delivery changes
  status more than once between two rounds, only the last status is sent. For
  example, if a delivery becomes `picked_up` and then `in_transit` right away,
  only `delivery.in_transit` arrives. The times of the skipped steps are in the
  delivery object.
- A response other than `2xx`, a timeout and a connection error are all
  failures. A failed event is sent again after 30 seconds, then after a wait
  that doubles each time, up to 30 minutes. There are 14 attempts in total
  (about 4 hours). After the last one the event is dropped.
- After a `5xx`, a `408`, a `429`, a timeout, a redirect or a connection error,
  no other event is sent to your URL in that round. The rest are sent in the
  next round.
- Every attempt is signed again with a new `X-GK-Timestamp`. The `event_id`
  does not change.
- The same event can arrive more than once. Ignore an `event_id` you have
  already processed.
- Events are sent only for deliveries opened in the last 24 hours, and not
  after the restaurant removes the connection.
- You can miss an event: when all 14 attempts fail, when a newer status
  replaces it in the same round, and when the delivery is older than 24 hours.
  To get the current status, read `GET /deliveries/{id}` while the connection
  is active.

### Personal data

How a customer's and a courier's data is stored, and who can see it.

- Until the cooperative has signed the customer data agreement with a
  restaurant (KVKK is Turkey's personal data protection law) and approved it
  for order transfer, the restaurant's orders are rejected with
  `403 not_approved`. Nothing about a rejected order's customer is stored.
- A platform order needs no customer data: do not send `customer`, the
  address line or `note`. If you send them, they are ignored and not stored.
  Genel Kurye keeps only the district and city of the address.
- While a delivery is in the pool, couriers see only the customer's drop-off
  address. The pool card also shows where the order came from, its code, the
  payment method, the preparation time, the package count and the item names.
- After a courier accepts the delivery, that courier also sees the customer's
  name as "Ayşe Y.".
- After that courier picks up the package, they also see the phone number, the
  customer's note, the address directions and the amount to collect at the
  door. No other courier sees them.
- Put anything personal about the customer, such as an allergy or a door code,
  in `note`, not in an item name. Item names are visible in the pool.
- Every delivery object and every webhook event carries the courier's name and
  phone number. The number is not masked. It is there so the restaurant can
  reach the courier about that delivery.
- Show the courier's name and number only to the restaurant staff who handle
  that delivery. Do not use them for anything else. Do not keep them after the
  delivery is over.
- For a test store, Genel Kurye keeps a copy of up to 10 requests a minute,
  and of every webhook event for the store's deliveries, for 7 days to help
  with debugging. A request's copy has the path and query, the first 16 KB of
  the request body, the first 8 KB of the response body, the IP address and
  the `User-Agent` header. An event's copy has the event body and the first
  1 KB of your response body. In these copies, the courier's phone number, the
  pickup code and any key are masked. The cooperative can look these copies
  up for you.
- For a request to a real store, an order sent to a test store without
  `"test": true`, and an order rejected before its content is checked (`401`,
  any `403`, `429 rate_limited`, or `400 invalid_request` on the `test`
  field), Genel Kurye keeps the names and types of the fields you sent. Of
  their values it keeps only those that say nothing about a person, and only
  when the store's keys are valid: `external_order_id`, `platform`,
  `platform_order_code`, `ordered_at`, `prep_minutes`, `package_count`,
  `delivery_by`, `test`, `payment.method`, `payment.amount_to_collect`, the
  items' names and quantities, and the `status` and `occurred_at` of a
  platform status report. A value that is not valid for its field is kept
  as a marker, not as sent. A top-level field that is not in this reference
  is kept only when its value is `true` or `false`, a small whole number or
  a short lowercase code. Nothing under `customer` or `address`, and never
  `note`, `reason` or `brand`. At most 2 KB per request, for 7 days. A test order
  rejected for its content, for example with `400 invalid_request` or
  `422 address_too_far`, is kept in full.

### Versioning

This document describes v1.

New fields can be added to responses and webhook events without notice, so
ignore fields you do not recognize. A change that removes a field or changes
its meaning will come with a new path, `/api/pos/v2`.
