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