Delivery services: integration API
This article is for developers at courier services. After reading it you will be able to receive orders from venues that run on Shuqr and tell them where the courier is.
What it is#
Shuqr is a system for cafes and restaurants: point of sale, kitchen, online orders. A venue can connect your service in its settings. Shuqr then sends you paid orders for delivery, and you send back the stages: courier assigned, order picked up, delivered.
You don't need a contract with Shuqr. You agree on terms, prices and payments directly with the venue.
How it works#
- The venue connects your service in its settings and clicks "Check connection" – Shuqr sends
ping. - When it is time to deliver an order, Shuqr sends
delivery.create. Within 10 seconds you reply with your job ID inid. - As the courier moves on, you send stages to the order's
callbackUrl: assigned, on the way to the venue, picked up. - The
deliveredstage closes the order for the venue: it becomes “Completed”. Along the way the cashier and the guest see the courier and the current stage. - Until the courier picks up the order, the venue may cancel it. Then you get
delivery.cancel.
How connecting works#
- You give the venue your server address (
https://only) and a signing key – a long random string you make up yourself. Each venue gets its own key. - The venue opens “Settings → Delivery” in Shuqr and, in the “Who delivers” block, adds “Other service via API” and enters your address, the key and the delivery price the guest pays. Delivery time for the guest is optional.
- When an order is paid and ready to go, Shuqr sends you a
delivery.createrequest. From then on you report the delivery stages.
A venue can connect several services and pick the main one. If you refuse an order or don't answer, the order goes to the next service or to the venue's cashier.
Several restaurants
Each venue has its own signing key. The easiest way is to give each venue its own address, for example https://your-service.example.com/shuqr/cafe-pelmen, where cafe-pelmen is a venue code you make up yourself. When a request comes in, find that venue's key by the code in the address and check the signature with it.
delivery.create also has a restaurantId field – the venue ID in Shuqr. It also tells you whose order it is. But trust it only after the signature check.
ping has no restaurantId. One more reason to give each venue its own address.
What you need to build#
- An HTTPS address that accepts
POSTwith JSON and understands three events:ping,delivery.create,delivery.cancel. - A separate address and key for each venue (see “Several restaurants”).
- A check of the
X-Shuqr-Signatureheader on every request. - Protection from repeats: a second
delivery.createwith the samedeliveryIdmust not create a second job. - Your job ID in
idin the reply todelivery.create. - Sending stages to the order's
callbackUrlevery time something changes.
Requests from Shuqr#
Shuqr sends a POST request to the address the venue entered in its settings. The body is UTF-8 JSON.
| Header | Value |
|---|---|
Content-Type | Always application/json. |
X-Shuqr-Event | Event type: ping, delivery.create or delivery.cancel. The same value is in the body's event field. |
X-Shuqr-Signature | sha256= followed by the hex signature of the body. See “Checking the signature”. If the venue didn't enter a key, the header is missing – better reject such a request. |
ping – connection check
When the venue clicks "Check connection" in its settings, Shuqr sends a signed ping. Reply with any 2xx code and the venue sees "The connection works". If the signature is wrong, reply 401 and the venue sees that the key needs checking.
{ "event": "ping" }delivery.create – new order
Sent when it is time to deliver a paid order. The venue chooses the moment: right after the order is accepted, when the kitchen starts cooking, when the order is ready, or manually – the cashier presses a button. So the courier may have to wait at the venue. orderNumber helps to pick up the right order.
{
"event": "delivery.create",
"restaurantId": "cmh8x2p1q0000ab3c",
"deliveryId": "cmhf2k9x80001qa7z",
"orderNumber": "42",
"callbackUrl": "https://shuqr.ru/api/delivery/webhook/webhook?delivery=cmhf2k9x80001qa7z&sig=5e1c0f3a9b2d4e6f8a7b9c0d1e2f3a4b",
"pickup": {
"lat": 55.7601,
"lng": 37.6187,
"address": "Moscow, Tverskaya st, 7",
"contact": { "name": "Cafe Pelmen", "phone": "+74950000000" }
},
"dropoff": {
"lat": 55.7712,
"lng": 37.6329,
"address": "Moscow, Sretensky blvd, 4, apt 15",
"comment": "apt 15, entrance 2, floor 4, intercom 15",
"details": {
"city": "Moscow",
"street": "Sretensky blvd",
"house": "4",
"block": null,
"flat": "15",
"entrance": "2",
"floor": "4",
"intercom": "15",
"geoQuality": 0
},
"contact": { "name": "Anna", "phone": "+79990001122" }
},
"items": [
{ "name": "Pelmeni", "quantity": 2, "price": 390 },
{ "name": "Mors", "quantity": 1, "price": 150 }
],
"total": 1180,
"price": 250,
"currency": "RUB",
"scheduledAt": null,
"comment": "Please call when you arrive",
"locale": "ru",
"paid": true
}| Field | What it is |
|---|---|
restaurantId | Venue ID in Shuqr. It tells you whose order it is – but only after the signature check. See “Several restaurants”. |
deliveryId | Delivery ID in Shuqr. Keep it: Shuqr uses it to cancel the job, and you use it to detect repeats. |
orderNumber | Order number the guest and the cashier see. The courier says it when picking up the order. |
callbackUrl | Where to send the stages of this delivery. Save it together with the job. |
pickup | Where to pick up: lat/lng coordinates, address and the venue's contact (name and phone). The phone may be null if the venue hasn't set one. |
dropoff | Where to deliver: coordinates, address and the guest contact (name and phone). dropoff.comment is the apartment, entrance, floor and intercom as one line in the venue language; dropoff.details is the same address split into parts (see below). Both may be absent. The guest phone may be null if the guest did not leave one – then the courier calls the venue (contact from pickup). |
items | Order contents: name, quantity and price per item. |
total | Order total. Useful for cargo insurance. Don't collect money from the guest. |
price | Delivery price the venue set in its settings. |
currency | Currency of all amounts, ISO 4217 code, for example RUB. |
scheduledAt | Deliver by this time (ISO 8601). null – as soon as possible. |
comment | Comment from the guest and the cashier: requests such as «call in advance». Apartment and entrance are in dropoff. May be null. |
locale | Venue language, for example ru or en. |
paid | Always true: the order is paid. |
Guest address in parts
When the address was picked from suggestions, dropoff.details carries its parts – handy for finding the building in your own database and showing the courier the apartment and entrance separately. All fields are optional: an order with a typed-in address may have none.
| Field | What it is |
|---|---|
city | City or settlement. |
street | Street with its type («ul Tverskaya»). |
house | House number. |
block | Block, building or letter with its type («k 2»). |
flat | Apartment or office. |
entrance | Entrance – given by the guest. |
floor | Floor – given by the guest. |
intercom | Intercom code – given by the guest. |
geoQuality | Coordinate precision: 0 – exact building, 1 – nearest building, 2 – street, 3–4 – settlement or city, 5 – no coordinates. At 2 and above the courier should check the address with the guest. |
Amounts are in currency units (from currency), not in cents or kopecks. They can be fractional, for example 249.5.
A 2xx code and JSON with your job ID in id. You can pass the stage in status right away – usually searching. Without status, Shuqr assumes you are looking for a courier.
{ "id": "A-1042", "status": "searching" }If the same order is handed over again (for example, after a refusal), it comes with a new deliveryId – that is a new job. A repeat with the same deliveryId is the same order: return the job you already created.
delivery.cancel – cancellation
Sent when the venue cancels the order or refunds the guest, and also when you take too long to find a courier (the venue sets the time limit). The id field holds your job ID from the delivery.create reply.
{ "event": "delivery.cancel", "deliveryId": "cmhf2k9x80001qa7z", "id": "A-1042" }Cancelled – reply 2xx. No such job – 404, this also counts as cancelled. The courier already has the order and you can't cancel – 409.
If you replied 409, the venue sees that cancelling failed. Contact the venue to agree what to do with the order.
How Shuqr reads your reply
| Response code | What it means for Shuqr |
|---|---|
2xx | Accepted. |
401, 403 | Wrong key. The venue sees that the key needs checking. |
422 | You don't deliver to this address. |
409 | Cancellation only: too late to cancel, the courier has the order. |
404 | Cancellation only: no such job, treated as cancelled. |
4xx | Order refused. |
5xx | Service unavailable. |
On any error for delivery.create the order goes to the venue's backup service or to the cashier. Shuqr does not retry this request by itself.
In an error reply you can pass an error field with a clear message. Shuqr saves it in the delivery history, which helps to sort out the problem.
Checking the signature#
The signature proves that the request came from Shuqr and nobody changed it. Shuqr takes the request body as is and computes HMAC-SHA256 of it with the key the venue entered in its settings. The hex result with the sha256= prefix goes into the X-Shuqr-Signature header. You do the same on your side and compare the strings.
- Compute the signature over the raw body – exactly the bytes you received. If you parse the JSON and encode it again, the signature won't match.
- Compare with a constant-time function:
hash_equalsin PHP,crypto.timingSafeEqualin Node.js. - Signature doesn't match – reply
401and don't touch the order.
const http = require('node:http');
const crypto = require('node:crypto');
// The signing key you gave to the restaurant. With several restaurants give each one its own address,
// e.g. /shuqr/cafe-pelmen, and look up that restaurant's key by the code from req.url.
const SECRET = process.env.SHUQR_SECRET;
// The signature is calculated over the raw request body, byte for byte.
function isFromShuqr(rawBody, header) {
const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex'));
const received = Buffer.from(header || '');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
function reply(res, code, data) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (!isFromShuqr(rawBody, req.headers['x-shuqr-signature'])) {
return reply(res, 401, { error: 'Bad signature' });
}
const event = JSON.parse(rawBody.toString('utf8'));
if (event.event === 'ping') {
return reply(res, 200, { ok: true });
}
if (event.event === 'delivery.create') {
// Your code: save the order together with event.callbackUrl.
// If an order with this event.deliveryId already exists, return it, do not create a second one.
const order = saveOrder(event);
return reply(res, 200, { id: order.id, status: 'searching' });
}
if (event.event === 'delivery.cancel') {
// Your code: cancel the order. false = the courier already has the food.
const cancelled = cancelOrder(event.deliveryId);
return cancelled ? reply(res, 200, { ok: true }) : reply(res, 409, { error: 'Courier already picked up the order' });
}
reply(res, 400, { error: 'Unknown event' });
});
}).listen(8080);Both examples were tested against real Shuqr requests. saveOrder and cancelOrder are your code: save the job and cancel it.
Reporting delivery stages#
When something changes with a job, send a POST with JSON to the order's callbackUrl. Use the address exactly as you received it, don't change anything in it.
Fields
| Field | What it is |
|---|---|
status | Required. Delivery stage – one of the values below. |
id | Your job ID. |
courier | Courier: name and phone. The cashier and the guest will see them. |
trackingUrl | Link where the guest sees the courier on a map. https:// or http:// only. |
etaDropoffAt | When the courier will reach the guest, ISO 8601 with time zone, for example 2026-11-01T19:40:00+03:00. |
price | Your final delivery price. It goes to the venue's expenses. If you don't send it, the price from the venue's settings counts as the expense. |
reason | Why the delivery didn't happen – for returning, cancelled and failed. Values are in the reasons table below. |
Send only what changed: a field missing from the request stays as it was. Only status is always needed.
{
"status": "assigned",
"id": "A-1042",
"courier": { "name": "Oleg", "phone": "+79990003344" },
"trackingUrl": "https://track.example.com/A-1042",
"etaDropoffAt": "2026-11-01T19:40:00+03:00",
"price": 250
}Stages
| status | Meaning |
|---|---|
searching | Looking for a courier. |
assigned | Courier assigned. |
to_pickup | Courier is on the way to the venue. |
picked_up | Courier picked up the order. |
delivered | Guest received the order. Delivery is over: the order is closed in Shuqr and becomes “Completed”. |
returning | Courier couldn't hand over the order and is taking it back to the venue. |
cancelled | You cancelled the delivery. Delivery is over. |
failed | Couldn't take the order: no courier found or you don't deliver there. Delivery is over. |
The usual path: searching → assigned → to_pickup → picked_up → delivered.
- Stages only move forward. If you send an earlier stage, Shuqr skips it. One exception – the courier dropped out before picking up the order: you can go back to
searchingand look for another one. delivered,cancelledandfailedare final. After them Shuqr changes nothing, so send the final price no later than the final stage.failedorcancelledbefore the courier picked up the order – Shuqr immediately hands the order to the venue's backup service or to the cashier.- The courier picked up the order but couldn't hand it over: send
returningwith a reason, for exampleguestRefused. When the courier brings the order back to the venue, sendcancelledwith reasonreturned. The cashier decides what happens next.
{ "status": "returning", "id": "A-1042", "reason": "guestRefused" }{ "status": "cancelled", "id": "A-1042", "reason": "returned" }Reasons
The first four reasons are for refusing before the courier picked up the order. The rest are for when the courier already has the order but couldn't hand it over.
| reason | When to send |
|---|---|
courierNotFound | No courier found. |
courierCancelled | The courier refused the order. |
notDeliverable | You don't deliver to this address. |
rejected | Refused for another reason. |
guestNoAnswer | The guest doesn't answer the phone or open the door. |
wrongAddress | Wrong address. |
guestRefused | The guest refused the order. |
orderDamaged | The order is spoiled or damaged. |
courierProblem | The courier can't complete the delivery (breakdown and so on). |
handoverFailed | Couldn't hand over the order for another reason. |
returned | The courier brought the order back to the venue. |
Shuqr doesn't store other reason values, but still accepts the stage.
What Shuqr replies
| Code | Meaning |
|---|---|
200 | Accepted. Shuqr gives the same reply to a repeat or to a stage that is no longer needed – no need to retry. |
401 | The address was changed or belongs to another delivery, the body isn't JSON, or the status is unknown. Retrying won't help – check the request. |
No reply or a 5xx code – retry a bit later.
curl -X POST "https://shuqr.ru/api/delivery/webhook/webhook?delivery=cmhf2k9x80001qa7z&sig=5e1c0f3a9b2d4e6f8a7b9c0d1e2f3a4b" \
-H "Content-Type: application/json" \
-d '{"status": "picked_up", "id": "A-1042"}'How to test#
- Shuqr has no separate test environment yet. You test the connection together with a venue that wants to work with you.
- Your address must be reachable from the internet over HTTPS. To test a server on your own computer, use a tunnel such as ngrok or Cloudflare Tunnel.
- Log every incoming request in full: headers and raw body. That makes it easier to find out why a signature doesn't match.
- You can check the signature without Shuqr: sign a test body yourself and send a request to yourself, as in the example below.
BODY='{"event":"ping"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SHUQR_SECRET" | sed 's/^.* //')
curl -X POST "https://your-service.example.com/shuqr" \
-H "Content-Type: application/json" \
-H "X-Shuqr-Event: ping" \
-H "X-Shuqr-Signature: sha256=$SIG" \
-d "$BODY"Then ask the venue to place a test delivery order and pay for it. You will get delivery.create and can go through all stages. Cancelling the order in Shuqr sends delivery.cancel.
Questions#
If something is unclear or doesn't work, contact us or write to support@shuqr.ru. Include your server address and the request time – that helps us find it in the logs faster.