Skip to content

Webhook Events Reference ​

Upvendo receives webhooks from multiple payment and delivery platform providers. All webhook routes are unauthenticated (guest middleware) but use signature verification middleware specific to each provider.

Key Files ​

FilePurpose
app/Http/Controllers/Api/WebhookController.phpWebhook route handlers (thin controller)
app/Services/Orchestrators/StripeWebhookOrchestrator.phpStripe event processing
app/Services/Orchestrators/VivaWebhookOrchestrator.phpViva Wallet event processing
app/Services/Orchestrators/DeliverooWebhookOrchestrator.phpDeliveroo event processing
app/Services/Orchestrators/UberEatsWebhookOrchestrator.phpUber Eats event processing
app/Services/ThirdParty/SquareWebhookService.phpSquare event processing
app/Services/Payment/WebhookService.phpShared webhook utilities (Stripe/Viva)
app/Http/Middleware/VerifyDeliverooWebhook.phpDeliveroo signature verification
app/Http/Middleware/VerifyShopifyWebhook.phpShopify HMAC verification
app/Http/Middleware/VerifySquareWebhook.phpSquare signature verification
app/Http/Middleware/VerifyUberEatsWebhook.phpUber Eats signature verification

Webhook Routes ​

ProviderMethodRouteVerification
StripePOST/api/stripe-webhook/{countryCode}Stripe signature (in orchestrator)
Viva WalletGET/api/viva-webhook/{countryCode}/{eventTypeId}Returns verification key
Viva WalletPOST/api/viva-webhook/{countryCode}/{eventTypeId}Event type in URL
ShopifyPOST/api/shopify-webhookverify.shopify-webhook middleware
DeliverooPOST/api/webhook/deliveroo/ordersverify.deliveroo-webhook middleware
DeliverooPOST/api/webhook/deliveroo/menuverify.deliveroo-webhook middleware
Uber EatsPOST/api/webhook/uber-eatsverify.uber-eats-webhook middleware
SquarePOST/api/webhook/squareverify.square-webhook middleware
ShopCaissePOST/api/webhook/shopcaisseverify.shopcaisse-webhook middleware
MplusKassaPOST/api/webhook/mpluskassa/{event}verify.mpluskassa-webhook middleware
Lightspeed K-SeriesPOST/api/webhook/lightspeedverify.lightspeed-k-series-webhook middleware
CRM intakePOST/api/crm/intakeverify.crm-webhook + throttle:60,1 (not under /webhook)

Stripe Webhooks ​

Route: POST /api/stripe-webhook/{countryCode}Orchestrator: StripeWebhookOrchestrator

The countryCode parameter determines which Stripe API key and webhook secret to use (multi-country support). Webhook secrets are configured per country: config("services.stripe.{countryCode}.webhook_secret").

Signature Verification ​

Verification happens inside the orchestrator (not middleware):

  1. WebhookService::constructEvent() validates the Stripe-Signature header
  2. Uses \Stripe\Webhook::constructEvent() for production
  3. In local environment, constructs event directly from payload (no signature check)

Handled Event Types ​

Event TypeHandlerDescription
payment_intent.succeededhandleSuccessfulPayment()Online ordering payment completed
terminal.reader.action_succeededhandleSuccessfulTerminalPayment()Terminal/kiosk payment completed
customer.subscription.createdhandleCustomerSubscriptionUpdated()New subscription created
customer.subscription.updatedhandleCustomerSubscriptionUpdated()Subscription modified
customer.subscription.pausedhandleCustomerSubscriptionUpdated()Subscription paused
customer.subscription.resumedhandleCustomerSubscriptionUpdated()Subscription resumed
customer.subscription.deletedhandleSubscriptionDeleted()Subscription cancelled (with SEPA recovery)
customer.subscription.trial_will_endhandleCustomerSubscriptionUpdated()Trial ending notification
account.updatedhandleAccountUpdated()Connected account updated
invoice.payment_failedhandleInvoicePaymentFailed()Invoice payment failed (SEPA retry)
setup_intent.succeededSetup-intent handlerSEPA mandate / payment method set up
payment_intent.payment_failedFailure handlerOnline payment failed
payment_intent.canceledCancellation handlerPayment intent cancelled
terminal.reader.action_failedTerminal failure handlerTerminal/kiosk payment failed
customer.subscription.pending_update_appliedhandleCustomerSubscriptionUpdated()Pending subscription update applied
customer.subscription.pending_update_expiredhandleCustomerSubscriptionUpdated()Pending subscription update expired
capability.updatedhandleAccountUpdated() pathConnected-account capability changed
person.createdAccount-person handlerConnected-account person added
person.updatedAccount-person handlerConnected-account person changed
customer.updatedCustomer handlerStripe customer record changed
invoice.paidInvoice handlerInvoice paid — also drives reseller subscription commission and Invoice Collection commission (both short-circuit on paid-out-of-band / non-Upvendo invoices)
invoice.voidedInvoice handlerInvoice voided
charge.refundedRefund handlerCommission adjustment on refund
application_fee.createdFee back-fill handlerBack-fills platform_fee_cents on the existing ledger row; Stripe managed pricing attaches the fee asynchronously, so this often lands after payment_intent.succeeded / invoice.paid

(payment_method.attached has a case but its handler call is commented out. Verified against the switch ($eventType) block at StripeWebhookOrchestrator.php:1120-1210.)

Payment Flow (Terminal) ​

When terminal.reader.action_succeeded fires:

  1. Extract payment_intent ID from reader action
  2. Resolve tenant database from the reader's device ID
  3. Call PaymentCaptureService::capturePayment() with the payment intent ID
  4. This triggers the full capture flow (inventory, loyalty, KDS, etc.)

Subscription Management ​

When subscription events fire:

  1. WebhookService::updateLocalSubscription() syncs the Stripe subscription to local DB
  2. Updates device or location constants via DeviceService::updateD1Constants()
  3. Triggers Firebase location update for real-time sync

SEPA Direct Debit Recovery ​

Special handling for SEPA payment failures:

  1. invoice.payment_failed stores failed invoice data in cache (10 min TTL)
  2. Attempts immediate retry with mandate data via retrySubscriptionPaymentWithMandate()
  3. If customer.subscription.deleted fires due to payment failure:
    • Checks cache for recent SEPA failure
    • Attempts to recreate subscription with proper mandate data
    • Uses database locks to prevent race conditions between the two event handlers

Viva Wallet Webhooks ​

Route: POST /api/viva-webhook/{countryCode}/{eventTypeId}Orchestrator: VivaWebhookOrchestrator

Verification ​

  • GET requests: Return the Viva Wallet webhook verification key (for initial setup)
  • POST requests: Process actual webhook events

The guest.php route file applies guest as middleware, not as a URL prefix, and is required from routes/api.php outside any prefix group. The path is therefore /api/viva-webhook/..., not /api/guest/viva-webhook/.... The comment at routes/api.php line 15 still claims the latter and is stale. (Verified: routes/api/guest.php lines 7 and 44-47; routes/api.php line 38; bootstrap/app.php line 14.)

Raw Webhook Capture (90-day retention) ​

Every inbound Viva webhook is persisted before the duplicate guard and before location resolution, so an event that is dropped as a duplicate or discarded for an unresolvable location is still recoverable — Viva offers no dashboard resend.

  • Written to the shared logs collection with type: 'viva_webhook' and status: 'received'.
  • Idempotently upserted on webhook_id, the same SHA-256 content hash the duplicate guard computes, with an occurrences counter incremented per delivery — redeliveries update one document rather than piling up.
  • Stored fields: event_type_id, event_type, order_code, transaction_id, merchant_trns, tags, and the full decoded payload.
  • Failures are swallowed (logged at warning) so the capture can never block the payment path.

Retention is a partial TTL of 7,776,000 seconds (90 days) scoped to type='viva_webhook', so it prunes only the PII-bearing webhook receipts and never touches other documents in the shared logs collection. A unique partial index on webhook_id enforces capture idempotency under concurrent redelivery. Both indexes are applied by:

php artisan monitoring:ensure-request-log-indexes

(Verified: app/Services/Orchestrators/VivaWebhookOrchestrator.php lines 41-45 and 196-207; app/Repositories/RequestLogRepository.php lines 67-96 and 105-133; app/Console/Commands/EnsureRequestLogIndexes.php line 10. Introduced by #1397.)

Duplicate Detection ​

After the raw capture, the orchestrator applies duplicate webhook detection:

  1. Generates SHA-256 hash from event data fields (OrderCode, CustomerTrns, TransactionTypeId, Amount, StatusId, Tags[0], MerchantTrns)
  2. Uses database cache lock with 60-second TTL
  3. Checks if identical webhook was processed within last 30 seconds
  4. Stores recent webhook data for 5 minutes for future comparison

(Verified: VivaWebhookOrchestrator::isDuplicateWebhook() lines 90-126, generateWebhookId() lines 133-151.)

Event Types ​

Event type IDs are mapped to names via Constants::$VIVA_EVENT_TYPES in the orchestrator; the orchestrator then dispatches ProcessVivaWebhookJob, which switches on the name and does the actual work asynchronously.

IDEvent TypeHandlerDescription
1796TRANSACTION_PAYMENT_CREATEDhandlePaymentCreated() + handleCommissionTracking()Payment completed
1797TRANSACTION_REVERSAL_CREATEDhandleReversalCreated()Refund / reversal — commission clawback
1798TRANSACTION_FAILEDhandleTransactionFailed()Payment failed
8193ACCOUNT_CONNECTEDhandleAccountConnected()Merchant account connected
8194ACCOUNT_VERIFICATION_STATUS_CHANGEDhandleAccountVerificationStatusChanged()KYC status changed

1799 (TRANSACTION_PRICE_CALCULATED) is mapped to a name but has no case in the job's switch, so it falls through to the "Unhandled event type" debug log. (Verified: app/Constants.php:780 ($VIVA_EVENT_TYPES); app/Jobs/ProcessVivaWebhookJob.php lines 85-110.)

Reversal / Refund Commission Handling ​

TRANSACTION_REVERSAL_CREATED (1797) drives the commission clawback for a Viva terminal refund.

Viva carries the id of the original transaction being reversed in EventData.ParentId (a UUID) — it does not send an OriginalTransactionId field. Reading that non-existent key always yielded null, so the handler used to log a warning and return before adjusting anything, and every Viva terminal refund's commission clawback was silently skipped. (Fixed in #1389.)

VivaCommissionHandler::handleTransactionReversalCreated($payload)
  |
  +-> eventId               = EventData.TransactionId
  +-> originalTransactionId = EventData.ParentId      <-- not OriginalTransactionId
  |
  +-> Either missing? -> log warning, return
  +-> claimEvent('viva', 'reversal', $eventId)  (idempotency guard)
  |
  +-> findOriginalLedgerEntries($originalTransactionId)
  |     CommissionLedger where source_ref = $originalTransactionId
  |                       and source      = 'viva_terminal'
  |                       and is_adjustment = false
  |                       and status      != 'voided'
  |
  +-> For each entry -> commissionEngine->createAdjustment(
  |                       adjustmentAmountCents: entry.commission_amount_cents,
  |                       reason: 'viva_reversal',
  |                       sourceRef: $eventId)
  +-> markEventProcessed($eventId)   (or markEventFailed on exception)

The original sale stored its Viva TransactionId as the ledger entry's source_ref, and a reversal's ParentId is exactly that id — which is why the two match. (Verified: app/Services/Commission/VivaCommissionHandler.php lines 186-238 and 249-256; dispatched from app/Jobs/ProcessVivaWebhookJob.php line 270.)

Payment Created Processing ​

When TRANSACTION_PAYMENT_CREATED fires with StatusId: "F" (Finalized):

  1. Resolve Location (orchestrator, synchronous): Extract location ID from Tags[0], else the leading segment of MerchantTrns, else look the location up by viva_wallet_physical_source_code. If it still cannot be resolved, the event is written to RequestLog as location_not_resolved and dropped (the raw capture above already retained it).
  2. Set Tenant Database (job): Load location, get vendor, set tenant DB
  3. Determine Channel (job): Check Tags[1] for order channel

Online Ordering Path:

  • Find transaction by payment_snapshot.orderCode
  • Acquire database lock: payment_processing_online_{idempotencyKey} (30s TTL)
  • Call OnlineOrderingOrchestrator::verifyPayment()

Kiosk Path:

  • Find transaction by order_no (from CustomerTrns or MerchantTrns)
  • Acquire database lock: payment_processing_kiosk_{idempotencyKey} (30s TTL)
  • Call PaymentCaptureService::capturePayment() with webhook data

Deliveroo Webhooks ​

Route: POST /api/webhook/deliveroo/ordersMiddleware: verify.deliveroo-webhook (signature verification) Orchestrator: DeliverooWebhookOrchestrator

The orchestrator delegates to DeliverooWebhookService::handleEvent().

Event Types ​

Deliveroo sends order lifecycle events:

  • New order created
  • Order accepted/rejected
  • Order preparation updates
  • Order pickup/delivery status changes

Route: POST /api/webhook/deliveroo/menu

Currently logged but not actively processed (menu sync is push-based from Upvendo to Deliveroo).


Uber Eats Webhooks ​

Route: POST /api/webhook/uber-eatsMiddleware: verify.uber-eats-webhook (signature verification) Orchestrator: UberEatsWebhookOrchestrator

The orchestrator delegates to UberEatsWebhookService::handleEvent().

Uber Eats webhook processing dispatches these jobs:

  • ProcessUberEatsOrderNotificationJob -- Process new order notifications
  • ProcessUberEatsCancelNotificationJob -- Process order cancellations
  • ProcessUberEatsScheduledNotificationJob -- Process scheduled order updates

Square Webhooks ​

Route: POST /api/webhook/squareMiddleware: verify.square-webhook (signature verification) Handler: SquareWebhookService::handleEvent()

Square webhooks are handled directly by a service (not an orchestrator pattern) since the webhook service itself is a third-party service.

Event Types ​

Square sends events for:

  • Terminal checkout status changes (payment completed/cancelled)
  • Order updates
  • Inventory changes (when two-way sync is enabled)
  • Catalog updates

Shopify Webhooks ​

Route: POST /api/shopify-webhookMiddleware: verify.shopify-webhook (HMAC verification) Handler: ShopifyIntegrationService::processWebhook()

Processing Flow ​

  1. Extract shop domain from X-Shopify-Shop-Domain header
  2. Resolve shop name (strip .myshopify.com)
  3. Find matching ThirdPartyIntegration record by shop name
  4. Delegate to ShopifyIntegrationService::processWebhook()

OAuth Callbacks ​

These are not webhooks but related authorization callbacks:

ProviderRouteDescription
ShopifyGET /api/shopify-callbackOAuth authorization callback
SquareGET /api/square-callbackOAuth authorization callback
Uber EatsGET /api/uber-eats/callbackOAuth authorization callback

All handled by ThirdPartyAuthService.


Webhook Processing Patterns ​

Idempotency ​

All payment webhook handlers use database locks to prevent duplicate processing:

Lock key: "payment_verification_{idempotencyKey}" or "payment_processing_{channel}_{key}"
Lock TTL: 20-30 seconds

Error Handling ​

All webhook controllers follow the same pattern:

php
try {
    app(SomeOrchestrator::class)->handle($request);
} catch (\Throwable $th) {
    $this->handleException($th);
}
return $this->sendSuccess();

Webhooks always return 200/success to the provider to prevent retries, even on internal errors. Errors are logged for investigation.

Tenant Resolution ​

Webhooks must resolve the tenant database before processing:

  • Stripe: Resolves from payment intent metadata (location_id) or device reader ID
  • Viva Wallet: Resolves from Tags[0] (location ID) or MerchantTrns field
  • Deliveroo/Uber Eats: Resolved from the integration's stored location reference
  • Square: Resolved from the webhook payload's merchant/location reference

Debugging Webhooks ​

Common Issues ​

  1. Signature verification failures

    • Ensure webhook secrets match between provider dashboard and .env
    • For Stripe: STRIPE_{COUNTRY}_WEBHOOK_SECRET
    • Check that the raw request body is used for verification (not parsed JSON)
  2. Duplicate processing

    • Check database cache locks for stuck locks
    • Viva Wallet duplicate detection may reject legitimate retries within 30s
  3. Tenant database not found

    • Webhook contains location ID that doesn't exist in the system
    • Location was deleted but webhook still fires
    • Check logs for "Location not found" warnings
  4. Payment capture fails after webhook

    • Check PaymentCaptureService::capturePayment() logs
    • Look for write conflict retries (up to 5 attempts)
    • Verify transaction exists with matching idempotency key

Log Patterns ​

Search for these log messages:

  • "Error processing Viva webhook" -- Viva Wallet failures
  • "Error processing Uber Eats webhook" -- Uber Eats failures
  • "Error processing Deliveroo webhook" -- Deliveroo failures
  • "Stripe webhook event not handled" -- Unhandled Stripe event types
  • "Unhandled Viva Wallet webhook event" -- Unhandled Viva event types
  • "Payment capture failed" -- Payment capture failures
  • "Transaction not found for" -- Missing transaction during webhook processing