Skip to content

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

ConceptDescription
Store IDUber 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 TokenObtained 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 TokenObtained via the client-credentials flow. Used for menu sync, order fetching, and order acceptance. Cached app-wide under uber_eats.access_token.
POS ProvisioningLinking an Upvendo location to an Uber Eats store via the /v1/eats/stores/{storeId}/pos_data endpoint, which also enables webhooks.
ThirdPartyIntegration recordThe single record storing credentials, settings, sync status, and menu_ids for the location. Provider is uber_eats. Modeled by UberEatsIntegration (extends ThirdPartyIntegration).
Integrator Brand IDSet to upvendo_brand during POS provisioning; identifies Upvendo as the integrator.
Integrator Store IDThe 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.)
CapabilityA 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-location ThirdPartyIntegration record. There is no separate "credential" record.

settings.selected_store_name is persisted — store_name is 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 as null. (Verified: app/Http/Requests/BackOffice/UberEatsIntegration/SelectUberEatsStoreRequest.php lines 17-21; app/Services/BackOffice/UberEatsService.php line 175; upvendo-backoffice/src/store/modules/uberEats.ts lines 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 ID
  • UBER_EATS_CLIENT_SECRET -- OAuth application client secret
  • UBER_EATS_REDIRECT_URI -- Redirect URI for the OAuth callback
  • UBER_EATS_WEBHOOK_SECRET -- Shared secret used for webhook signature verification and to HMAC-sign the OAuth state parameter
  • UBER_EATS_SETUP_URL -- Where the OAuth callback redirects the merchant after success
  • UBER_EATS_SANDBOX -- Defaults to true. Read by the back-office controller on both connect and select-store and stored as the integration's is_sandbox, which selects the sandbox or production API base URL for every subsequent call. It must be set to false for real merchants.
  • UBER_EATS_SCOPES -- Space-separated approved scopes; defaults to eats.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 (default https://auth.uber.com/oauth/v2), UBER_EATS_API_BASE_URL (default https://api.uber.com), UBER_EATS_SANDBOX_API_BASE_URL (default https://sandbox-api.uber.com)
  • UBER_EATS_CONNECT_TIMEOUT (default 10s), UBER_EATS_TIMEOUT (default 30s)
  • UBER_EATS_OAUTH_STATE_TTL -- Lifetime of the signed OAuth state, in seconds (default 600)
  • UBER_EATS_REPORT_DOWNLOAD_HOSTS -- Comma-separated host allow-list for report downloads (default api.uber.com,sandbox-api.uber.com)

(Verified: config/services.php lines 178-196.)

3. Location Configuration

  1. Go to Settings -> Locations
  2. Ensure the location address matches the Uber Eats listing
  3. Configure business hours / availability to match Uber Eats

4. Menu Setup

  1. Menu items should exist in Upvendo first
  2. Prices can differ from in-store prices (set the Uber Eats channel price on items)
  3. Ensure items have correct tax rates assigned (the delivery tax rate is used)
  4. Free-text item instructions are disabled in the Uber Eats menu payload (disable_item_instructions: true)

Setup Steps

Step 1: Connect (OAuth authorization)

  1. Go to the Uber Eats page (back-office route /uber-eats, listed under the Online channels in the sidebar)
  2. Click Connect to Uber Eats
  3. The system creates a ThirdPartyIntegration record (inactive) and returns an Uber OAuth URL
  4. The browser is redirected to https://auth.uber.com/oauth/v2/authorize with scope eats.pos_provisioning
  5. Log in to Uber and authorize Upvendo; Uber redirects to Upvendo's callback
  6. 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} calls startOAuth, which creates the record and builds the OAuth URL. The OAuth state is a base64url-encoded JSON { payload: { vendor_id, location_id, iat, exp, nonce }, signature }, where signature is hash_hmac('sha256', payload, client_secret). The nonce is also cached server-side and consumed on callback, so a state can be replayed only once and expires after UBER_EATS_OAUTH_STATE_TTL seconds. (Verified: app/Services/BackOffice/UberEatsService.php lines 549-600.)
  • The OAuth callback (GET /uber-eats/callback, a guest route handled by ThirdPartyAuthController::handleUberEatsCallback) verifies the signature, expiry and nonce, then exchanges the code via POST {auth_base_url}/token (grant_type=authorization_code, scope eats.pos_provisioning). An invalid or expired state returns 401; a missing code/state returns 422; a user-denied authorization returns 400. (Verified: app/Http/Controllers/Api/ThirdPartyAuthController.php lines 102-123.)
  • Tokens are saved to credentials.user_access_token, credentials.refresh_token, credentials.expiry; the callback then redirects the merchant to UBER_EATS_SETUP_URL.
  1. Back on the Uber Eats page, the store dropdown is populated from your Uber account
  2. Pick a Store and confirm

What happens behind the scenes:

  • POST /uber-eats/{locationId}/select-store first saves the mapping in a restartable, inactive state: credentials.store_id, settings.selected_store_id, settings.selected_store_name (from the optional store_name field), settings.webhook_identifier (the integration record ID), is_active = false, and sync_status.message: "Provisioning Uber Eats store".
  • It then reloads the integration and calls linkLocationToStore() -- POST /v1/eats/stores/{storeId}/pos_data (user access token, scope eats.pos_provisioning, Idempotency-Key: provision-{integrationId}) with:
    • integrator_brand_id: upvendo_brand
    • integrator_store_id: {integrationId}, merchant_store_id: upvendo_{locationId}
    • is_order_manager: true, require_manual_acceptance: false
    • allowed_customer_requests: single-use items and special instructions both false
    • webhooks_config: order_release_webhooks, schedule_order_webhooks and delivery_status_webhooks all enabled, webhooks_version: "1.0"
  • Only after Uber confirms provisioning does Upvendo commit activation: is_active = true, settings.connection_status = 'connected', settings.connected_at, and sync_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_sandbox is accepted in the request validators but ignored: the controller passes config('services.uber_eats.sandbox') for both enable and select-store, so sandbox mode is decided by the UBER_EATS_SANDBOX environment variable (which defaults to true), not by the request or the UI. The stored is_sandbox then selects the sandbox or production API base URL on every subsequent call. (Verified: app/Http/Controllers/Api/BackOffice/UberEatsIntegrationController.php lines 61 and 85; config/services.php line 184; base-URL selection in app/Services/UberEats/UberEatsApiClient.php lines 181-193.)

Step 3: Sync your menu

  1. On the Uber Eats page, select the menus to push
  2. Click Sync Menu
  3. The system transforms your Upvendo menus and PUTs them to Uber Eats

Data Sync Details

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_ids is updated. MenuService::update() fires a MenuUpdated event; the queued SyncMenuWithUberEats listener (auto-discovered, 3 tries) re-syncs the full menu_ids set 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.php lines 29-42; app/Services/BackOffice/MenuService.php line 127; listener discovery in bootstrap/app.php lines 150-152.)

What is synced:

Upvendo EntityUber Eats EntityDetails
MenuMenuTitle, category_ids, service_availability (schedule)
Display Group (Category)CategoryTitle, entities (item references)
ItemItemTitle, description, image URL, price, tax rate, allergen classifications (dish_info.classifications), modifier group references
Modifier GroupModifier GroupTitle, modifier options, group-level quantity constraints
ModifierItem (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, context MODIFIER_GROUP)
  • allow_same_modifier_more_than_one false/absent -> max_permitted: 1 per modifier (override)
  • allow_select_more_than_one with max_selected -> group-level max_permitted: {max_selected}
  • not allow_select_more_than_one -> group-level max_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()).

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}):

SettingValueDescription
integrator_brand_idupvendo_brandIdentifies 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_idupvendo_{locationId}Merchant-side identifier; the upvendo_ prefix is stripped when resolving webhooks back to a location
is_order_managertrueUpvendo manages the order lifecycle
require_manual_acceptancefalseOrders are auto-accepted
webhooks_config.order_release_webhooksenabledReceive new-order notifications
webhooks_config.schedule_order_webhooksenabledReceive scheduled-order notifications
webhooks_config.delivery_status_webhooksenabledReceive delivery state changes (stored on settings.last_delivery_event)
webhooks_config.webhooks_version1.0Webhook payload version
allowed_customer_requests.allow_single_use_items_requestsfalseSingle-use item requests not allowed
allowed_customer_requests.allow_special_instruction_requestsfalseSpecial 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 record

Integration Matching

The integration is resolved in three steps, first match wins. (Verified: app/Services/ThirdParty/UberEatsWebhookService.php lines 125-253.)

  1. Integrator / webhook identifier. Read from meta.integrator_store_id, a top-level integrator_store_id, a partner_identifiers entry of type INTEGRATOR_STORE_ID, or the store ID when it happens to look like a Mongo ObjectId. Matched against settings.webhook_identifier, then against the integration _id.
  2. Location. Read from meta.location_id, or from merchant_store_id / partner_store_id (in meta or top-level) or a MERCHANT_STORE_ID partner identifier, with the upvendo_ prefix stripped. Matched against location_id.
  3. Uber store ID. Read from meta.user_id, meta.store_id, or a top-level store_id. Matched against credentials.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 TypeHandling
orders.notificationProcessUberEatsOrderNotificationJob -- new order (see below)
orders.releaseProcessUberEatsOrderNotificationJob -- same handling as orders.notification
orders.customer_order_editProcessUberEatsOrderNotificationJob -- re-fetches and updates the existing Transaction
order.fulfillment_issues.resolvedProcessUberEatsOrderNotificationJob
orders.fulfillment_issues.resolvedProcessUberEatsOrderNotificationJob
orders.scheduled.notificationProcessUberEatsScheduledNotificationJob -- creates/updates the Transaction and accepts on Uber. No KDS/POS forwarding (that happens on the new-order event).
orders.cancelProcessUberEatsCancelNotificationJob -- Transaction order_status -> Cancelled, items marked done
orders.failureProcessUberEatsCancelNotificationJob -- same cancellation handling
store.provisionedSets is_active = true, settings.connection_status = 'connected', stores the payload on settings.last_provider_event
store.deprovisionedSets 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.changedStores meta.status (or top-level status, else unknown) on settings.remote_store_status
delivery.state_changedStores the payload on settings.last_delivery_event
any other event whose type contains menu or notificationStamps settings.menu_refresh_requested_at with the current time and stores the payload
anything elseLogged 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:

  1. The integration is re-loaded by _id (the integration record ID dispatched with the job) and the tenant DB is set.
  2. The full order is fetched: GET /v2/eats/order/{orderId} (scopes eats.order, eats.store.orders.read); the response body is the order.
  3. A Transaction is created or updated via UberEatsTransactionService::createOrUpdateTransaction() (matched on external_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 type ITEM_ISSUE, client_error_code: INVALID_ORDER) and processing stops -- no transaction, no KDS rows.
  4. KDS items are created via KitchenDisplayService::storeTransactionItems().
  5. 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.
  6. sent_at is computed via calculateAndSetSentAt() from the location's online-ordering settings and the integration's pos_send_timing / pos_lead_minutes.
  7. 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)

  1. The Transaction is found by external_ids.uber_eats.
  2. Its order_status is set to Cancelled.
  3. 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

ActionMethodEndpointDescription
Get StatusGET/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 StorePOST P/back-office/uber-eats/{locationId}/select-storeLinks the chosen store on Uber, then activates the integration
DisconnectDELETE P/back-office/uber-eats/{locationId}Unlinks the store on Uber (best-effort) and deletes the integration record
Sync MenuPOST/back-office/uber-eats/{locationId}/sync-menuPushes selected menus to Uber Eats
Store ListGET/back-office/uber-eats/{locationId}/store-listLists available Uber Eats stores (paginated)
Update SettingsPUT P/back-office/uber-eats/{locationId}/settingsUpdates pos_send_timing / pos_lead_minutes; returns the refreshed status payload
Get ConfigurationGET/back-office/uber-eats/{locationId}/configurationReads the store's pos_data configuration back from Uber

Store operations (capability store_read / store_write)

ActionMethodEndpointUber call
Store DetailsGET.../storeGET /v1/delivery/store/{storeId}
Store StatusGET.../store/statusGET /v1/delivery/store/{storeId}/status
Set Store StatusPUT P.../store/statusPOST /v1/delivery/store/{storeId}/status
Set Prep TimePUT P.../store/prep-timePOST /v1/delivery/store/{storeId}/prep-time
Set FulfillmentPUT P.../store/fulfillmentPATCH /v1/delivery/store/{storeId}/fulfillment

Remote menu (capability menu)

ActionMethodEndpointUber call
Read Remote MenuGET.../menuGET /v2/eats/stores/{storeId}/menus
Update Remote ItemPATCH.../menu/items/{itemId}POST /v2/eats/stores/{storeId}/menus/items/{itemId} -- price and/or 86-ing

Orders (capability orders_read / order_actions)

ActionMethodEndpointDescription
List OrdersGET.../ordersGET /v1/delivery/store/{storeId}/orders
Get OrderGET.../orders/{orderId}GET /v2/eats/order/{orderId}
Order ActionPOST.../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)

ActionMethodEndpoint
List PromotionsGET.../promotions
Create PromotionPOST P.../promotions
Get PromotionGET.../promotions/{promotionId}
Revoke PromotionDELETE P.../promotions/{promotionId}
Request ReportPOST P.../reports
Get ReportGET.../reports/{reportId}
Download ReportGET.../reports/{reportId}/download

Guest routes

ActionMethodEndpointDescription
OAuth CallbackGET/uber-eats/callbackHandles Uber's OAuth redirect (guest route)
WebhookPOST/webhook/uber-eatsReceives Uber webhooks (guest route, signature-verified)

Changing the linked store does not require disconnecting: re-running Select Store overwrites credentials.store_id and re-provisions on Uber. Disconnect is only needed when you want the integration record removed. (Verified: app/Services/BackOffice/UberEatsService.php lines 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})

FieldTypeRequiredDescription
store_idstringNo (nullable)Accepted by the validator but ignored by the connect handler -- connect only starts OAuth.
is_sandboxbooleanNoAccepted by the validator but ignored -- the controller uses config('services.uber_eats.sandbox') instead.

Select Store Request (POST .../select-store)

FieldTypeRequiredDescription
store_idstringYesThe Uber Eats store ID to link.
store_namestring, nullableNoDisplay name for the store; persisted as settings.selected_store_name.
is_sandboxbooleanNoAccepted 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)

FieldTypeRequiredDescription
menu_idsarray of stringsYesUpvendo menu IDs to sync (each must exist).

Store List Request (GET .../store-list)

FieldTypeRequiredDescription
page_keystringNoPagination token for the next page of stores.

Update Settings Request (PUT .../settings)

FieldTypeRequiredDescription
pos_send_timingstringYesOne of location_default, immediate, scheduled.
pos_lead_minutesintegerYes0-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)

FieldTypeRequiredDescription
statusstringFor set-statusOne of ONLINE, OFFLINE, PAUSED.
reasonstring, nullableNoMax 500 characters.
pause_untildate, nullableNoWhen a pause should end.
prep_time_minutesintegerFor prep-time0-240.
fulfillmentarrayFor fulfillmentFulfillment configuration passed through to Uber.

(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsStoreOperationRequest.php lines 13-19.)

Remote Menu Item Request (PATCH .../menu/items/{itemId})

FieldTypeRequiredDescription
price_infoarrayNoPrice override for the remote item.
price_info.priceintegerWith price_infoPrice in the smallest currency unit, minimum 0.
suspension_infoarrayNoAvailability override.
suspension_info.suspensionstringNoOne 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.

FieldTypeRequired forDescription
reasonarray (type, info)deny, cancelreason.type max 100 chars, reason.info max 1000 chars.
ready_for_pickup_timedateready-timeNew ready-for-pickup time.
amount_e5integeradjust-priceAdjustment amount in Uber's amount_e5 units.
price_reasonstringadjust-priceOne of REQUESTED_ADD_ONS, BIGGER_SIZE, NEW_ITEM_ADDED, ITEM_SOLD_OUT, REMOVED_ITEM, ADD_ON_UNAVAILABLE, OTHER.
custom_reasonstringadjust-price with price_reason: OTHERMax 500 chars.
tax_ratenumericNo0-100.
issue_typestringvalidate-fulfillmentOne of OUT_OF_ITEM, PARTIAL_AVAILABILITY, FOUND_ITEM.
itemarrayvalidate-fulfillmentThe item being validated.
fulfillment_issuesarray (min 1)resolve-fulfillmentIssues to resolve.
idstringreplacement-recommendationsMax 255 chars.
delivery_partner_countintegercourier-count1-5.
statusstringmerchant-delivery-statusMax 100 chars.
external_reference_id, accepted_by, order_pickup_instructions, tracking_urlstringNoOptional pass-through fields.

(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsOrderActionRequest.php lines 13-34.)

Orders / Promotions List Request (GET .../orders, GET .../promotions)

FieldTypeRequiredDescription
state, statusstringNoMax 200 chars each.
start_time, end_timedateNoend_time must be after start_time.
next_page_tokenstringNoMax 1000 chars.
page_sizeintegerNo1-50.
expandstringNoMax 100 chars.

(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsOrdersRequest.php lines 12-20.)

Create Promotion Request (POST .../promotions)

FieldTypeRequiredDescription
external_promotion_idstringYesMax 255 chars.
promotion_typestringYesMax 100 chars.
start_time / end_timedateYesend_time must be after start_time.
discountarrayYesDiscount definition passed through to Uber.
itemsarrayNoItems the promotion applies to.
minimum_basket_sizeintegerNoMinimum 0.

(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsPromotionRequest.php lines 12-20.)

Request Report (POST .../reports)

FieldTypeRequiredDescription
report_typestringYesMax 100 chars.
start_date / end_datedateYesend_date must be on or after start_date.
store_uuidsarray of stringsNoMax 50. Defaults to the integration's linked store.

(Verified: app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsReportRequest.php lines 12-18.)


Business Rules

  1. 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_data endpoint and enables webhooks.
  2. OAuth required before selecting a store: Selecting a store throws if OAuth credentials (a refresh token) are not present. (Verified: app/Services/BackOffice/UberEatsService.php lines 162-164.)
  3. One integration per location: A location has a single uber_eats ThirdPartyIntegration record; connecting again reuses the existing record rather than creating a duplicate. (Verified: app/Services/BackOffice/UberEatsService.php lines 104-107.)
  4. 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.
  5. Activation is committed last: Select Store saves the mapping with is_active = false, provisions on Uber, and only then sets is_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.php lines 166-196.)
  6. Settings are updatable: PUT .../settings updates pos_send_timing and pos_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.php lines 18-29.)
  7. Auto-acceptance, with an auto-deny path: New orders are accepted automatically -- require_manual_acceptance: false at 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 (including accept and deny) for locations that want to drive the lifecycle manually. (Verified: app/Jobs/UberEats/ProcessUberEatsOrderNotificationJob.php lines 101-123; routes/api/backoffice/uber-eats.php lines 32-33.)
  8. Payment is external: Uber Eats handles all payments; transactions are stored as already paid (status: Complete).
  9. OAuth state signing: The OAuth state is HMAC-SHA256 signed with UBER_EATS_CLIENT_SECRET (not the webhook secret) and carries vendor_id, location_id, iat, exp and a single-use cached nonce. Replayed or expired states are rejected with 401. (Verified: app/Services/BackOffice/UberEatsService.php lines 549-600.)
  10. 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 raises reauthorization_required (409). (Verified: app/Services/UberEats/UberEatsApiClient.php lines 39-62 and 121-149.)
  11. Sandbox comes from the environment: is_sandbox in the request body is ignored; the controller reads config('services.uber_eats.sandbox') (UBER_EATS_SANDBOX, default true) on both connect and select-store, stores it on the record, and the API client uses it to pick sandbox_api_base_url vs api_base_url. (Verified: app/Http/Controllers/Api/BackOffice/UberEatsIntegrationController.php lines 61 and 85; app/Services/UberEats/UberEatsApiClient.php lines 183-185.)
  12. 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, or capability_unavailable when the feature is not open to that location. (Verified: app/Services/UberEats/UberEatsMarketplaceService.php lines 266-275; app/RawModels/UberEatsIntegration.php lines 157-185.)
  13. Connected-integration preconditions: Every marketplace call except Update Settings requires an active integration (409 integration_not_connected) with a store selected (409 store_not_selected). (Verified: app/Services/UberEats/UberEatsMarketplaceService.php lines 250-264.)
  14. Uber can switch the integration off: A store.deprovisioned webhook sets is_active = false and settings.connection_status = 'deprovisioned'. The record and its menu_ids survive; re-selecting the store re-provisions and reactivates it. (Verified: app/Services/ThirdParty/UberEatsWebhookService.php lines 81-93.)
  15. 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 an Idempotency-Key. (Verified: app/Services/UberEats/UberEatsApiClient.php lines 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 up

Note: 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.


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} with suspension_info.suspension set to OUT_OF_STOCK_TODAY, OUT_OF_STOCK_INDEFINITELY, or AVAILABLE. The same call can override price_info.price. This is a backend endpoint (capability menu); the Uber Eats page in the back office does not currently expose a control for it. (Verified: routes/api/backoffice/uber-eats.php line 29; app/Http/Requests/BackOffice/UberEatsIntegration/UberEatsMenuItemRequest.php lines 13-18; app/Services/UberEats/UberEatsMarketplaceService.php lines 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 (not not_connected, pending_store_selection, provisioning_failed, or deprovisioned)
  • Verify the restaurant is online in Uber Eats Manager; remote_health.store_status reflects the last store.status.changed event Uber sent
  • Ensure POST /api/webhook/uber-eats is reachable and verify.uber-eats-webhook isn't rejecting requests
  • Confirm order_release_webhooks was 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), the upvendo_{locationId} merchant store ID, and credentials.store_id
  • Check the Activity Log for errors

Integration is inactive / "turned itself off"

  • connection_status: deprovisioned -- Uber sent store.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's pos_data call failed. Re-confirm the store; the record is intentionally left restartable.
  • Either way the integration record and its menu_ids are 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_URI matches the Uber app settings
  • The state signature, exp, and single-use nonce must 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 capabilities map on the status response.
  • scope_not_approved -- the mapped scope isn't in the approved list. Promotions need eats.store.promotions and reports need eats.report; neither is in the default UBER_EATS_SCOPES.
  • merchant_delivery_not_enabled -- settings.merchant_delivery_enabled is not true for 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 active uber_eats integration for the location
  • store_not_selected -- integration is active but credentials.store_id is empty
  • action_not_eligible -- Uber's action_eligibility on the order says the requested action can't be performed right now
  • report_not_ready -- the report status isn't completed yet, or it has no download URL
  • reauthorization_required -- the stored user token expired and there is no refresh token; reconnect via OAuth
  • 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_default follows the location's online-ordering rules, immediate sends on acceptance, scheduled sends pos_lead_minutes before the pickup/delivery time
  • pos_lead_minutes is clamped to 0-1440 and only applies when pos_send_timing is scheduled
  • 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 / cancel and 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.deprovisioned event (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 as deprovisioned. ready is not a connection status -- it is only a back-office sync-status label. (Verified: app/RawModels/UberEatsIntegration.php lines 97-111; upvendo-backoffice/src/views/uber-eats/index.vue lines 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