Developers
API and integrations
The Symfony backend exposes the API used by the mobile app and external integrations.
API documentation
Every API endpoint is documented (generated from the code) at:
/api/doc (interactive; use "Authorize" with a token)
/api/doc.json (OpenAPI document)
The Postman collection OnlyParcels.postman_collection.json is generated from the same document, grouped by area. Run Sign in first: it stores the token used by every other request. Set the baseUrl variable to your backend. After changing the API, regenerate it:
php bin/console app:api:postman [--base-url=https://delivery.example.com]
Authentication
Login endpoint:
POST /api/v1/users/login
Refresh endpoint:
POST /api/v1/users/token/refresh
The refresh endpoint expects a refresh token body similar to:
{
"refreshToken": "your_refresh_token"
}
Authenticated requests should include:
Authorization: Bearer your_access_token
Sign-in code on new devices
With Settings → Sign-in security → Ask for a code… (security.two_factor) set to staff (admins, stores and couriers) or everyone, a password sign-in from a device the account hasn't verified answers 202 instead of the tokens:
{
"twoFactorRequired": true,
"challenge": "c3f1…",
"channel": "email",
"sentTo": "m•••@example.com",
"expiresIn": 600
}
A six-digit code went to the account's email (or, for an account without one, by text to its verified phone). The app finishes the sign-in with:
POST /api/v1/auth/two-factor/verify {"challenge": "…", "code": "123456", "trustDevice": true, "deviceName": "iPhone 16"}
POST /api/v1/auth/two-factor/resend {"challenge": "…"}
Verify returns the usual tokens and, when trustDevice is true, a deviceToken. Sent with later sign-ins as the header below, it skips the code for Remember a verified device for (days) (security.trusted_device_days, 30; 0 = never). Only its hash is stored, so it can't be guessed or copied from the database, and changing the password forgets every verified device.
X-Device-Token: device_token_from_verify
Codes expire after 10 minutes. Five wrong codes burn the code and lock the account's code checks for 15 minutes; five codes per account per 15 minutes can be sent. Phone sign-in skips the step, since its code already proves the phone. The admin panel, the store panel and the website ask for the same code on their own page, hold every other page until it is entered, and remember the browser with a cookie when the box is ticked; remember-me waits for the code too.
Password reset
POST /api/v1/users/forgot-password emails a six-digit code (at most three per address per 15 minutes; the answer is the same whether or not the address has an account) and POST /api/v1/users/reset-password takes it with the new password. Only a hash of the code is stored. Five wrong codes burn it and lock resets for that address for 15 minutes. A new password signs every device out: refresh tokens are revoked and verified devices forgotten.
Main API areas
Common API areas include:
- Health check.
- User registration, login, profile, OTP, password reset, refresh token.
- Contacts.
- Vendor types.
- Vendors.
- Categories.
- Products.
- Cart.
- Orders.
- Bookings.
- Parcels.
- Attachments.
- Favorites.
- Ratings.
- Vendor stats, products, and orders.
- Rider routes, stats, jobs, job status, and proof.
- Stripe webhook.
Google Maps
Google Maps and Places functionality requires a valid Google Maps API key.
Configure the backend integration settings and mobile app environment with the same buyer-owned Google Cloud project where appropriate.
For production, restrict the Google key by platform:
- HTTP referrer restrictions for backend/admin web usage when applicable.
- Android package and SHA restrictions.
- iOS bundle identifier restrictions.
Pusher
Pusher is used for real-time rider delivery offers.
Configure matching Pusher values in:
- Backend integration settings.
- Mobile app
app/env.ts.
Required values:
Pusher app ID
Pusher key
Pusher secret
Pusher cluster
Mailer
Email is configured from the backend admin panel under Notification Settings. Fill in the Mailgun API key, Mailgun domain, from email, from name, and enable mailing when ready.
Email is used for flows such as OTP and password reset. Symfony's built-in mailer transport is disabled by default because the platform uses the admin-managed Mailgun sender.
The public website URL can be configured under Platform Settings. It replaces the need for a technical DEFAULT_URI environment value and gives future public links a buyer-editable home.
Notifications inbox
Every push is also kept in the recipient's in-app inbox (GET /api/v1/notifications, PATCH /api/v1/notifications/{id}/read, POST /api/v1/notifications/read-all), whether or not a device received it. Each push carries its notificationId, so opening it marks it read. Notifications are deleted after notifications.retention_days (90) by app:messages:purge; admins can browse them under Communications → Notifications.
Search
GET /api/v1/search?q= finds stores by their name and description and by what they sell, ranked: a store's own match counts more than a product's, and each result lists up to four matching products. Only live stores that deliver to the given address appear. It runs on MySQL's full-text index (created by the migrations), with every word matched as a prefix. Words shorter than three letters fall back to a name search.
A deployment that outgrows MySQL search can plug in a search engine (Meilisearch, for example) by implementing App\Search\SearchProviderInterface and binding it in config/services.yaml. The ranking and store filtering stay the same.
The store list GET /api/v1/vendors combines its filters on the server: type, categoryId, latitude/longitude (only stores delivering there), openNow=1, freeDelivery=1, maxDistanceKm and orderBy=distance.
Product availability
Products carry availableNow (can be ordered right now), soldOut, availableAgainAt (when a sold-out item comes back by itself), stockQuantity and availableHours. Modifier options carry availableNow. The menu (GET /api/v1/products?vendor=) leaves out items outside their menu hours. POST /api/v1/orders/quote returns unavailableProductIds for cart items that can't be ordered any more, and POST /api/v1/orders refuses them with 422 (also when there isn't enough stock left).
Stores change availability with PATCH /api/v1/vendor/products/{id}/availability and PATCH /api/v1/vendor/modifier-options/{id}/availability. The body takes availability (available, sold_out_today, sold_out_until with until, or unavailable); for products, stockQuantity too (null stops counting).
Allergens and dietary tags
Products carry allergens (celery, gluten, crustaceans, eggs, fish, lupin, milk, molluscs, mustard, nuts, peanuts, sesame, soy, sulphites), dietaryTags (vegetarian, vegan, halal, kosher, gluten_free, lactose_free, spicy) and calories, and stores write them on their products the same way. GET /api/v1/vendors?dietary=vegan,gluten_free keeps the stores with a dish on the menu carrying all of the given tags.
A request body with a value the API doesn't know (an unknown allergen, for example) is refused with 422 naming the field.
Reviews
GET /api/v1/vendors/{id}/reviews?before= lists a store's reviews that have a comment or an answer, newest first (20 at a time): liked, comment, author (first name and initial), createdAt, reply. POST /api/v1/ratings/{id}/report reports one. Stores read theirs with GET /api/v1/vendor/reviews (with their score and unanswered count), answer with PUT /api/v1/vendor/reviews/{id}/reply ({"reply": "…"}; empty takes the answer down) and report with POST /api/v1/vendor/reviews/{id}/report. Sorting the store list by orderBy=likePercentage:DESC uses the customers' score.
Several stores and store roles
Every /api/v1/vendor/* request works on one store: the one in the X-Vendor-Id header, or the person's first store without it. A header for a store they don't work at is a 404, and an action their role doesn't allow is a 403. GET /api/v1/vendor/stores lists their stores with role (owner, manager, staff) and permissions (orders, availability, menu, promotions, settings, reviews, stats, payouts, team).
The team: GET/POST /api/v1/vendor/team ({"email", "role": "manager" | "staff"}) and PATCH/DELETE /api/v1/vendor/team/{id}. A chain's figures: GET /api/v1/vendor/group/stats. Copying a menu: POST /api/v1/vendor/menu/copy with {"fromVendorId": …} into the current store.
Courier vehicles
An approved courier changes vehicle with PATCH /api/v1/courier-application/vehicle ({"vehicleType": "bicycle" | "e_bike" | "e_scooter" | "motorbike" | "car" | "van", "plateNumber": …}). A motorised vehicle is refused with 422 until the courier's driving licence, registration and insurance are accepted. Delivery offers, the parcel board and taking a parcel job only include jobs the courier's vehicle can do (its distance limit and the vehicles each parcel class allows).
Courier shifts
GET /api/v1/rider/shifts returns mode (free, slots_preferred or slots_required), cancelCutoffMinutes, noShowCount, current (the slot the courier is on now, or null) and slots: every slot still to come, with zone, startsAt, endsAt, capacity, booked, started, the courier's own status (booked, checked_in, no_show or null) and canCancel. POST /api/v1/rider/shifts/{id}/book books one and DELETE /api/v1/rider/shifts/{id}/book cancels it; both return the same overview, or 422 when the slot is full, overlaps another booking, was missed, or is past the cancel cutoff. Going online (PATCH /api/v1/rider/availability) during a booked slot checks the courier in. Reminders arrive as a push with {"type": "shift", "slotId": …}.
Courier earnings
GET /api/v1/rider/earnings?period=day|week|month&offset=0 returns a courier's earnings for a period (offset counts periods back; weeks start on Monday, in the platform's time zone), read from their wallet ledger: total = deliveryPay + surge + tips + adjustments, deliveries, cashCollected and cashRemitted in the period, cashHeld and netPayable now, remitInstructions, days (total and deliveries per day) and jobs (pay, surge, tip and cash per delivery). Payouts are listed by GET /api/v1/payouts with their status, periodFrom and periodTo.
Failed deliveries
POST /api/v1/rider/jobs/{id}/fail takes reason (customer_unreachable, wrong_address, customer_refused, vendor_closed, item_unavailable, vehicle_issue, other), note (required for other) and, as multipart, a photo. It returns outcome: handed_back (a vehicle problem before pickup; the job goes back to dispatch), failed, or returning (the store wants it back; the courier then calls POST /api/v1/rider/jobs/{id}/returned). POST /api/v1/rider/jobs/{id}/contact-attempts ({"method": "call"}) records a call to the customer; the job's unreachableAllowedAt says when "customer unreachable" is accepted. A failed order has status delivery_failed and deliveryFailureReason; a job being brought back has status returning. Moving a job from in_progress straight to incomplete is no longer accepted. Parcel actions collection_failed and delivery_failed need a reason. Store settings take failedDeliveryHandling (dispose or return_to_store).
Phone sign-in
POST /api/v1/auth/phone/start ({"phone": "0712 345 678", "country": "RO"}; country says how to read a number without +code) sends a code and returns phone (E.164), channel (sms or email) and sentTo (masked). POST /api/v1/auth/phone/verify ({"phone", "code"}) returns token, refresh_token, isNewUser and userId; a new number gets a customer account. Refusals carry a message: wait N seconds (429), wrong code with tries left (422), locked for N minutes (429), country not supported, or texts unavailable (503). Signed-in people add a number with POST /api/v1/users/me/phone/start and /verify (409 if another account has it). POST /api/v1/users/login also accepts a verified phone number in email. A phone-only account has email: null and verifiedPhone.
Referrals and membership
GET /api/v1/referrals/me returns the customer's code, the credit for both sides, how many friends joined and what they earned, claimed and canClaim. POST /api/v1/referrals/claim ({"code", "deviceToken"}) joins with a friend's code (422 with the reason when refused).
GET /api/v1/membership returns the plan and the customer's status, active, currentPeriodEnd, cancelAtPeriodEnd and savedThisPeriod. POST /api/v1/membership/subscribe ({"paymentMethodId"}, a saved card) answers with requiresAction and clientSecret when the bank asks for 3-D Secure (confirm it with the Stripe SDK); POST /api/v1/membership/cancel and /resume change the renewal. Order quotes and orders carry membershipSavingsAmount. GET /api/v1/platform adds membership (the plan) and referralsEnabled.
Store promotions and settings
Products carry bestActiveOffer wherever they're returned: the best offer without a minimum order (discountType, discountValue, discountedAmountValue, …). Stores (vendor:read) carry promotions, their running whole-store offers (type percent, fixed or free_delivery, value, minOrderAmount, endsAt). The order quote returns deliveryPromoAmount: the delivery fee the store's free-delivery offer covers.
Stores manage their promotions with GET/POST /api/v1/vendor/offers and PATCH/DELETE /api/v1/vendor/offers/{id}. The body takes discountType, discountValue, target ({"type": "store" | "category" | "product", "id": …}), minOrderAmount, startsAt, endsAt, isActive and name; a PATCH changes only what it sends. Their settings are at GET/PATCH /api/v1/vendor/settings: acceptMode (manual or auto), preparationMinutes, minOrderAmount, deliveryFeeAmount (when the platform allows it) and openingHours.
Partner API and webhooks
For a store's POS or an integrator: /api/partner/v1, documented on its own at /api/doc/partner (JSON at /api/doc/partner.json, which Postman and similar tools can import). Each call sends a key made in the admin as Authorization: Bearer opk_… (or X-Api-Key). A missing, unknown or revoked key gets 401, a key without the scope 403, and another store's order or store 404. Money is in whole cents. Errors are {"errors": {"status", "title", "detail"}}.
GET /stores: the stores the key manages.GET /orders(store,statuscomma-separated,since) andGET /orders/{id}: orders once paid (or confirmed, for cash), with items (externalId, quantity, amounts, modifiers), the customer's first name, notes, payment and amounts.POST /orders/{id}/accept({"preparationMinutes"}),POST /orders/{id}/reject({"reason"}; the customer is refunded),POST /orders/{id}/ready(tells the courier) andPUT /orders/{id}/preparation-time.PUT /stores/{id}/menuwith{"products": […], "replace": false}. Each product hasexternalId(the POS's id),nameandprice, and optionallydescription,category(an existing category's name),available,allergens,tags,caloriesandmodifierGroups(name,minSelect,maxSelect,required,optionswithname,priceDeltaandavailable). Products are matched byexternalId; withreplacethe store's other products are hidden. It returns how many were created, updated and hidden, and warnings.PATCH /stores/{id}/products/{externalId}changesavailableorprice.
Webhooks. Events: order.created, order.accepted, order.ready, order.picked_up, order.delivered, order.cancelled, order.delivery_failed and product.updated (changes made outside the partner API). Each is a POST of {"id", "type", "createdAt", "data"}, where data is the order or product as the API returns it, with the headers X-Webhook-Event, X-Webhook-Id (the same on retries; use it to skip repeats) and X-Webhook-Signature: t=<unix time>,v1=<hex>. To check it, compute HMAC-SHA256 of <t>.<raw body> with the webhook's secret, compare it with v1 and refuse old timestamps. Answer with a 2xx within 10 seconds: 5xx, 408, 429 and timeouts are retried after 1, 5, 30, 120 and 360 minutes; other answers and redirects aren't.
Store reports
GET /api/v1/vendor/reports?from=2026-10-01&to=2026-10-31 (store stats permission; the last 7 days by default) returns a store's period of local days, both included, in its timezone (at most 366 days): sales (orders, gross product sales, average basket, cancelled, issues), an hourly or daily series, topProducts, and, with the payouts permission, money (earnings, tips, refunds, adjustments, net) summed from the store's wallet ledger lines posted in the period, so it matches the ledger exactly. Amounts are in cents.
POST /api/v1/vendor/reports/export-link with from, to, dataset (orders or ledger) and format (xlsx or csv) returns a signed download URL, valid 15 minutes, that the app opens in the browser. The store dashboard figures (/vendor/stats todayOrders/todaySales, chain stats) count the store's own day.
Website
The ordering website is rendered by the backend (Twig with Stimulus and Turbo, built by Encore into public/build), so it needs no separate service: it ships with every instance and uses the same services as the app's API. Pages: / (home), /stores and /stores/{type slug}, /store/{store slug}, /search?q=, /cart, /checkout, /orders and /orders/{id}, /login, /account. Customers sign in with a session (the web firewall, remembered for 30 days); signing in during an account's deletion grace period cancels the deletion, as in the app.
Search engines. Store pages have their own title, description, canonical URL, Open Graph tags and schema.org data (Restaurant or Store, address, position, opening hours, rating). /sitemap.xml lists the home page, store types and live stores; /robots.txt points to it and keeps private pages out. Each page links its other languages (?lang=).
App links. /s/{id}, /t/{id}, /o/{id} and /promo/{code} still open the app where it's installed. Elsewhere they open the store, store type or order on the website; a promo link keeps the code for checkout.
Orders. Placing an order uses the API's order path (same checks and server-side prices), so a store can't tell web orders from app orders. Card payments are confirmed in the browser with Stripe.js; the order page then checks the payment with Stripe and marks it paid at once, without waiting for the webhook (keep the webhook: it still handles everything else). The status page refreshes itself while the order is under way and shows the courier on a map when the store has live tracking on.
Caching
GET catalog endpoints (/home, /vendors, /vendors/{id}, /vendor-types, /products, /products/{id}, /categories, /search, /platform, /faq) answer with Cache-Control: private, max-age=15, must-revalidate, Vary: Authorization, Accept-Language and a weak ETag computed from the body. Send it back in If-None-Match to get 304 Not Modified while nothing has changed; any change to the store, its products, offers or opening state gives a new ETag. The app's HTTP stack (OkHttp on Android, URLCache on iOS) does this by itself. Orders, carts and account endpoints are never marked cacheable.
File storage
Uploaded media can be stored locally or in S3, depending on storage settings.
Production systems should use durable storage and should make uploaded public files accessible from the configured domain or CDN.
Found a mistake? Tell us.