Skip to content

ADR-015: Uklon courier delivery through its Delivery API

Status: accepted 5 October 2026 (Stage 3A, improvement-plans/Stage-3A-Plan.md). Extends the fulfilment decision of ADR-014.

The owner wants customers to be able to choose delivery by a Uklon courier, priced live. Uklon publishes a Delivery API: the OpenAPI spec is at deliverygateway.uklon.com.ua/openapi/v1, version 1.28.0, read 5 October 2026.

Access and coverage

  • Access needs a contract with Uklon per business: a legal entity or a registered sole trader (ФОП).
  • Each business gets its own client_id and client_secret. A florist SaaS (Flora24) integrates this way, one account per shop.
  • Uklon covers 27 Ukrainian cities.

Booking and pricing

  • The flow is: estimate a fare from coordinates, book an order from that fare, then follow status through HMAC-signed webhooks.
  • A fare lives 3–4 minutes (expires_at). Booking must therefore re-estimate, and the price may then differ through surge or tariff changes.
  • A courier on foot is limited to 10 km; a car has no distance limit.
  • There is no idempotency key for creating an order.
  • There is no tracking page for the recipient.

Payment and test environment

  • The business pays Uklon by invoice. “Recipient pays” is currently unavailable, so a Uklon courier cannot take payment for the order.
  • The staging environment was not responding when checked.
  1. Per-shop account. Each shop connects its own Uklon account: Client ID and Secret (stored encrypted), its Uklon city, the pickup point’s coordinates and the sender’s phone. The option is hidden from customers until the account is connected and a test estimate succeeds.
  2. An estimate at checkout.
    • Checkout geocodes the recipient’s address with Uklon’s address search and estimates a fare. Courier on foot is preferred, with a car when the distance is too long.
    • The estimate shows as the delivery fee, labelled as an estimate that is confirmed at dispatch.
    • If Uklon cannot estimate (outside the city, no response, no account), the option is not offered. A price is never invented.
  3. Booking at dispatch.
    • When staff dispatch a confirmed order, the app estimates again.
    • It books within the shop’s tolerance (default +15%); the shop absorbs that difference.
    • Above the tolerance, staff are asked to contact the customer first.
    • The local order reference goes in external_tracking_number. The Uklon order id is stored before anything else can retry, so a retry never books twice (there is no idempotency key).
  4. Paid by bank transfer before dispatch.
    • A Uklon order is paid by bank transfer to the shop’s IBAN, which is shown to the customer after the shop confirms. Staff record the payment, then dispatch.
    • Cash and card on delivery are not offered with Uklon.
  5. Status from webhooks, with polling as a fallback.
    • Uklon’s signed webhooks (HMAC-SHA256 in X-Signature, deduplicated by event_id) update the courier’s status.
    • If they stop, polling GET /orders/{id} catches up.
    • Staff see the courier and the vehicle. The customer’s status page shows the stage (courier found, on the way, delivered).
  6. Built before access. Until a shop has Uklon access, the integration is built and tested against the published spec with recorded responses. Nothing reaches customers before a real account is connected and verified.

Amendment, 6 October 2026: what the published spec says (PR 7a)

Section titled “Amendment, 6 October 2026: what the published spec says (PR 7a)”

Read against the spec itself (1.28.0) while building:

  • Reference number. external_tracking_number sits on each receiver, under receivers[].extra_parameters, not on the order. An order is found again only by its Uklon id or through GET /orders/active. So a booking whose answer is lost is recovered by matching our order number in the active list (PR 7b).
  • Fare lifetime. The estimate says 4 minutes; the order call says 3, “could be changed”. Booking re-estimates and does not rely on either.
  • Errors. The error key is spelled both sub_code and subcode, and a 403 nests it under error. The client reads all three, and logs Uklon’s text without showing it.
  • Webhook signature. It is HMAC-SHA256 over the raw body with our key, in X-Signature. The spec does not say whether hex or base64, so both are accepted (PR 7b).
  • Amounts come back as JSON numbers in the fare’s currency. They become exact money only when they fit the currency; anything else is treated as an error.
  • Dispatch (7b). Booking needs orders.edit and the payment recorded. The Uklon order id and webhook event ids are stored as plain strings, not UUIDs, so an id in an unexpected format can never fail to save a booking Uklon already made. A completed courier delivers the order through the legal steps, and staff are emailed when Uklon cancels a courier they did not. Polling needs Laravel’s scheduler, which production did not run until this change.
  • Built in two parts. 7a covers the account and checkout (estimate, address, bank transfer). 7b covers booking at dispatch, webhooks, polling and the staff panel.
  • Shops apply for Uklon business access themselves (uklon.com.ua/delivery).
  • Open questions for Uklon ([email protected]): staging access; whether a platform may hold many shops’ credentials; the real fare lifetime; idempotency of POST /orders; webhook retry policy; scheduled-order lead times; cancellation and idle fees.
  • Scheduled deliveries use Uklon’s deferred arrival, at most a week ahead. A courier is not guaranteed, so staff can fall back to the shop’s own delivery.