Architecture overview
The app is a public Shopify app built on Laravel. It runs as a background sync service: it receives events from Shopify (through webhooks and scheduled polling), transforms them, and writes the results into Engage. A smaller flow runs in the opposite direction to keep marketing preferences in sync. At a high level, there are four moving parts:- Webhook controllers receive real-time events from Shopify and Voyado.
- Scheduled commands poll Shopify for new or updated records every minute as a safety net for missed webhooks.
- Queued jobs do the actual transformation and API calls, with retries and idempotency guards.
- API clients (
ShopifyApiandVoyadoApi) wrap the outbound HTTP calls to each platform.
- An index job (for example
IndexCustomer,IndexOrder,IndexRefund) pulls the full record from Shopify and stores a local copy. - A sync job (for example
SyncContact,SyncOrder,SyncRefund) reads the local copy, maps it to the Voyado format, and sends it to Engage.
Authentication
Each direction uses its own credentials and its own request validation.Shopify
- The app authenticates to the Shopify Admin API using the per-shop OAuth access token issued during installation.
- Requests use the API version configured in
SHOPIFY_API_VERSION(default2023-04). - Inbound Shopify webhooks are validated by the
ValidateShopifyWebhookmiddleware before any processing happens.
Voyado Engage
- The app authenticates to the Voyado API using the shop’s Voyado API key, sent in the
apikeyheader against the shop’svoyado_api_domainat the/api/v2base path. - Inbound Voyado webhooks are validated by the
ValidateVoyadoWebhookmiddleware. - The Voyado contact webhook payload is encrypted; the app decrypts it using the shop’s
voyado_contact_webhook_encryption_key.
Data flow
Shopify to Voyado
Triggered by webhooks and by per-minute polling commands (IndexCustomersCommand, IndexOrdersCommand, and the matching SyncContactsCommand, SyncOrdersCommand, SyncRefundsCommand).
1
Event received
A Shopify webhook fires, or a scheduled command detects a new or updated record.
2
Local copy stored
An index job stores the record locally so downstream jobs have a consistent snapshot.
3
Payload built
A sync job maps the Shopify record to the Voyado format and resolves the correct Voyado store from the country-to-store mapping.
4
Sent to Voyado
The payload is posted to the Voyado API. For contacts, the returned Voyado contact ID is written back to Shopify as a customer metafield (
voyado.contactId).Voyado to Shopify
When marketing preferences change in Voyado, Voyado calls the contact webhook. If the shop has “sync marketing preferences to Shopify” enabled, the app decrypts the payload and dispatchesSyncCustomerMarketingPreferences to update the matching Shopify customer. Contacts that were created by a third party are skipped.
Contact sync flow
Contact sync also runs on a schedule: thevoyado:sync-contacts command finds Shopify customers that need syncing and dispatches a SyncContact job for each one.
Each SyncContact job stops immediately if the shop doesn’t have an active Voyado account. Otherwise, it loads the customer record and follows one of two paths:
- Bulk import: the payload is built from the Shopify customer data and posted through the bulk-import creation path, which creates the contact in Voyado or reuses an existing match. The customer record is marked as a bulk import and stores the resulting Voyado ID.
- Regular sync: the app checks whether the customer is a POS customer, looks up the matching Voyado contact, and builds the payload (name, email, phone, address, language, consents, preferences, and store mapping). A matching contact is updated; otherwise, a new contact is created.
It’s not yet documented what distinguishes a “bulk import sync” run from a regular sync run for a given job, or what defines an “active Voyado account” for the purposes of the first check. Confirm both with the implementation owner.
Webhook event reference
Shopify webhooks (inbound)
All Shopify webhooks are registered by theSyncWebhooks action from config/esc-shopify.php and are served under the /shopify/webhooks prefix.
Voyado webhooks (inbound)
APIs mapped between Shopify and Voyado
Engage API (/api/v2)
The exact path for the order notification endpoint could not be verified from the repository. Confirm this with the implementation owner.
Shopify Admin API
The app uses both the REST Admin API and the GraphQL Admin API.Data mappings
Contact (Shopify customer → Voyado contact)
Built inContactService::getContactData. Empty or null values are omitted so that existing Voyado data isn’t overwritten unnecessarily.
Contact matching: the app looks up an existing Voyado contact by trying each contact type (
contact, member) against the shop’s configured identification sequence (email and/or phone). On a mobilePhone uniqueness conflict, the app retries without the phone number. A matched contact with a Shopify account is promoted to member. Contacts synced through the bulk-import creation path (see Contact sync flow) follow a separate creation path that reuses an existing match instead of applying this lookup sequence.
Order receipt (Shopify order → Voyado receipt)
Built inSyncOrder. Posted to POST receipts with type: PURCHASE line items.
Refund (Shopify refund → Voyado return receipt)
Built inSyncRefund. Posted to POST receipts with type: RETURN line items and negative quantities.
The refund job releases itself back to the queue if the related order hasn’t been synced yet, so refunds always follow their order. Shipping-only refunds with no line items are marked as ignored.
Order notification (Shopify order/fulfillment → Voyado notification)
Built inSyncOrderNotification and NotificationFormatterService. These are sent only when order notifications are enabled for the shop.
Notification types are:
order_created, order_fulfilled, order_partially_fulfilled, order_cancelled, and refund_created. Each type is sent at most once per order unless a resend is explicitly requested.
Promotion and voucher redemption
During order sync, if promotion or voucher sync is enabled for the shop, the app compares the discount codes used on the order against the contact’s available Voyado promotions and loyalty vouchers.- Matched promotions are redeemed through
POST contacts/{id}/promotions/{promoId}/redeemand added to the receipt asusedPromotions. - Matched vouchers are redeemed through
POST contacts/{id}/bonuschecks/{voucherId}/redeemand added to the receipt asusedBonusChecks.
ECOM channel for online orders and POS for point-of-sale orders.
Operational behavior
Scheduling and idempotency
- Polling commands run every minute as a backstop for missed webhooks.
- Sync jobs are unique (
ShouldBeUnique) per record, so duplicate dispatches don’t produce duplicate work. - Orders and refunds that have already synced (
last_synced_atis set) are skipped, and notifications are guarded by per-type “already sent” checks. - The Voyado
uniqueReceiptIdprevents duplicate receipts on the Voyado side.
Retries and resilience
Error handling
- Every job runs inside a task that logs each API call and payload for traceability.
- Failed syncs record the error against the record (
last_sync_errororlast_bulk_import_sync_error) and mark it as ignored where appropriate to avoid retry loops. - Missing country-to-store mappings create a visible mapping error rather than silently failing.
Technical considerations for implementers
- Store mapping is required. Receipts, refunds, and notifications all resolve a Voyado store from the order’s country. Without a mapping, records are skipped and a mapping error is raised.
- Currency conversion uses a third-party rate cached for 24 hours, converting the order currency to the shop’s configured Voyado group currency.
- Marketing preference direction is configurable per shop, in both directions (Shopify checkout to Voyado, and Voyado to Shopify). Third-party-created contacts are treated carefully to avoid overwriting preferences.
- Order and bulk-import queries must stay in sync. If you change the order flow, update the bulk import path and the paired queries (
OrderQueryandOrdersBulkImportQuery) together.