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#

  1. The venue connects your service in its settings and clicks "Check connection" – Shuqr sends ping.
  2. When it is time to deliver an order, Shuqr sends delivery.create. Within 10 seconds you reply with your job ID in id.
  3. As the courier moves on, you send stages to the order's callbackUrl: assigned, on the way to the venue, picked up.
  4. The delivered stage closes the order for the venue: it becomes “Completed”. Along the way the cashier and the guest see the courier and the current stage.
  5. Until the courier picks up the order, the venue may cancel it. Then you get delivery.cancel.

How connecting works#

  1. 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.
  2. 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.
  3. When an order is paid and ready to go, Shuqr sends you a delivery.create request. 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 POST with 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-Signature header on every request.
  • Protection from repeats: a second delivery.create with the same deliveryId must not create a second job.
  • Your job ID in id in the reply to delivery.create.
  • Sending stages to the order's callbackUrl every 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.

HeaderValue
Content-TypeAlways application/json.
X-Shuqr-EventEvent type: ping, delivery.create or delivery.cancel. The same value is in the body's event field.
X-Shuqr-Signaturesha256= 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.

JSON
{ "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.

JSON
{
  "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
}
FieldWhat it is
restaurantIdVenue ID in Shuqr. It tells you whose order it is – but only after the signature check. See “Several restaurants”.
deliveryIdDelivery ID in Shuqr. Keep it: Shuqr uses it to cancel the job, and you use it to detect repeats.
orderNumberOrder number the guest and the cashier see. The courier says it when picking up the order.
callbackUrlWhere to send the stages of this delivery. Save it together with the job.
pickupWhere 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.
dropoffWhere 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).
itemsOrder contents: name, quantity and price per item.
totalOrder total. Useful for cargo insurance. Don't collect money from the guest.
priceDelivery price the venue set in its settings.
currencyCurrency of all amounts, ISO 4217 code, for example RUB.
scheduledAtDeliver by this time (ISO 8601). null – as soon as possible.
commentComment from the guest and the cashier: requests such as «call in advance». Apartment and entrance are in dropoff. May be null.
localeVenue language, for example ru or en.
paidAlways 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.

FieldWhat it is
cityCity or settlement.
streetStreet with its type («ul Tverskaya»).
houseHouse number.
blockBlock, building or letter with its type («k 2»).
flatApartment or office.
entranceEntrance – given by the guest.
floorFloor – given by the guest.
intercomIntercom code – given by the guest.
geoQualityCoordinate 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.

JSON
{ "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.

JSON
{ "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 codeWhat it means for Shuqr
2xxAccepted.
401, 403Wrong key. The venue sees that the key needs checking.
422You don't deliver to this address.
409Cancellation only: too late to cancel, the courier has the order.
404Cancellation only: no such job, treated as cancelled.
4xxOrder refused.
5xxService 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_equals in PHP, crypto.timingSafeEqual in Node.js.
  • Signature doesn't match – reply 401 and don't touch the order.
Node.js (no libraries, built-in http module)
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

FieldWhat it is
statusRequired. Delivery stage – one of the values below.
idYour job ID.
courierCourier: name and phone. The cashier and the guest will see them.
trackingUrlLink where the guest sees the courier on a map. https:// or http:// only.
etaDropoffAtWhen the courier will reach the guest, ISO 8601 with time zone, for example 2026-11-01T19:40:00+03:00.
priceYour 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.
reasonWhy 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.

JSON
Example body
{
  "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

statusMeaning
searchingLooking for a courier.
assignedCourier assigned.
to_pickupCourier is on the way to the venue.
picked_upCourier picked up the order.
deliveredGuest received the order. Delivery is over: the order is closed in Shuqr and becomes “Completed”.
returningCourier couldn't hand over the order and is taking it back to the venue.
cancelledYou cancelled the delivery. Delivery is over.
failedCouldn'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 searching and look for another one.
  • delivered, cancelled and failed are final. After them Shuqr changes nothing, so send the final price no later than the final stage.
  • failed or cancelled before 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 returning with a reason, for example guestRefused. When the courier brings the order back to the venue, send cancelled with reason returned. The cashier decides what happens next.
JSON
1. The guest refused, the courier takes the order back
{ "status": "returning", "id": "A-1042", "reason": "guestRefused" }
JSON
2. The courier brought the order back to the venue
{ "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.

reasonWhen to send
courierNotFoundNo courier found.
courierCancelledThe courier refused the order.
notDeliverableYou don't deliver to this address.
rejectedRefused for another reason.
guestNoAnswerThe guest doesn't answer the phone or open the door.
wrongAddressWrong address.
guestRefusedThe guest refused the order.
orderDamagedThe order is spoiled or damaged.
courierProblemThe courier can't complete the delivery (breakdown and so on).
handoverFailedCouldn't hand over the order for another reason.
returnedThe courier brought the order back to the venue.

Shuqr doesn't store other reason values, but still accepts the stage.

What Shuqr replies

CodeMeaning
200Accepted. Shuqr gives the same reply to a repeat or to a stage that is no longer needed – no need to retry.
401The 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.

The same from the command line
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.
Shell
Signed test request (bash, openssl, curl)
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.