Appearance
Uber Eats Integration Setup
Overview
Uber Eats is a food delivery platform. This integration lets you receive Uber Eats orders directly in Upvendo and manage them alongside your other orders. The integration is location-scoped -- each Uber Eats store is linked to a specific Upvendo location. Menu data is pushed from Upvendo to Uber Eats, and orders flow from Uber Eats into Upvendo via webhooks.
Integration Type: Third-party delivery channel (location-scoped) Sync Direction: Menu is one-way (Upvendo to Uber Eats); Orders flow in from Uber Eats, with order actions, store status and per-item updates pushed back out Payment Handling: Uber Eats handles all customer payments. You receive payouts from Uber Eats directly. Authentication: Two flows -- an Uber OAuth authorization-code flow (scope eats.pos_provisioning) for store provisioning/store-list, plus a client-credentials flow (app-level token) for menu sync, order APIs and the rest of the marketplace surface.
A merchant-facing overview of this integration is in Uber Eats Integration.
Purpose
The Uber Eats integration serves restaurants that want to:
- Receive Uber Eats orders inside the Upvendo back-office and kitchen workflow
- Push their Upvendo menu (items, categories, modifier groups, prices, schedules) to Uber Eats
- Have orders auto-accepted and forwarded into the existing order/POS pipeline, with control over when they hit the POS
- Manage the Uber store from Upvendo -- status, prep time, fulfillment, per-item availability, promotions and reports (subject to approved Uber scopes)
- Link specific Uber Eats stores to specific Upvendo locations
Key Concepts
| Concept | Description |
|---|---|
| Store ID | Uber Eats' identifier for a specific store. Selected from a list after OAuth authorization and stored in credentials.store_id (and mirrored to settings.selected_store_id). |
| User Access Token | Obtained via the OAuth authorization-code flow. Used for the store list and for linking/unlinking the store (pos_data). Stored in credentials.user_access_token with credentials.expiry; refreshed via credentials.refresh_token. |
| App Access Token | Obtained via the client-credentials flow. Used for menu sync, order fetching, and order acceptance. Cached app-wide under uber_eats.access_token. |
| POS Provisioning | Linking an Upvendo location to an Uber Eats store via the /v1/eats/stores/{storeId}/pos_data endpoint, which also enables webhooks. |
| ThirdPartyIntegration record | The single record storing credentials, settings, sync status, and menu_ids for the location. Provider is uber_eats. Modeled by UberEatsIntegration (extends ThirdPartyIntegration). |
| Integrator Brand ID | Set to upvendo_brand during POS provisioning; identifies Upvendo as the integrator. |
| Integrator Store ID | The Upvendo integration record ID, sent to Uber during provisioning and mirrored to settings.webhook_identifier. It is the primary key Uber webhooks are matched on. (Verified: app/Services/BackOffice/UberEatsService.php lines 634, 646, 176.) |
| Capability | A named marketplace feature (store_read, store_write, menu, orders_read, order_actions, promotions, reports, merchant_delivery) whose availability is derived from the location's approved Uber scopes. Returned in the status response and enforced on every marketplace call. (Verified: app/RawModels/UberEatsIntegration.php lines 157-185.) |
Note: all Uber Eats data (OAuth tokens, store ID, settings, sync status,
menu_ids) lives on the one per-locationThirdPartyIntegrationrecord. There is no separate "credential" record.
settings.selected_store_nameis persisted —store_nameis an accepted (optional) field on the select-store request and is written during store selection. The back-office UI does not currently send it, so for stores linked from the back office it typically reads back asnull. (Verified:app/Http/Requests/BackOffice/UberEatsIntegration/SelectUberEatsStoreRequest.phplines 17-21;app/Services/BackOffice/UberEatsService.phpline 175;upvendo-backoffice/src/store/modules/uberEats.tslines 79-82.)
Prerequisites
1. Uber Eats Partner Account
- Active Uber Eats for Merchants account
- Restaurant approved on the Uber Eats platform
- Access to Uber Eats Manager
2. Upvendo Environment Variables
The backend requires these environment-level secrets (configured by the Upvendo team, under config('services.uber_eats')):
UBER_EATS_CLIENT_ID-- OAuth application client IDUBER_EATS_CLIENT_SECRET-- OAuth application client secretUBER_EATS_REDIRECT_URI-- Redirect URI for the OAuth callbackUBER_EATS_WEBHOOK_SECRET-- Shared secret used for webhook signature verification and to HMAC-sign the OAuth state parameterUBER_EATS_SETUP_URL-- Where the OAuth callback redirects the merchant after successUBER_EATS_SANDBOX-- Defaults totrue. Read by the back-office controller on both connect and select-store and stored as the integration'sis_sandbox, which selects the sandbox or production API base URL for every subsequent call. It must be set tofalsefor real merchants.UBER_EATS_SCOPES-- Space-separated approved scopes; defaults toeats.store eats.store.orders.read eats.store.status.write eats.order. This list drives the capability map, so promotions (eats.store.promotions) and reports (eats.report) are unavailable unless added.UBER_EATS_AUTH_BASE_URL(defaulthttps://auth.uber.com/oauth/v2),UBER_EATS_API_BASE_URL(defaulthttps://api.uber.com),UBER_EATS_SANDBOX_API_BASE_URL(defaulthttps://sandbox-api.uber.com)UBER_EATS_CONNECT_TIMEOUT(default 10s),UBER_EATS_TIMEOUT(default 30s)UBER_EATS_OAUTH_STATE_TTL-- Lifetime of the signed OAuthstate, in seconds (default 600)UBER_EATS_REPORT_DOWNLOAD_HOSTS-- Comma-separated host allow-list for report downloads (defaultapi.uber.com,sandbox-api.uber.com)
(Verified: config/services.php lines 178-196.)
3. Location Configuration
- Go to Settings -> Locations
- Ensure the location address matches the Uber Eats listing
- Configure business hours / availability to match Uber Eats
4. Menu Setup
- Menu items should exist in Upvendo first
- Prices can differ from in-store prices (set the Uber Eats channel price on items)
- Ensure items have correct tax rates assigned (the delivery tax rate is used)
- Free-text item instructions are disabled in the Uber Eats menu payload (
disable_item_instructions: true)
Setup Steps
Step 1: Connect (OAuth authorization)
- Go to the Uber Eats page (back-office route
/uber-eats, listed under the Online channels in the sidebar) - Click Connect to Uber Eats
- The system creates a
ThirdPartyIntegrationrecord (inactive) and returns an Uber OAuth URL - The browser is redirected to
https://auth.uber.com/oauth/v2/authorizewith scopeeats.pos_provisioning - Log in to Uber and authorize Upvendo; Uber redirects to Upvendo's callback
- The callback exchanges the authorization code for an access token + refresh token and stores them on the integration
What happens behind the scenes:
POST /uber-eats/{locationId}callsstartOAuth, which creates the record and builds the OAuth URL. The OAuthstateis a base64url-encoded JSON{ payload: { vendor_id, location_id, iat, exp, nonce }, signature }, wheresignatureishash_hmac('sha256', payload, client_secret). Thenonceis also cached server-side and consumed on callback, so a state can be replayed only once and expires afterUBER_EATS_OAUTH_STATE_TTLseconds. (Verified:app/Services/BackOffice/UberEatsService.phplines 549-600.)- The OAuth callback (
GET /uber-eats/callback, a guest route handled byThirdPartyAuthController::handleUberEatsCallback) verifies the signature, expiry and nonce, then exchanges the code viaPOST {auth_base_url}/token(grant_type=authorization_code, scopeeats.pos_provisioning). An invalid or expired state returns 401; a missingcode/statereturns 422; a user-denied authorization returns 400. (Verified:app/Http/Controllers/Api/ThirdPartyAuthController.phplines 102-123.) - Tokens are saved to
credentials.user_access_token,credentials.refresh_token,credentials.expiry; the callback then redirects the merchant toUBER_EATS_SETUP_URL.
Step 2: Select and link a store
- Back on the Uber Eats page, the store dropdown is populated from your Uber account
- Pick a Store and confirm
What happens behind the scenes:
POST /uber-eats/{locationId}/select-storefirst saves the mapping in a restartable, inactive state:credentials.store_id,settings.selected_store_id,settings.selected_store_name(from the optionalstore_namefield),settings.webhook_identifier(the integration record ID),is_active = false, andsync_status.message: "Provisioning Uber Eats store".- It then reloads the integration and calls
linkLocationToStore()--POST /v1/eats/stores/{storeId}/pos_data(user access token, scopeeats.pos_provisioning,Idempotency-Key: provision-{integrationId}) with:integrator_brand_id: upvendo_brandintegrator_store_id: {integrationId},merchant_store_id: upvendo_{locationId}is_order_manager: true,require_manual_acceptance: falseallowed_customer_requests: single-use items and special instructions bothfalsewebhooks_config:order_release_webhooks,schedule_order_webhooksanddelivery_status_webhooksall enabled,webhooks_version: "1.0"
- Only after Uber confirms provisioning does Upvendo commit activation:
is_active = true,settings.connection_status = 'connected',settings.connected_at, andsync_status.message: "Integration enabled, awaiting first sync". - If linking throws, the exception propagates to the controller's error handler and the record stays inactive with the store mapping saved -- so the merchant can simply re-confirm the store rather than disconnect and reconnect.
(Verified: app/Services/BackOffice/UberEatsService.php lines 151-197 and 631-669.)
is_sandboxis accepted in the request validators but ignored: the controller passesconfig('services.uber_eats.sandbox')for both enable and select-store, so sandbox mode is decided by theUBER_EATS_SANDBOXenvironment variable (which defaults totrue), not by the request or the UI. The storedis_sandboxthen selects the sandbox or production API base URL on every subsequent call. (Verified:app/Http/Controllers/Api/BackOffice/UberEatsIntegrationController.phplines 61 and 85;config/services.phpline 184; base-URL selection inapp/Services/UberEats/UberEatsApiClient.phplines 181-193.)
Step 3: Sync your menu
- On the Uber Eats page, select the menus to push
- Click Sync Menu
- The system transforms your Upvendo menus and PUTs them to Uber Eats
Data Sync Details
Menu Sync (Upvendo -> Uber Eats)
Menu sync is one-way from Upvendo to Uber Eats. It PUTs the full payload to PUT /v2/eats/stores/{storeId}/menus using the app (client-credentials) access token with scope eats.store, and records progress in sync_status (syncing -> standby, or error on failure) plus the synced menu_ids and an appended sync_status.logs entry (last 100 kept). (Verified: app/Services/BackOffice/UberEatsService.php lines 217-286 and 59-83.)
It is triggered two ways:
- Manually from the Uber Eats page (Sync Menu).
- Automatically when a menu that is already in the integration's
menu_idsis updated.MenuService::update()fires aMenuUpdatedevent; the queuedSyncMenuWithUberEatslistener (auto-discovered, 3 tries) re-syncs the fullmenu_idsset if the integration is active and the edited menu is one of them. Menus that have never been synced are ignored. (Verified:app/Listeners/SyncMenuWithUberEats.phplines 29-42;app/Services/BackOffice/MenuService.phpline 127; listener discovery inbootstrap/app.phplines 150-152.)
What is synced:
| Upvendo Entity | Uber Eats Entity | Details |
|---|---|---|
| Menu | Menu | Title, category_ids, service_availability (schedule) |
| Display Group (Category) | Category | Title, entities (item references) |
| Item | Item | Title, description, image URL, price, tax rate, allergen classifications (dish_info.classifications), modifier group references |
| Modifier Group | Modifier Group | Title, modifier options, group-level quantity constraints |
| Modifier | Item (within modifier group) | Title, description, image URL, price, tax rate, per-group quantity overrides |
Price conversion: Prices are pushed in the smallest currency unit via the price's cents() value.
Language handling: Uber Eats uses a translations.default structure; the default-language name/description from the Upvendo entity is used.
Schedule handling: Menu schedules are transformed into Uber Eats' service_availability format with day_of_week (lowercase day name) and time_periods (start_time / end_time). All-day availability maps to 00:00-23:59.
Modifier quantity constraints (per modifier group settings):
is_mandatory->min_permitted: 1(as a per-modifier override, contextMODIFIER_GROUP)allow_same_modifier_more_than_onefalse/absent ->max_permitted: 1per modifier (override)allow_select_more_than_onewithmax_selected-> group-levelmax_permitted: {max_selected}- not
allow_select_more_than_one-> group-levelmax_permitted: 1
Display options: The payload sets display_options.disable_item_instructions: true.
Tax rates: The item's / modifier group's delivery tax rate is used (taxRate->getDeliveryRate()).
Menu Payload Structure
json
{
"categories": [],
"items": [],
"menus": [],
"modifier_groups": [],
"display_options": {
"disable_item_instructions": true
}
}Each item includes id, title.translations.default, description.translations.default, price_info.price (cents), tax_info.tax_rate, image_url, dish_info.classifications (the item's Upvendo allergens), and (for main items with modifiers) modifier_group_ids.ids. Modifier (option) items additionally carry quantity_info.overrides when constrained. (Verified: app/Services/BackOffice/UberEatsService.php lines 443-479 and 387-418.)
Each modifier group includes id, title.translations.default, modifier_options (array of {id, type: "ITEM"}), and optional quantity_info.quantity (group-level constraints).
Store List Retrieval
The store list is fetched with the user access token from GET /v1/delivery/stores (scope eats.pos_provisioning), supports pagination via next_page_token (pagination_data.next_page_token in the response), and is returned to the UI as { stores: [{id, name}], next_page_token }. The list is not persisted on the integration record. (Verified: app/Services/BackOffice/UberEatsService.php lines 615-629 and 702-725.)
POS Provisioning Details
When linking a store, the following is sent to Uber (POST /v1/eats/stores/{storeId}/pos_data, user access token, Idempotency-Key: provision-{integrationId}):
| Setting | Value | Description |
|---|---|---|
integrator_brand_id | upvendo_brand | Identifies Upvendo as the POS integrator |
integrator_store_id | {integrationId} | The Upvendo integration record ID (also stored as settings.webhook_identifier and used to match webhooks) |
merchant_store_id | upvendo_{locationId} | Merchant-side identifier; the upvendo_ prefix is stripped when resolving webhooks back to a location |
is_order_manager | true | Upvendo manages the order lifecycle |
require_manual_acceptance | false | Orders are auto-accepted |
webhooks_config.order_release_webhooks | enabled | Receive new-order notifications |
webhooks_config.schedule_order_webhooks | enabled | Receive scheduled-order notifications |
webhooks_config.delivery_status_webhooks | enabled | Receive delivery state changes (stored on settings.last_delivery_event) |
webhooks_config.webhooks_version | 1.0 | Webhook payload version |
allowed_customer_requests.allow_single_use_items_requests | false | Single-use item requests not allowed |
allowed_customer_requests.allow_special_instruction_requests | false | Special instructions not allowed |
(Verified: app/Services/BackOffice/UberEatsService.php lines 631-669.)
Webhook Events
Uber Eats sends webhooks to POST /api/webhook/uber-eats, verified by the verify.uber-eats-webhook middleware (HMAC-SHA256 X-Uber-Signature over the raw body, using UBER_EATS_WEBHOOK_SECRET, falling back to UBER_EATS_CLIENT_SECRET; missing or bad signature returns 401, unconfigured secret returns 503). The controller acknowledges immediately and queues ProcessUberEatsWebhookJob on the webhooks queue. (Verified: app/Http/Middleware/VerifyUberEatsWebhook.php; app/Http/Controllers/Api/WebhookController.php lines 34-39.)
ProcessUberEatsWebhookJob retries up to 4 times with a 1/5/30/120-second backoff, and de-duplicates on the Uber event ID (event_id, else webhook_meta.webhook_msg_uuid, else a hash of the payload): a 300-second in-flight marker blocks concurrent processing and a 2-day processed marker blocks replays. (Verified: app/Jobs/UberEats/ProcessUberEatsWebhookJob.php lines 13-58.)
Webhook Flow
Uber Eats sends webhook (POST /api/webhook/uber-eats)
|
verify.uber-eats-webhook middleware (X-Uber-Signature HMAC)
|
ProcessUberEatsWebhookJob on the "webhooks" queue (event-ID de-duplication)
|
UberEatsWebhookService.handleEvent()
|
Resolve integration: webhook/integrator identifier -> location -> store ID
|
Route by event_type -> dispatch a job, or update the integration recordIntegration Matching
The integration is resolved in three steps, first match wins. (Verified: app/Services/ThirdParty/UberEatsWebhookService.php lines 125-253.)
- Integrator / webhook identifier. Read from
meta.integrator_store_id, a top-levelintegrator_store_id, apartner_identifiersentry of typeINTEGRATOR_STORE_ID, or the store ID when it happens to look like a Mongo ObjectId. Matched againstsettings.webhook_identifier, then against the integration_id. - Location. Read from
meta.location_id, or frommerchant_store_id/partner_store_id(inmetaor top-level) or aMERCHANT_STORE_IDpartner identifier, with theupvendo_prefix stripped. Matched againstlocation_id. - Uber store ID. Read from
meta.user_id,meta.store_id, or a top-levelstore_id. Matched againstcredentials.store_id, preferring an active integration and falling back to any.
Payloads carrying none of these identifiers, and payloads matching no integration, are logged as errors and dropped.
Event Types
| Event Type | Handling |
|---|---|
orders.notification | ProcessUberEatsOrderNotificationJob -- new order (see below) |
orders.release | ProcessUberEatsOrderNotificationJob -- same handling as orders.notification |
orders.customer_order_edit | ProcessUberEatsOrderNotificationJob -- re-fetches and updates the existing Transaction |
order.fulfillment_issues.resolved | ProcessUberEatsOrderNotificationJob |
orders.fulfillment_issues.resolved | ProcessUberEatsOrderNotificationJob |
orders.scheduled.notification | ProcessUberEatsScheduledNotificationJob -- creates/updates the Transaction and accepts on Uber. No KDS/POS forwarding (that happens on the new-order event). |
orders.cancel | ProcessUberEatsCancelNotificationJob -- Transaction order_status -> Cancelled, items marked done |
orders.failure | ProcessUberEatsCancelNotificationJob -- same cancellation handling |
store.provisioned | Sets is_active = true, settings.connection_status = 'connected', stores the payload on settings.last_provider_event |
store.deprovisioned | Sets is_active = false, settings.connection_status = 'deprovisioned', stores the payload, logs the deprovision. The integration stops receiving orders until the store is re-selected. |
store.status.changed | Stores meta.status (or top-level status, else unknown) on settings.remote_store_status |
delivery.state_changed | Stores the payload on settings.last_delivery_event |
any other event whose type contains menu or notification | Stamps settings.menu_refresh_requested_at with the current time and stores the payload |
| anything else | Logged as Unhandled Uber Eats webhook event type and dropped |
(Verified: app/Services/ThirdParty/UberEatsWebhookService.php lines 58-121.)
Order jobs are asynchronous. Each uses a unique lock (UniqueJobTrait) keyed on the Uber order ID (meta.resource_id, 5-minute lock timeout) to reduce duplicate processing. The store/delivery/menu events are applied inline on the webhook job.
remote_health in the status response surfaces two of these: store_status from settings.remote_store_status and last_event_at from settings.last_provider_event.event_time. (Verified: app/RawModels/UberEatsIntegration.php lines 145-148.)
Order Processing Details (new-order events)
When a new-order webhook is processed:
- The integration is re-loaded by
_id(the integration record ID dispatched with the job) and the tenant DB is set. - The full order is fetched:
GET /v2/eats/order/{orderId}(scopeseats.order,eats.store.orders.read); the response body is the order. - A Transaction is created or updated via
UberEatsTransactionService::createOrUpdateTransaction()(matched onexternal_ids.uber_eats). If the order fails validation or maps to items that do not exist locally, the order is denied on Uber (POST /v1/delivery/order/{orderId}/deny, reason typeITEM_ISSUE,client_error_code: INVALID_ORDER) and processing stops -- no transaction, no KDS rows. - KDS items are created via
KitchenDisplayService::storeTransactionItems(). - The order is accepted on Uber (
POST /v1/delivery/order/{orderId}/accept,Idempotency-Key: accept-{orderId}) -- deliberately after the local transaction and KDS rows are durable. sent_atis computed viacalculateAndSetSentAt()from the location's online-ordering settings and the integration'spos_send_timing/pos_lead_minutes.- The order is forwarded to the in-house POS via
PaymentCaptureService::processOrderJob().
(Verified: app/Jobs/UberEats/ProcessUberEatsOrderNotificationJob.php lines 71-145.)
Items are matched to Upvendo items by ID from cart.items[], modifiers from selected_modifier_groups[].selected_items[]. Item price comes from the Uber payload (price.unit_price), falling back to the Upvendo Uber Eats channel price (Item::getPrice('Uber Eats')). Totals are read from payment.charges (total, sub_total, tax, total_fee, delivery_fee, tip, total_promo_applied); money values are accepted either as amount_e5 (divided by 100000) or as integer-cent amount. The transaction is stored with order_status: Queued, status: Complete (Uber handles payment), order_channel: "Uber Eats", payment_provider: uber_eats, and idempotency_key: uber_eats_{orderId}. (Verified: app/Services/UberEats/UberEatsTransactionService.php lines 114-165, 318-354, 412-441, 476-500, 627-650.)
Order-validation rejections. An order is rejected (and denied on Uber) when: the order has no id; cart.items is empty or contains a non-object; any item carries non-empty special_instructions; payment.charges.total is missing or negative; or an item, modifier group or modifier ID has no local match. (Verified: app/Services/UberEats/UberEatsTransactionService.php lines 653-677, 327-336, 417-438.)
Order Cancellation (orders.cancel)
- The Transaction is found by
external_ids.uber_eats. - Its
order_statusis set to Cancelled. - All associated TransactionItems are marked
is_done: true.
(Nothing is deleted; the transaction is retained in a Cancelled state.)
Actions
All back-office endpoints are under /api/back-office/uber-eats/{locationId} and require back-office auth (admin-vendor-override) plus the location-owner cross-tenant guard. Write actions additionally require the manage-integration-settings permission (marked P below). The callback and webhook are guest routes. (Verified: routes/api/backoffice/uber-eats.php lines 7-44; routes/api/guest.php lines 58 and 69-70; app/Constants/Permissions.php line 391.)
Connection and menu push
| Action | Method | Endpoint | Description |
|---|---|---|---|
| Get Status | GET | /back-office/uber-eats/{locationId} | Returns integration status for the location (or {is_active:false, has_oauth:false} if none) |
| Connect (start OAuth) | POST P | /back-office/uber-eats/{locationId} | Creates the record and returns { oauth_url, message }. Does not link a store. |
| Select Store | POST P | /back-office/uber-eats/{locationId}/select-store | Links the chosen store on Uber, then activates the integration |
| Disconnect | DELETE P | /back-office/uber-eats/{locationId} | Unlinks the store on Uber (best-effort) and deletes the integration record |
| Sync Menu | POST | /back-office/uber-eats/{locationId}/sync-menu | Pushes selected menus to Uber Eats |
| Store List | GET | /back-office/uber-eats/{locationId}/store-list | Lists available Uber Eats stores (paginated) |
| Update Settings | PUT P | /back-office/uber-eats/{locationId}/settings | Updates pos_send_timing / pos_lead_minutes; returns the refreshed status payload |
| Get Configuration | GET | /back-office/uber-eats/{locationId}/configuration | Reads the store's pos_data configuration back from Uber |
Store operations (capability store_read / store_write)
| Action | Method | Endpoint | Uber call |
|---|---|---|---|
| Store Details | GET | .../store | GET /v1/delivery/store/{storeId} |
| Store Status | GET | .../store/status | GET /v1/delivery/store/{storeId}/status |
| Set Store Status | PUT P | .../store/status | POST /v1/delivery/store/{storeId}/status |
| Set Prep Time | PUT P | .../store/prep-time | POST /v1/delivery/store/{storeId}/prep-time |
| Set Fulfillment | PUT P | .../store/fulfillment | PATCH /v1/delivery/store/{storeId}/fulfillment |
Remote menu (capability menu)
| Action | Method | Endpoint | Uber call |
|---|---|---|---|
| Read Remote Menu | GET | .../menu | GET /v2/eats/stores/{storeId}/menus |
| Update Remote Item | PATCH | .../menu/items/{itemId} | POST /v2/eats/stores/{storeId}/menus/items/{itemId} -- price and/or 86-ing |
Orders (capability orders_read / order_actions)
| Action | Method | Endpoint | Description |
|---|---|---|---|
| List Orders | GET | .../orders | GET /v1/delivery/store/{storeId}/orders |
| Get Order | GET | .../orders/{orderId} | GET /v2/eats/order/{orderId} |
| Order Action | POST | .../orders/{orderId}/{action} | action is restricted to accept, deny, cancel, ready, ready-time, adjust-price, validate-fulfillment, resolve-fulfillment, replacement-recommendations, courier-count, merchant-delivery-status |
Promotions (capability promotions) and reports (capability reports)
| Action | Method | Endpoint |
|---|---|---|
| List Promotions | GET | .../promotions |
| Create Promotion | POST P | .../promotions |
| Get Promotion | GET | .../promotions/{promotionId} |
| Revoke Promotion | DELETE P | .../promotions/{promotionId} |
| Request Report | POST P | .../reports |
| Get Report | GET | .../reports/{reportId} |
| Download Report | GET | .../reports/{reportId}/download |
Guest routes
| Action | Method | Endpoint | Description |
|---|---|---|---|
| OAuth Callback | GET | /uber-eats/callback | Handles Uber's OAuth redirect (guest route) |
| Webhook | POST | /webhook/uber-eats | Receives Uber webhooks (guest route, signature-verified) |
Changing the linked store does not require disconnecting: re-running Select Store overwrites
credentials.store_idand re-provisions on Uber. Disconnect is only needed when you want the integration record removed. (Verified:app/Services/BackOffice/UberEatsService.phplines 166-196.)
Order-action eligibility
Before performing an order action, Upvendo fetches the order and checks action_eligibility[{action}] (dashes converted to underscores). If Uber reports the action as ineligible, the call fails with 409 and reason: action_not_eligible before anything is sent. (Verified: app/Services/UberEats/UberEatsMarketplaceService.php lines 104-113.)
Report download
Downloads are only served when the report status is completed and it carries a download_url / url; otherwise 409 report_not_ready. The URL's host must be in UBER_EATS_REPORT_DOWNLOAD_HOSTS, otherwise 502 untrusted_report_host. The file is streamed as uber-eats-report-{reportId}.csv (or .zip when file_type is zip). Each requested report is also appended to settings.reports (last 100). (Verified: app/Services/UberEats/UberEatsMarketplaceService.php lines 181-248.)
Fields
Connect Request (POST /back-office/uber-eats/{locationId})
| Field | Type | Required | Description |
|---|---|---|---|
store_id | string | No (nullable) | Accepted by the validator but ignored by the connect handler -- connect only starts OAuth. |
is_sandbox | boolean | No | Accepted by the validator but ignored -- the controller uses config('services.uber_eats.sandbox') instead. |
Select Store Request (POST .../select-store)
| Field | Type | Required | Description |
|---|---|---|---|
store_id | string | Yes | The Uber Eats store ID to link. |
store_name | string, nullable | No | Display name for the store; persisted as settings.selected_store_name. |
is_sandbox | boolean | No | Accepted by the validator but ignored -- the controller uses config('services.uber_eats.sandbox') instead. |
(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/SelectUberEatsStoreRequest.php lines 17-21.)
Sync Menu Request (POST .../sync-menu)
| Field | Type | Required | Description |
|---|---|---|---|
menu_ids | array of strings | Yes | Upvendo menu IDs to sync (each must exist). |
Store List Request (GET .../store-list)
| Field | Type | Required | Description |
|---|---|---|---|
page_key | string | No | Pagination token for the next page of stores. |
Update Settings Request (PUT .../settings)
| Field | Type | Required | Description |
|---|---|---|---|
pos_send_timing | string | Yes | One of location_default, immediate, scheduled. |
pos_lead_minutes | integer | Yes | 0-1440. Minutes before the pickup/delivery time to send the order to the POS; used only when pos_send_timing is scheduled. |
The submitted values are merged into the existing settings object, so unrelated settings keys are preserved. Unlike the other marketplace endpoints, this one does not require the integration to be active. (Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UpdateUberEatsSettingsRequest.php lines 13-16; app/Services/UberEats/UberEatsMarketplaceService.php lines 29-39.)
Store Operation Request (PUT .../store/status, .../store/prep-time, .../store/fulfillment)
| Field | Type | Required | Description |
|---|---|---|---|
status | string | For set-status | One of ONLINE, OFFLINE, PAUSED. |
reason | string, nullable | No | Max 500 characters. |
pause_until | date, nullable | No | When a pause should end. |
prep_time_minutes | integer | For prep-time | 0-240. |
fulfillment | array | For fulfillment | Fulfillment configuration passed through to Uber. |
(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsStoreOperationRequest.php lines 13-19.)
Remote Menu Item Request (PATCH .../menu/items/{itemId})
| Field | Type | Required | Description |
|---|---|---|---|
price_info | array | No | Price override for the remote item. |
price_info.price | integer | With price_info | Price in the smallest currency unit, minimum 0. |
suspension_info | array | No | Availability override. |
suspension_info.suspension | string | No | One of OUT_OF_STOCK_TODAY, OUT_OF_STOCK_INDEFINITELY, AVAILABLE. |
(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsMenuItemRequest.php lines 13-18.)
Order Action Request (POST .../orders/{orderId}/{action})
Required fields depend on the action.
| Field | Type | Required for | Description |
|---|---|---|---|
reason | array (type, info) | deny, cancel | reason.type max 100 chars, reason.info max 1000 chars. |
ready_for_pickup_time | date | ready-time | New ready-for-pickup time. |
amount_e5 | integer | adjust-price | Adjustment amount in Uber's amount_e5 units. |
price_reason | string | adjust-price | One of REQUESTED_ADD_ONS, BIGGER_SIZE, NEW_ITEM_ADDED, ITEM_SOLD_OUT, REMOVED_ITEM, ADD_ON_UNAVAILABLE, OTHER. |
custom_reason | string | adjust-price with price_reason: OTHER | Max 500 chars. |
tax_rate | numeric | No | 0-100. |
issue_type | string | validate-fulfillment | One of OUT_OF_ITEM, PARTIAL_AVAILABILITY, FOUND_ITEM. |
item | array | validate-fulfillment | The item being validated. |
fulfillment_issues | array (min 1) | resolve-fulfillment | Issues to resolve. |
id | string | replacement-recommendations | Max 255 chars. |
delivery_partner_count | integer | courier-count | 1-5. |
status | string | merchant-delivery-status | Max 100 chars. |
external_reference_id, accepted_by, order_pickup_instructions, tracking_url | string | No | Optional pass-through fields. |
(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsOrderActionRequest.php lines 13-34.)
Orders / Promotions List Request (GET .../orders, GET .../promotions)
| Field | Type | Required | Description |
|---|---|---|---|
state, status | string | No | Max 200 chars each. |
start_time, end_time | date | No | end_time must be after start_time. |
next_page_token | string | No | Max 1000 chars. |
page_size | integer | No | 1-50. |
expand | string | No | Max 100 chars. |
(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsOrdersRequest.php lines 12-20.)
Create Promotion Request (POST .../promotions)
| Field | Type | Required | Description |
|---|---|---|---|
external_promotion_id | string | Yes | Max 255 chars. |
promotion_type | string | Yes | Max 100 chars. |
start_time / end_time | date | Yes | end_time must be after start_time. |
discount | array | Yes | Discount definition passed through to Uber. |
items | array | No | Items the promotion applies to. |
minimum_basket_size | integer | No | Minimum 0. |
(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsPromotionRequest.php lines 12-20.)
Request Report (POST .../reports)
| Field | Type | Required | Description |
|---|---|---|---|
report_type | string | Yes | Max 100 chars. |
start_date / end_date | date | Yes | end_date must be on or after start_date. |
store_uuids | array of strings | No | Max 50. Defaults to the integration's linked store. |
(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsReportRequest.php lines 12-18.)
Business Rules
- Connect then select: Connecting only starts OAuth and returns a URL; the store is linked in a separate Select Store step that calls Uber's
pos_dataendpoint and enables webhooks. - OAuth required before selecting a store: Selecting a store throws if OAuth credentials (a refresh token) are not present. (Verified:
app/Services/BackOffice/UberEatsService.phplines 162-164.) - One integration per location: A location has a single
uber_eatsThirdPartyIntegrationrecord; connecting again reuses the existing record rather than creating a duplicate. (Verified:app/Services/BackOffice/UberEatsService.phplines 104-107.) - Disable removes the link and record: Disconnecting calls
DELETE /v1/eats/stores/{storeId}/pos_data(best-effort -- failures are logged and ignored), then deletes the local record. - Activation is committed last: Select Store saves the mapping with
is_active = false, provisions on Uber, and only then setsis_active = true+settings.connection_status = 'connected'+settings.connected_at. A failed provision therefore leaves a saved-but-inactive record that can be retried by re-selecting the store. (Verified:app/Services/BackOffice/UberEatsService.phplines 166-196.) - Settings are updatable:
PUT .../settingsupdatespos_send_timingandpos_lead_minutes. There are also PUTs for store status, prep time and fulfillment, and a PATCH for individual remote menu items -- changing the linked store is done by re-running Select Store, not by disconnecting. (Verified:routes/api/backoffice/uber-eats.phplines 18-29.) - Auto-acceptance, with an auto-deny path: New orders are accepted automatically --
require_manual_acceptance: falseat provisioning, and the order/scheduled jobs call Uber's accept endpoint after the local transaction is stored. Orders that fail local validation are denied on Uber instead (ITEM_ISSUE/INVALID_ORDER). The backend also exposes explicit per-order actions (includingacceptanddeny) for locations that want to drive the lifecycle manually. (Verified:app/Jobs/UberEats/ProcessUberEatsOrderNotificationJob.phplines 101-123;routes/api/backoffice/uber-eats.phplines 32-33.) - Payment is external: Uber Eats handles all payments; transactions are stored as already paid (
status: Complete). - OAuth state signing: The OAuth
stateis HMAC-SHA256 signed withUBER_EATS_CLIENT_SECRET(not the webhook secret) and carriesvendor_id,location_id,iat,expand a single-use cachednonce. Replayed or expired states are rejected with 401. (Verified:app/Services/BackOffice/UberEatsService.phplines 549-600.) - Token handling: Client-credentials tokens are cached per scope-set under
uber_eats.token.{sha1(scopes)}until 5 minutes before expiry. The user (authorization-code) token is stored per integration and auto-refreshed via the refresh token when expired; a missing refresh token raisesreauthorization_required(409). (Verified:app/Services/UberEats/UberEatsApiClient.phplines 39-62 and 121-149.) - Sandbox comes from the environment:
is_sandboxin the request body is ignored; the controller readsconfig('services.uber_eats.sandbox')(UBER_EATS_SANDBOX, defaulttrue) on both connect and select-store, stores it on the record, and the API client uses it to picksandbox_api_base_urlvsapi_base_url. (Verified:app/Http/Controllers/Api/BackOffice/UberEatsIntegrationController.phplines 61 and 85;app/Services/UberEats/UberEatsApiClient.phplines 183-185.) - Capability gate: Store, menu, order, promotion and report calls check the integration's capability map first and return 409 with
reason=scope_not_approved,merchant_delivery_not_enabled,capability_unknown, orcapability_unavailablewhen the feature is not open to that location. (Verified:app/Services/UberEats/UberEatsMarketplaceService.phplines 266-275;app/RawModels/UberEatsIntegration.phplines 157-185.) - Connected-integration preconditions: Every marketplace call except Update Settings requires an active integration (409
integration_not_connected) with a store selected (409store_not_selected). (Verified:app/Services/UberEats/UberEatsMarketplaceService.phplines 250-264.) - Uber can switch the integration off: A
store.deprovisionedwebhook setsis_active = falseandsettings.connection_status = 'deprovisioned'. The record and itsmenu_idssurvive; re-selecting the store re-provisions and reactivates it. (Verified:app/Services/ThirdParty/UberEatsWebhookService.phplines 81-93.) - Retry and idempotency: Uber API calls retry up to 4 times on 408/429/500/502/503/504 and connection failures, honouring
Retry-After(capped at 5s) -- but only for GET/HEAD/PUT/DELETE or requests carrying anIdempotency-Key. (Verified:app/Services/UberEats/UberEatsApiClient.phplines 16, 83-104, 196-213.)
Order Flow
Customer orders on Uber Eats
|
Uber Eats sends a new-order webhook to POST /api/webhook/uber-eats
|
Signature verified -> ProcessUberEatsWebhookJob (webhooks queue, de-duped on event ID)
|
UberEatsWebhookService resolves the integration
(webhook identifier -> location -> store ID)
|
ProcessUberEatsOrderNotificationJob (queued, unique on order ID)
|
Full order fetched (GET /v2/eats/order/{orderId})
|
Transaction created --- validation fails? -> order DENIED on Uber, stop
|
KDS items created
|
Order accepted on Uber (POST /v1/delivery/order/{orderId}/accept)
|
sent_at computed (pos_send_timing / pos_lead_minutes)
|
Forwarded to in-house POS (PaymentCaptureService::processOrderJob)
|
Order appears in Orders (Uber Eats channel) -> kitchen prepares -> driver picks upNote: Payment is handled by Uber Eats. You receive payouts from Uber directly.
POS (In-House) Forwarding
The deep POS-forwarding routing is shared with other channels and not fully verified here beyond the entry point.
After a Transaction is created from an Uber order, the job calls PaymentCaptureService::processOrderJob($transaction). The actual POS routing happens inside ProcessOrderJob and depends on the transaction's item_label and the merchant's POS configuration (Kassanet / ShopCaisse / MplusKassa / or none). The item_label is resolved from the first matched Upvendo item, and item snapshots are built from canonical Item/ModifierGroup/Modifier snapshots so the external IDs needed for POS mapping flow through.
Menu Management
Syncing Menu
- Push changes from Upvendo to Uber Eats via Uber Eats -> Sync Menu
- The sync is a full menu replacement (PUT)
- Editing a menu that has already been synced re-pushes the whole synced set automatically in the background
- Review changes before syncing
Item Availability
- Menu-level availability is expressed through the menu schedule (
service_availability) pushed during a menu sync. - Individual items can also be suspended or restored on Uber without a full re-sync, via
PATCH /back-office/uber-eats/{locationId}/menu/items/{itemId}withsuspension_info.suspensionset toOUT_OF_STOCK_TODAY,OUT_OF_STOCK_INDEFINITELY, orAVAILABLE. The same call can overrideprice_info.price. This is a backend endpoint (capabilitymenu); the Uber Eats page in the back office does not currently expose a control for it. (Verified:routes/api/backoffice/uber-eats.phpline 29;app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsMenuItemRequest.phplines 13-18;app/Services/UberEats/UberEatsMarketplaceService.phplines 63-75.)
FAQs
Why am I redirected to Uber when connecting?
Connecting starts an Uber OAuth authorization-code flow so Upvendo can manage your store(s). After authorizing, you return to Upvendo to pick the store.
How do I know which store to select?
After OAuth, the store list is fetched from your Uber account and shown as a dropdown (store name + ID). Match the store to the Upvendo location.
Can I sync multiple menus at once?
Yes. Sync Menu accepts an array of menu IDs; all selected menus are combined into one payload.
Are orders automatically accepted?
Yes, by default -- both via provisioning (require_manual_acceptance: false) and the order job calling Uber's accept endpoint once the local transaction and KDS rows are stored. Orders that cannot be mapped locally are automatically denied instead. The backend also exposes explicit accept / deny / cancel order actions for callers that want to drive the lifecycle themselves.
Why was an incoming order rejected automatically?
Upvendo denies an order on Uber (ITEM_ISSUE / INVALID_ORDER) when it fails local validation: no order ID, an empty cart, an invalid total, free-text item instructions, or an item / modifier group / modifier that does not exist in Upvendo. Re-sync the menu so Uber's copy matches Upvendo, then ask the customer to reorder.
What happens if I disconnect?
Disconnecting unlinks the store on Uber (best-effort) and deletes the integration record. You stop receiving orders for that location.
Why did my integration turn itself off?
Uber sent a store.deprovisioned webhook for the linked store. Upvendo sets is_active = false and settings.connection_status = 'deprovisioned' but keeps the record and its synced menu_ids. Re-select the store to re-provision and reactivate.
How do I change the linked store or the settings?
Re-run Select Store to point the location at a different Uber store -- disconnecting is not required. Integration settings (pos_send_timing, pos_lead_minutes) are updated with PUT /back-office/uber-eats/{locationId}/settings; store status, prep time and fulfillment have their own PUT endpoints.
Why does an Uber Eats call return 409 "This Uber Eats capability is unavailable"?
The location's approved Uber scopes do not cover that feature. Check the capabilities map in the status response: scope_not_approved means the scope is missing (promotions and reports are outside the default scope list), and merchant_delivery_not_enabled means settings.merchant_delivery_enabled is not set. Other 409s from these endpoints are integration_not_connected, store_not_selected, action_not_eligible, and report_not_ready.
What's the difference between the two access tokens?
- User access token (authorization-code flow, scope
eats.pos_provisioning): store list + store linking/unlinking. Requires merchant login; stored per integration and refreshed via the refresh token. - App access token (client-credentials flow): menu sync, marketplace calls, and order fetch/accept. App-wide; cached per scope-set, no user interaction.
Troubleshooting
Orders not coming through
- Confirm the connection status is
connected(notnot_connected,pending_store_selection,provisioning_failed, ordeprovisioned) - Verify the restaurant is online in Uber Eats Manager;
remote_health.store_statusreflects the laststore.status.changedevent Uber sent - Ensure
POST /api/webhook/uber-eatsis reachable andverify.uber-eats-webhookisn't rejecting requests - Confirm
order_release_webhookswas enabled at provisioning (re-select the store to re-provision if needed) - If webhook logs show "No integration found for Uber Eats webhook payload", the payload's identifiers don't match: check
settings.webhook_identifier(should equal the integration_id), theupvendo_{locationId}merchant store ID, andcredentials.store_id - Check the Activity Log for errors
Integration is inactive / "turned itself off"
connection_status: deprovisioned-- Uber sentstore.deprovisioned; the store link was ended on Uber's side. Re-select the store to re-provision.connection_status: provisioning_failed-- store selection saved but Uber'spos_datacall failed. Re-confirm the store; the record is intentionally left restartable.- Either way the integration record and its
menu_idsare preserved -- disconnecting is not required.
"Integration not found" / "OAuth not completed" on store select
- The OAuth flow has not completed (no refresh token). Connect first, then select a store.
OAuth redirect fails or loops
- Ensure
UBER_EATS_REDIRECT_URImatches the Uber app settings - The state signature,
exp, and single-usenoncemust all validate; if any fails the callback returns 401 ("The Uber Eats authorization session is invalid or expired") - A state older than
UBER_EATS_OAUTH_STATE_TTL(default 600s), or one that was already used, is rejected -- restart from Connect rather than re-opening an old redirect - Confirm the vendor/location in the state matches the request
409 "This Uber Eats capability is unavailable"
- The location's approved Uber scopes don't cover the feature. Inspect the
capabilitiesmap on the status response. scope_not_approved-- the mapped scope isn't in the approved list. Promotions needeats.store.promotionsand reports needeats.report; neither is in the defaultUBER_EATS_SCOPES.merchant_delivery_not_enabled--settings.merchant_delivery_enabledis nottruefor the location.capability_unknown-- the capability name isn't present in the status payload at all.
Other 409s from marketplace endpoints
integration_not_connected-- no activeuber_eatsintegration for the locationstore_not_selected-- integration is active butcredentials.store_idis emptyaction_not_eligible-- Uber'saction_eligibilityon the order says the requested action can't be performed right nowreport_not_ready-- the report status isn'tcompletedyet, or it has no download URLreauthorization_required-- the stored user token expired and there is no refresh token; reconnect via OAuth
Menu sync fails
- Verify a store is linked and active
- Check items have valid names, prices, and tax rates
- Uber may reject invalid modifier quantity constraints
- Review the sync error message recorded on the integration (
sync_status.message)
Orders showing wrong item prices
- Incoming orders use the unit price Uber sends on the order, falling back to the Upvendo Uber Eats channel price (
Item::getPrice('Uber Eats')) when Uber sends none - Set the Uber Eats price on items if it differs from the default, then re-sync the menu so Uber charges the price you expect
Orders reaching the POS at the wrong time
- Check the integration's
pos_send_timing:location_defaultfollows the location's online-ordering rules,immediatesends on acceptance,scheduledsendspos_lead_minutesbefore the pickup/delivery time pos_lead_minutesis clamped to 0-1440 and only applies whenpos_send_timingisscheduled- Update both with
PUT /back-office/uber-eats/{locationId}/settings
Assistant Guidance
When helping users with the Uber Eats integration:
- Connecting only starts OAuth -- the store must be linked in a separate Select Store step before orders flow
- Menu sync runs on demand from the Sync Menu button, and also automatically when a menu that has already been synced is edited. A menu that has never been synced will not auto-push.
- To change the linked store, re-run Select Store. Disconnecting is only for removing the integration entirely.
- Integration settings are updatable (
pos_send_timing,pos_lead_minutes), as are store status, prep time and fulfillment -- but these are backend endpoints today, so describe them as capabilities rather than telling the merchant to click something on the Uber Eats page. That page currently renders only Connect, Select Store, and Sync Menu. - Sandbox mode is an environment setting (
UBER_EATS_SANDBOX), not a merchant-facing toggle. It is not chosen from the request or the UI. - New orders are auto-accepted by default. Do not promise a manual-accept toggle on the Uber Eats page, but do not claim manual acceptance is impossible either -- the backend exposes
accept/deny/canceland other per-order actions. - Orders that can't be mapped to local items are auto-denied on Uber. If a merchant reports Uber orders vanishing or being rejected, check for menu drift and suggest a re-sync.
- If a merchant says the integration "turned itself off", check for a
store.deprovisionedevent (connection_status: deprovisioned) or a failed provision (provisioning_failed) before suggesting a reconnect -- in both cases re-selecting the store is the fix and nothing is lost. - Connection status values are
not_connected,pending_store_selection,connected,provisioning_failed, or a stored value such asdeprovisioned.readyis not a connection status -- it is only a back-office sync-status label. (Verified:app/RawModels/UberEatsIntegration.phplines 97-111;upvendo-backoffice/src/views/uber-eats/index.vuelines 235 and 245.) - Feature availability depends on approved Uber scopes. If a marketplace call fails with 409 and a
reason, explain the capability map rather than suggesting a reconnect. - Uber Eats payments are fully external -- do not suggest configuring payment profiles
- If orders are missing, check the connection status, webhook reachability, the webhook identifier fields, and the Activity Log