Skip to content

Common Errors and Debugging

This guide covers the most frequently encountered errors in the Upvendo backend, their causes, and how to diagnose and resolve them.

HTTP Status Codes Used

CodeMeaningWhen Returned
200SuccessStandard successful response
400Bad RequestInvalid input, malformed payload, failed validation
401UnauthenticatedMissing/invalid/expired JWT token
402Payment RequiredKiosk/KDS device without an active subscription (TokenType.php:152, in checkDeviceSubscription())
403ForbiddenValid token but insufficient permissions or wrong user type
404Not FoundResource doesn't exist or wrong tenant database
409ConflictDuplicate resource, idempotency conflict (Viva Wallet)
422Unprocessable EntityLaravel validation errors
429Too Many RequestsRate limiting (online ordering endpoints)
500Server ErrorUnhandled exceptions, database errors
503Service UnavailableUnconfigured Uber Eats webhook secret (VerifyUberEatsWebhook.php:22), or a failed dependency check on /health (HealthController.php:57)

Authentication Errors

401 "Unauthenticated"

Code Path: JwtAuthenticate middleware or Authenticate middleware

Causes:

  1. No Authorization: Bearer {token} header in request
  2. Token has expired (check exp claim)
  3. Token signature invalid (JWT secret mismatch)
  4. Token payload malformed

Diagnosis:

  • Decode the JWT token at jwt.io (use the debugger, not the verifier unless you have the secret)
  • Check the exp timestamp against current time
  • Verify JWT_SECRET in .env matches what was used to sign the token
  • Check if the user/device/customer model exists in the database
Files to check:
  app/Http/Middleware/JwtAuthenticate.php (line 40-48)
  app/Services/JwtService.php

401 "Invalid token"

Same middleware as above. The token was present but JwtService::validateToken() returned null.

Common Causes:

  • Token signed with a different secret (e.g., staging token used against production)
  • Token structure is corrupt
  • Token was manually modified

403 "Unauthorized: Invalid user type"

Code Path: JwtAuthenticate middleware (type guard check)

Cause: Token type doesn't match the route's required type. For example:

  • Customer token used on a backoffice route (requires type:backoffice)
  • User token used on a kiosk route (requires type:kiosk)

Diagnosis:

  • Decode the JWT, check the type field
  • Verify the route middleware stack to see expected types

403 "Unauthorized: Insufficient permissions"

Code Path: CheckPermission middleware

Cause: The authenticated user's role does not include the required permission.

Diagnosis:

  1. Check which permission the route requires (look at route definition)
  2. Check the user's assigned roles in MongoDB
  3. Check the role's permission list
  4. Verify the user has access to the specific location (for location-scoped permissions)
Files to check:
  app/Http/Middleware/CheckPermission.php
  app/Services/PermissionService.php
  app/Constants/Permissions.php (for permission constant names)

Validation Errors (422)

Laravel returns 422 with validation error details:

json
{
  "message": "(Laravel-generated summary of the validation errors)",
  "errors": {
    "field_name": ["The field_name field is required."]
  }
}

message is not a fixed string -- there is no invalidJson override in bootstrap/app.php, and no code path in the backend emits "The given data was invalid." (that was Laravel's wording on much older versions; production runs laravel/framework v13.18.1). Always read errors for the per-field detail rather than matching on message.

Where validation happens:

  • Request classes in app/Http/Requests/ (FormRequest objects)
  • Each controller action that accepts input has a corresponding Request class

Common Validation Failures:

  • Required fields missing
  • Invalid UUID/ObjectId format
  • Enum value not in allowed set
  • Numeric fields receiving string values
  • Image upload exceeding size limits

Database Errors

Tenant database not set

There is no "No tenant database configured" error message -- SetTenantDatabase does not error when the tenant DB is missing. If the JWT carries no tenant_database claim and the request user cannot be resolved to a database, the middleware silently leaves the connection unchanged (app/Http/Middleware/SetTenantDatabase.php:70-83), so the symptom is 404s / empty result sets rather than a message.

Its actual failure responses are:

  • 401 Unauthenticated -- no bearer token (SetTenantDatabase.php:58)
  • 401 Invalid token -- JwtService::validateToken() returned null (SetTenantDatabase.php:66)
  • 500 Internal Server Error -- an exception during the switch, logged as Tenant database switch attempt failed (SetTenantDatabase.php:86-91)

Diagnosis:

  • Check JWT token for tenant_database claim
  • Verify the vendor's database name exists
  • Check the middleware order in the route group

MongoDB Write Conflict

Error: Write conflict or duplicate key error during transactions.

Code Path: DBTransactionTrait::executeWithTransactionRetry()

Cause: Two concurrent operations tried to modify the same document. The retry mechanism handles this automatically -- 3 attempts by default, with exponential backoff (100ms * 2^(attempt-1) plus 0-50ms jitter). See app/Traits/DBTransactionTrait.php:201-260.

The payment capture path is the one caller that raises this: PaymentCaptureService.php:183 passes 5. Do not confuse either figure with the 5-attempt default of the lock retry helpers (CachingTrait::executeWithDatabaseLockAndRetry, app/Traits/CachingTrait.php:571) -- that is a different mechanism.

If retries are exhausted:

  • Check for hot documents (frequently updated by multiple requests)
  • Consider redesigning the data access pattern
  • Check if webhook handlers are processing duplicates

"Model not found" / 404 on Retrieve

Code Path: Repository retrieve() methods

Causes:

  1. Document was deleted
  2. Wrong tenant database (query running against wrong DB)
  3. ObjectId format incorrect (string vs MongoDB ObjectId)
  4. Soft-deleted document (need withTrashed: true)

Diagnosis:

  • Verify the document exists in MongoDB directly
  • Check which database the query is running against
  • Enable query logging to see the actual database being queried

Payment Errors

"Square Terminal device not configured for this device"

Code Path: PaymentService::processSquarePayment()

Cause: Device doesn't have a Square Terminal ID assigned.

Fix: Configure the Square Terminal device code in the BackOffice device settings.

409 Conflict on Viva Wallet Terminal Sale

Code Path: PaymentService::processVivaWalletPayment()

Cause: The idempotency key was already used for a previous terminal sale attempt.

Resolution: The code automatically regenerates the idempotency key and retries. If the error persists:

  • Check all_idempotency_keys on the transaction
  • Verify the terminal isn't stuck on a previous payment
  • May need to abort the terminal session manually

"Error verifying payment"

Code Path: PaymentService::verifyPayment()

Cause: Viva Wallet API returned an error when attempting to retrieve or capture the transaction.

Diagnosis:

  • Check payment_snapshot.orderCode exists
  • Verify the merchant ID is correct
  • Check Viva Wallet dashboard for the transaction status

"Payment capture failed"

Code Path: PaymentCaptureService::capturePayment()

Cause: Exception during the capture flow. Logged with full trace.

Diagnosis:

  • Search logs for the idempotency key
  • Check if the transaction was already captured (duplicate webhook)
  • Look for database write conflicts in the trace

Integration Errors

Webhook Signature Verification Failures

Middleware: nine verify.* aliases are registered in bootstrap/app.php:41-49, and they do not all use the same mechanism:

MechanismMiddleware
HMAC-SHA256 signatureVerifyShopifyWebhook, VerifySquareWebhook, VerifyUberEatsWebhook, VerifyDeliverooWebhook, VerifyShopCaisseWebhook, VerifyMplusKassaWebhook
HTTP Basic auth (no HMAC)VerifyLightspeedKSeriesWebhook
Shared bearer tokenVerifyCrmWebhook (401 Invalid CRM webhook token)
IP allow-listVerifyVivaWebhookIp -- Viva does not send per-request signatures

Cause: The webhook secret in .env doesn't match the provider's configuration.

Fix:

  • Re-check the webhook secret in the provider's dashboard
  • Ensure the raw request body is used for signature computation
  • For Shopify: verify HMAC is computed correctly against X-Shopify-Hmac-Sha256
  • For Uber Eats: an unconfigured secret aborts with 503 Webhook verification is not configured (VerifyUberEatsWebhook.php:22), not 401/403 -- a 401 there means a missing or mismatched signature

"Shopify integration not found"

Code Path: WebhookController::shopifyWebhook()

Cause: Webhook received from a shop domain that doesn't match any enabled integration.

Fix:

  • Verify the shop name in the ThirdPartyIntegration collection
  • Check if the integration was disabled

Third-Party API Timeouts

Cause: External API (Viva Wallet, Square, Deliveroo, Uber Eats, Shopify) is slow or down.

Diagnosis:

  • Check the provider's status page
  • Look for timeout exceptions in logs
  • Verify network connectivity from the server

Rate Limiting

429 Too Many Requests

Affected endpoints:

  • POST /customer/{slug}/order-history -- 5 requests per minute
  • GET /customer/{slug}/order-detail/{orderId} -- 10 requests per minute

Rate limiting is disabled in local environment.


Multi-Tenant Errors

Queries returning wrong data

Cause: Tenant database not properly set before query execution.

Diagnosis:

  1. Check middleware order ensures SetTenantDatabase runs before controller
  2. In jobs, verify tenant database is set in handle() method
  3. In webhook handlers, verify tenant resolution logic

Cross-tenant data leakage

Prevention:

  • All tenant-scoped queries go through the tenant database connection
  • The SetTenantDatabase middleware resets the connection per-request
  • Never use the default connection for tenant data

Debugging Toolkit

Log Searching

Key log patterns to search for:

"Payment capture failed"                -> Payment processing errors (PaymentCaptureService.php:488)
"Error processing Viva webhook"         -> Viva Wallet webhook failures (WebhookController.php:203)
"Uber Eats integration not found"       -> Uber Eats job could not resolve the integration
                                           (ProcessUberEatsOrderNotificationJob.php:86)
"Uber Eats webhook"                     -> Uber Eats webhook intake; the error variants are
                                           "Missing integration identifiers in Uber Eats webhook"
                                           and "No integration found for Uber Eats webhook payload"
                                           (UberEatsWebhookService.php:38, :48)
"Failed to abort terminal session"      -> Terminal session issues (PaymentService.php:671)
"Slow payment capture detected"         -> Performance issues (PaymentCaptureService.php:519)
"Transaction not found"                 -> Missing transactions during webhook
                                           (ProcessVivaWebhookJob.php:148, :195, :354)
"Location not found"                    -> Invalid location in webhook
"Write conflict"                        -> Database concurrency issues

Database Inspection

MongoDB queries for common investigations:

javascript
// Find transaction by order number
db.transactions.findOne({ order_no: "ORDER-123" })

// Find transaction by idempotency key
db.transactions.findOne({ "payment_snapshot.idempotency_key": "idemp_xxx" })

// Find recent failed transactions
// `status` holds the capitalised App\Enums\OrderStatuses values -- "unpaid" matches nothing
db.transactions.find({ status: "Unpaid", updated_at: { $gte: new Date(Date.now() - 3600000) } })

// Check device activation status
db.devices.findOne({ activation_code: "ABC123" })

Environment-Specific Behavior

FeatureLocalStagingProduction
Auto-success paymentsConfigurableOffOff
Rate limitingDisabledEnabledEnabled
Stripe webhook verificationSkippedEnabledEnabled
Queue processingsyncsyncdatabase (MongoDB-backed, collection jobs)
Debug endpointsAvailable (behind e2e.auth)DisabledDisabled
phpinfo endpointAvailable (behind e2e.auth)DisabledDisabled

Redis is not a queue driver here. config/queue.php:16 defaults to the database connection, whose driver is mongodb writing to the jobs collection (config/queue.php:37-41); the per-environment matrix is recorded in docs/PUSHER_ENV_CONFIGURATION.md. Redis is the cache store (CACHE_STORE=redis).

The whole dev/debug route group -- /phpinfo included -- is registered only when GeneralHelper::isTestEnv() is true, and that helper is ! app()->isProduction() && ! app()->environment('staging') (app/Helpers/GeneralHelper.php:9-12), so the routes do not exist on staging or production. Where they do register they additionally sit behind e2e.auth (E2EAuthMiddleware), which requires the E2E_API_KEY token (routes/api/guest.php:128-139).

Sentry Integration

Errors from every non-local environment are tracked in Sentry (staging included) -- the integration is registered in bootstrap/app.php:84-85:

  • Payment capture operations have Sentry tracing spans
  • app/Services/SentryBeforeSendCallback.php filters/enriches events before sending
  • Slow payment captures (>2s) generate Sentry performance alerts

Error Response Format

Standard Success

json
{
  "status": "success"
}

This is the canonical envelope, produced by the base Controller::sendSuccess() (app/Http/Controllers/Controller.php:57-60) and used by ~177 controller actions. A handful of legacy endpoints (5 at the time of writing) still return {"success": true} -- treat status as the canonical shape.

Standard Error (from handleException)

json
{
  "message": "Error description"
}

Validation Error (422)

json
{
  "message": "(Laravel-generated summary of the validation errors)",
  "errors": {
    "field": ["Validation message"]
  }
}

Controller Error Handling Pattern

All controllers use the same exception handling:

php
try {
    $result = $this->service->doSomething($request->validated());
} catch (\Throwable $th) {
    $this->handleException($th);  // Base Controller method
}
return response()->json($result);

The handleException() method in the base Controller class (app/Http/Controllers/Controller.php:35-55):

  • 500s: logs the exception (via RequestLogRepository into the logs collection, plus the system log), then aborts with the generic message "An unexpected error occurred" unless app.debug is on -- the real exception message is never returned to the client in production (Controller.php:192-196).
  • 4xx: passes the real exception message through, and is deliberately not written to the system logs at error level (client errors are expected and would otherwise alert; Controller.php:53-55, :161).
  • ModelNotFoundException becomes a 404 with "Unable to locate the {model} you requested." (Controller.php:37-39).
  • Sentry capture is not wired inside handleException() -- handleExceptionByEnvironment() has no active body. Reporting comes from Integration::handles($exceptions) in bootstrap/app.php:84-85, which applies to every non-local environment (staging included).