Appearance
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
| File | Purpose |
|---|---|
app/Http/Controllers/Api/WebhookController.php | Webhook route handlers (thin controller) |
app/Services/Orchestrators/StripeWebhookOrchestrator.php | Stripe event processing |
app/Services/Orchestrators/VivaWebhookOrchestrator.php | Viva Wallet event processing |
app/Services/Orchestrators/DeliverooWebhookOrchestrator.php | Deliveroo event processing |
app/Services/Orchestrators/UberEatsWebhookOrchestrator.php | Uber Eats event processing |
app/Services/ThirdParty/SquareWebhookService.php | Square event processing |
app/Services/Payment/WebhookService.php | Shared webhook utilities (Stripe/Viva) |
app/Http/Middleware/VerifyDeliverooWebhook.php | Deliveroo signature verification |
app/Http/Middleware/VerifyShopifyWebhook.php | Shopify HMAC verification |
app/Http/Middleware/VerifySquareWebhook.php | Square signature verification |
app/Http/Middleware/VerifyUberEatsWebhook.php | Uber Eats signature verification |
Webhook Routes
| Provider | Method | Route | Verification |
|---|---|---|---|
| Stripe | POST | /api/stripe-webhook/{countryCode} | Stripe signature (in orchestrator) |
| Viva Wallet | GET | /api/viva-webhook/{countryCode}/{eventTypeId} | Returns verification key |
| Viva Wallet | POST | /api/viva-webhook/{countryCode}/{eventTypeId} | Event type in URL |
| Shopify | POST | /api/shopify-webhook | verify.shopify-webhook middleware |
| Deliveroo | POST | /api/webhook/deliveroo/orders | verify.deliveroo-webhook middleware |
| Deliveroo | POST | /api/webhook/deliveroo/menu | verify.deliveroo-webhook middleware |
| Uber Eats | POST | /api/webhook/uber-eats | verify.uber-eats-webhook middleware |
| Square | POST | /api/webhook/square | verify.square-webhook middleware |
| ShopCaisse | POST | /api/webhook/shopcaisse | verify.shopcaisse-webhook middleware |
| MplusKassa | POST | /api/webhook/mpluskassa/{event} | verify.mpluskassa-webhook middleware |
| Lightspeed K-Series | POST | /api/webhook/lightspeed | verify.lightspeed-k-series-webhook middleware |
| CRM intake | POST | /api/crm/intake | verify.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):
WebhookService::constructEvent()validates theStripe-Signatureheader- Uses
\Stripe\Webhook::constructEvent()for production - In local environment, constructs event directly from payload (no signature check)
Handled Event Types
| Event Type | Handler | Description |
|---|---|---|
payment_intent.succeeded | handleSuccessfulPayment() | Online ordering payment completed |
terminal.reader.action_succeeded | handleSuccessfulTerminalPayment() | Terminal/kiosk payment completed |
customer.subscription.created | handleCustomerSubscriptionUpdated() | New subscription created |
customer.subscription.updated | handleCustomerSubscriptionUpdated() | Subscription modified |
customer.subscription.paused | handleCustomerSubscriptionUpdated() | Subscription paused |
customer.subscription.resumed | handleCustomerSubscriptionUpdated() | Subscription resumed |
customer.subscription.deleted | handleSubscriptionDeleted() | Subscription cancelled (with SEPA recovery) |
customer.subscription.trial_will_end | handleCustomerSubscriptionUpdated() | Trial ending notification |
account.updated | handleAccountUpdated() | Connected account updated |
invoice.payment_failed | handleInvoicePaymentFailed() | Invoice payment failed (SEPA retry) |
setup_intent.succeeded | Setup-intent handler | SEPA mandate / payment method set up |
payment_intent.payment_failed | Failure handler | Online payment failed |
payment_intent.canceled | Cancellation handler | Payment intent cancelled |
terminal.reader.action_failed | Terminal failure handler | Terminal/kiosk payment failed |
customer.subscription.pending_update_applied | handleCustomerSubscriptionUpdated() | Pending subscription update applied |
customer.subscription.pending_update_expired | handleCustomerSubscriptionUpdated() | Pending subscription update expired |
capability.updated | handleAccountUpdated() path | Connected-account capability changed |
person.created | Account-person handler | Connected-account person added |
person.updated | Account-person handler | Connected-account person changed |
customer.updated | Customer handler | Stripe customer record changed |
invoice.paid | Invoice handler | Invoice paid — also drives reseller subscription commission and Invoice Collection commission (both short-circuit on paid-out-of-band / non-Upvendo invoices) |
invoice.voided | Invoice handler | Invoice voided |
charge.refunded | Refund handler | Commission adjustment on refund |
application_fee.created | Fee back-fill handler | Back-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:
- Extract
payment_intentID from reader action - Resolve tenant database from the reader's device ID
- Call
PaymentCaptureService::capturePayment()with the payment intent ID - This triggers the full capture flow (inventory, loyalty, KDS, etc.)
Subscription Management
When subscription events fire:
WebhookService::updateLocalSubscription()syncs the Stripe subscription to local DB- Updates device or location constants via
DeviceService::updateD1Constants() - Triggers Firebase location update for real-time sync
SEPA Direct Debit Recovery
Special handling for SEPA payment failures:
invoice.payment_failedstores failed invoice data in cache (10 min TTL)- Attempts immediate retry with mandate data via
retrySubscriptionPaymentWithMandate() - If
customer.subscription.deletedfires 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.phproute file appliesguestas middleware, not as a URL prefix, and isrequired fromroutes/api.phpoutside any prefix group. The path is therefore/api/viva-webhook/..., not/api/guest/viva-webhook/.... The comment atroutes/api.phpline 15 still claims the latter and is stale. (Verified:routes/api/guest.phplines 7 and 44-47;routes/api.phpline 38;bootstrap/app.phpline 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
logscollection withtype: 'viva_webhook'andstatus: 'received'. - Idempotently upserted on
webhook_id, the same SHA-256 content hash the duplicate guard computes, with anoccurrencescounter 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 decodedpayload. - 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:
- Generates SHA-256 hash from event data fields (OrderCode, CustomerTrns, TransactionTypeId, Amount, StatusId, Tags[0], MerchantTrns)
- Uses database cache lock with 60-second TTL
- Checks if identical webhook was processed within last 30 seconds
- 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.
| ID | Event Type | Handler | Description |
|---|---|---|---|
| 1796 | TRANSACTION_PAYMENT_CREATED | handlePaymentCreated() + handleCommissionTracking() | Payment completed |
| 1797 | TRANSACTION_REVERSAL_CREATED | handleReversalCreated() | Refund / reversal — commission clawback |
| 1798 | TRANSACTION_FAILED | handleTransactionFailed() | Payment failed |
| 8193 | ACCOUNT_CONNECTED | handleAccountConnected() | Merchant account connected |
| 8194 | ACCOUNT_VERIFICATION_STATUS_CHANGED | handleAccountVerificationStatusChanged() | 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):
- Resolve Location (orchestrator, synchronous): Extract location ID from
Tags[0], else the leading segment ofMerchantTrns, else look the location up byviva_wallet_physical_source_code. If it still cannot be resolved, the event is written toRequestLogaslocation_not_resolvedand dropped (the raw capture above already retained it). - Set Tenant Database (job): Load location, get vendor, set tenant DB
- 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(fromCustomerTrnsorMerchantTrns) - 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
Menu Webhook
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().
Related Jobs
Uber Eats webhook processing dispatches these jobs:
ProcessUberEatsOrderNotificationJob-- Process new order notificationsProcessUberEatsCancelNotificationJob-- Process order cancellationsProcessUberEatsScheduledNotificationJob-- 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
- Extract shop domain from
X-Shopify-Shop-Domainheader - Resolve shop name (strip
.myshopify.com) - Find matching
ThirdPartyIntegrationrecord by shop name - Delegate to
ShopifyIntegrationService::processWebhook()
OAuth Callbacks
These are not webhooks but related authorization callbacks:
| Provider | Route | Description |
|---|---|---|
| Shopify | GET /api/shopify-callback | OAuth authorization callback |
| Square | GET /api/square-callback | OAuth authorization callback |
| Uber Eats | GET /api/uber-eats/callback | OAuth 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 secondsError 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) orMerchantTrnsfield - 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
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)
- Ensure webhook secrets match between provider dashboard and
Duplicate processing
- Check database cache locks for stuck locks
- Viva Wallet duplicate detection may reject legitimate retries within 30s
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
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
- Check
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