IntegrationsDelivery Platforms

Delivery Platforms

Operator menu path: Settings → Online Ordering  •  URL: /admin/settings/online-orders Access: ADMIN (configuration), MANAGER (view / test / open-close store) • Plan: plans that include the deliveryIntegration feature

This integration connects your restaurant to the Yemeksepeti, Getir, Trendyol Yemek and Migros Yemek platforms. Incoming orders become a HummyTummy Order, land on the kitchen display (KDS) and auto-print to the kitchen printer. The whole flow lives in one module (delivery-platforms); orders, menu sync and open/close all run through the same adapter layer.

This page is the developer reference (endpoints, signatures, lifecycle). For operator setup see the Online Ordering page in the help portal.

1. Per-platform connection

For each platform there is one configuration per tenant (DeliveryPlatformConfig). The operator enters credentials on Settings → Online Ordering; they are stored encrypted on the server (never returned in responses — only hasCredentials / hasAccessToken flags come back).

Configuration fields

FieldDescription
platformYEMEKSEPETI · GETIR · TRENDYOL · MIGROS
remoteRestaurantIdThe store/branch/chain id the platform issued. Inbound webhook and poll calls are matched to your tenant through this id.
credentialsPlatform-specific keys (below).
branchIdThe branch that receives this platform’s orders. Multi-branch routing: one config per platform → one branch. Empty/null applies the “first active branch” fallback.
environmentproduction (live) or sandbox (test endpoints + the test-order simulator). Defaults to production.
autoAccepttrue accepts incoming orders automatically; false parks them as PENDING_APPROVAL.

Credentials by platform

Each adapter uses a different authentication scheme:

OAuth client-credentials. POST /v2/login with clientId + clientSecret; the returned access_token is stored (refreshed 5 min before expiry). Menu and store-status calls additionally use chainCode and posVendorId.

credentials = { clientId, clientSecret, chainCode?, posVendorId? }
⚠️

remoteRestaurantId is unique per platform + restaurant id (@@unique([platform, remoteRestaurantId])). Registering the same store id for a second tenant/branch returns a conflict error.

2. Sandbox / test mode

You can validate the whole ingest → KDS → printer path without ever connecting a live platform account. Set the config’s environment to sandbox and fire a test order.

Switch the config to sandbox

PATCH /delivery-platforms/configs/:platform with { "environment": "sandbox" }. In sandbox the adapter routes to the platform’s test endpoints (Trendyol uses stageapi.trendyol.com; the others fall back to the production host until their sandbox host is published, overridable via the matching *_SANDBOX_API_BASE_URL env var).

Send a test order

POST /delivery-platforms/test-order/:platform   (ADMIN)

This endpoint pushes a synthetic order through the same processIncomingOrder pipeline that real webhooks use. Response:

{ "simulated": true, "orderId": "...", "orderNumber": "YEM-...", "externalOrderId": "TEST-<uuid>", "status": "PENDING_APPROVAL" }

Verify the outcome

The order shows on the KDS with [TEST]-labelled items, prints to the kitchen printer and arrives with a fixed TEST- prefix on its externalOrderId — it can’t be confused with a real one. If you have mapped products the simulator uses them; otherwise it produces PENDING_APPROVAL with clearly [TEST]-labelled unmapped items (captured in the notes).

⚠️

A test order works only on a sandbox config. On a production config the endpoint refuses — so a synthetic order can never be accepted by, or pushed back to, a live platform.

3. Inbound order lifecycle

Transport: webhook vs. polling

PlatformMethodCadence
YemeksepetiWebhook (real-time push)
TrendyolWebhook + polling~15 sec
GetirPolling~15 sec
MigrosPolling~20 sec

Both transports land in the same place: the adapter normalizes the raw body into a NormalizedOrder, and processIncomingOrder turns it into an Order. The dedup guarantee is the partial unique index on orders(tenantId, source, externalOrderId) (a concurrent duplicate webhook is swallowed via P2002).

Routing, auto-accept and the approval gate

  • Branch routing: if config.branchId points to an active branch of this tenant, the order routes there; otherwise it falls back to the oldest active branch.
  • Auto-accept: when autoAccept is on, the order is created PENDING (in the kitchen queue) and also accepted on the platform side.
  • Forced approval: if autoAccept is off, or any item is unmapped, or the platform totals drift more than 5% (or 1₺) from the sum of items, the order becomes PENDING_APPROVAL — it never enters the kitchen until an operator approves it.

Operator actions

POST /delivery-platforms/orders/:orderId/accept     { prepTimeMinutes? }
POST /delivery-platforms/orders/:orderId/reject      { reason }     (required)
POST /delivery-platforms/orders/:orderId/prep-time   { minutes }    (1–240)
  • Accept: PENDING_APPROVAL → PENDING; sends acceptOrder to the platform. May carry an optional prep time. No-op for an already-accepted order (alreadyAccepted: true).
  • Reject: reason is required and forwarded to the platform (the customer/courier sees why). Only PENDING_APPROVAL/PENDING can be rejected; beyond that it’s the cancellation flow.
  • Prep time: sends markPreparing to the platform and advances the order to PREPARING.

Honesty contract: if the adapter call fails, the error propagates, the internal status is not advanced, and the failure is recorded (log + circuit breaker).

Cancellation, amendment and refund (inbound)

These events exist as endpoints only on the two webhook platforms (Yemeksepeti, Trendyol); on Getir + Migros they surface via polling.

  • Status update / cancellationPICKED_UP/DELIVEREDSERVED; CANCELLED/REJECTEDCANCELLED. Atomic and idempotent; never bounces a terminal order backwards. It only mutates the internal status — it does not push anything back to the platform.
  • Amendment — the platform changes an order’s items before the kitchen committed. The full cart is re-resolved, totals recomputed with the same drift-safe logic, and re-emitted to the KDS. If the order is READY/SERVED/PAID/CANCELLED it is refused (mutating items after the food is out would desync the paper ticket); the platform must cancel + re-order.
  • Refund — the platform initiates the refund (it owns the money). We only reflect it, never push back:
    • Full refund → the order moves to CANCELLED (cancelledAt set).
    • Partial refund → the status is preserved.
⚠️

Partial-refund amount, honest limitation: the Order model has no dedicated refund-amount column and delivery orders never create Payment rows. So the partial amount lives only in the order’s externalData.refunds[] ledger, its notes, the delivery log and the emitted delivery.order.refunded.v1 event. Accounting/reporting must read those.

Restaurant-initiated refunds are unsupported. None of the four platforms documents a refund endpoint from the restaurant POS (refundOrder is optional and unimplemented by every adapter). Issue the refund from the platform’s own panel; we reflect it via the inbound refund webhook above.

4. Webhook URLs and signatures

Webhook endpoints are Public but protected by WebhookAuthGuard and rate-limited to 60 requests/minute. :remoteId is always the remoteRestaurantId.

POST /webhooks/delivery/yemeksepeti/order/:remoteId
PUT  /webhooks/delivery/yemeksepeti/:remoteId/order/:remoteOrderId/status
POST /webhooks/delivery/yemeksepeti/:remoteId/order/:remoteOrderId/refund
PUT  /webhooks/delivery/yemeksepeti/:remoteId/order/:remoteOrderId/amend

Signature: Authorization: Bearer <JWT>. The JWT is HS512-signed with YEMEKSEPETI_WEBHOOK_SECRET (the alg header must be exactly HS512alg: none and algorithm-confusion are rejected). exp must be valid and iat within the last 5 min. If the token carries a restaurant claim (sub/restaurantId/chainId/…) it must match the URL :remoteId (defends against cross-tenant replay).

Getir and Migros use no inbound webhook — we poll their orders, so there is no webhook URL or signature secret to configure for these platforms.

GET    /delivery-platforms/menu-mappings?platform=
POST   /delivery-platforms/menu-mappings           { productId, platform, externalItemId, externalData? }
DELETE /delivery-platforms/menu-mappings/:id
POST   /delivery-platforms/menu-sync/:platform                      (ADMIN)
POST   /delivery-platforms/configs/:platform/toggle-restaurant      { open: boolean }
POST   /delivery-platforms/configs/:platform/test                   (connection test)
  • Menu mapping: each product is linked to its counterpart on the platform (externalItemId). Menu sync pushes only mapped products, writing name, price (₺) and availability (isAvailable && isActive).
  • Availability: a single item can be toggled on/off via updateItemAvailability.
  • Open/close store: toggle-restaurant marks the restaurant open/closed only on that platform’s side — for pausing new orders during a rush (without turning the integration off).

Reconciliation

DeliveryReconciliationService is a low-frequency, read-only drift scan. Since no adapter exposes a settlement report, platform-side counts aren’t compared; instead, from our own state:

  • Staleness: an enabled polling config whose lastOrderPollAt is older than 1 hour is flagged (usually a circuit-breaker trip or a dead token); a menu unsynced for 7 days is flagged informationally.
  • Count drift: delivery orders ingested in the last 24 hours, and how many lack an externalOrderId (un-syncable back to the platform).

Findings are logged and rolled into a single delivery.reconciliation.v1 outbox summary.

Troubleshooting

  • Circuit breaker / auto-disable: when a config’s error count reaches 10 the integration is auto-disabled (to stop spamming the platform and the log table). A delivery.platform.auto_disabled.v1 event is emitted. The operator must fix the cause (lastError) and re-enable the config manually. Updating credentials resets the error count.
  • Dead-letter queue (DLQ): failed outbound operations are retried with exponential backoff (3 attempts by default, capped at 1 hour). Once attempts are exhausted, the row parks in the DLQ terminal state (success:false AND nextRetryAt:null AND retryCount>=maxRetries). DLQ depth is tracked by the delivery_dlq_depth metric; rows can be requeued by an operator/superadmin.
  • restaurant not configured: a webhook arrived with a remoteId that doesn’t map to an enabled config — verify the remoteRestaurantId and that the integration is on.
  • PII masking: personal data in inbound raw bodies (phone/email/address/name) is masked from both the log and the order’s externalData blob before persistence.