> ## Documentation Index
> Fetch the complete documentation index at: https://partner-integrations.voyado.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Technical details

> How the Voyado Engage app synchronizes data between Shopify and Voyado Engage, including data mappings, webhook events, and the APIs involved.

This article explains how the app moves data between Shopify and Engage. It covers the architecture, authentication, webhook events, the Shopify and Voyado APIs called, and the field-level data mappings applied for contacts, receipts, refunds, and order notifications.

This is a reference for implementers and technical consultants. If you're setting up the app for the first time, start with the prerequisites and how-to articles.

## 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** (`ShopifyApi` and `VoyadoApi`) wrap the outbound HTTP calls to each platform.

```mermaid theme={null}
flowchart TD
    A[Shopify webhook or scheduled poll] --> B[Index job stores local copy]
    B --> C[Sync job builds Voyado payload]
    C --> D[Transform and validate data]
    D --> E[Voyado Engage API]
    E --> F[Store Voyado ID as Shopify metafield]
    G[Voyado contact webhook] --> H[Sync marketing preferences to Shopify]
```

Most records follow a two-stage pattern:

1. An **index** job (for example `IndexCustomer`, `IndexOrder`, `IndexRefund`) pulls the full record from Shopify and stores a local copy.
2. 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` (default `2023-04`).
* Inbound Shopify webhooks are validated by the `ValidateShopifyWebhook` middleware before any processing happens.

The app requests the following OAuth scopes:

| Scope                                   | Purpose                                                  |
| --------------------------------------- | -------------------------------------------------------- |
| `read_customers`, `write_customers`     | Read customers and write the Voyado contact ID metafield |
| `read_orders`, `read_all_orders`        | Read orders for receipt sync                             |
| `read_products`                         | Enrich line items with product data                      |
| `read_fulfillments`                     | Build fulfillment notifications                          |
| `read_locales`                          | Resolve store locale and language                        |
| `write_pixels`, `read_customer_events`  | Manage the web pixel used for soft identification        |
| `write_script_tags`, `read_script_tags` | Manage storefront script tags                            |
| `write_discounts`                       | Support promotion and voucher redemption                 |

### Voyado Engage

* The app authenticates to the Voyado API using the shop's Voyado API key, sent in the `apikey` header against the shop's `voyado_api_domain` at the `/api/v2` base path.
* Inbound Voyado webhooks are validated by the `ValidateVoyadoWebhook` middleware.
* The Voyado contact webhook payload is encrypted; the app decrypts it using the shop's `voyado_contact_webhook_encryption_key`.

<Tip>
  Credentials are stored per shop. The app supports multiple merchants, and each request is scoped to a single shop resolved from the incoming request.
</Tip>

## Data flow

### Shopify to Voyado

Triggered by webhooks and by per-minute polling commands (`IndexCustomersCommand`, `IndexOrdersCommand`, and the matching `SyncContactsCommand`, `SyncOrdersCommand`, `SyncRefundsCommand`).

<Steps>
  <Step title="Event received">
    A Shopify webhook fires, or a scheduled command detects a new or updated record.
  </Step>

  <Step title="Local copy stored">
    An index job stores the record locally so downstream jobs have a consistent snapshot.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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`).
  </Step>
</Steps>

### 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 dispatches `SyncCustomerMarketingPreferences` 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: the `voyado:sync-contacts` command finds Shopify customers that need syncing and dispatches a `SyncContact` job for each one.

```mermaid theme={null}
flowchart TD
    A[Scheduled sync runs via voyado:sync-contacts] --> B[Find customers that need syncing]
    B --> C[Dispatch a SyncContact job for each customer]
    C --> D{Active Voyado account for the shop?}
    D -- No --> E[Stop sync]
    D -- Yes --> F[Start task and load customer data]

    F --> G{Is this a bulk import sync?}
    G -- Yes --> H[Use bulk-import create path]
    H --> I[Build contact payload from Shopify customer data]
    I --> J[Create contact in Voyado or reuse existing match]
    J --> K[Store Voyado ID and mark bulk import]

    G -- No --> L[Check whether the customer is a POS customer]
    L --> M[Look up existing Voyado contact]
    M --> N[Build payload with name, email, phone, address, language, consents, preferences, and store mapping]
    N --> O{Contact already exists?}
    O -- Yes --> P[Update the existing contact]
    O -- No --> Q[Create a new contact]
    P --> R[Create or update metafield and contact type]
    Q --> R
    R --> S[Update customer record with last synced time, Voyado ID, metafield ID, and POS flag]
    S --> T[Sync completed]

    K --> T
```

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.

Both paths finish by creating or updating the Shopify metafield and contact type, then updating the customer record with the last synced timestamp, the Voyado ID, the metafield ID, and the POS flag.

<Note>
  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.
</Note>

## Webhook event reference

### Shopify webhooks (inbound)

All Shopify webhooks are registered by the `SyncWebhooks` action from `config/esc-shopify.php` and are served under the `/shopify/webhooks` prefix.

| Topic                                                             | Endpoint                  | What it triggers                                                 |
| ----------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------- |
| `customers/create`                                                | `/customers/create`       | Contact sync to Voyado                                           |
| `customers/update`                                                | `/customers/update`       | Contact sync to Voyado                                           |
| `customers_marketing_consent/update`                              | `/customers/marketing`    | Marketing preference update                                      |
| `orders/create`                                                   | `/orders/create`          | Receipt sync and `OrderCreated` notification                     |
| `orders/fulfilled`                                                | `/orders/fulfilled`       | Receipt sync (when configured) and `OrderFulfilled` notification |
| `orders/cancelled`                                                | `/orders/cancelled`       | `OrderCancelled` notification                                    |
| `fulfillments/create`                                             | `/fulfillments/create`    | Order fulfillment notification                                   |
| `refunds/create`                                                  | `/refunds/create`         | Refund sync and `RefundCreated` notification                     |
| `shop/update`                                                     | `/shop/update`            | Shop details refresh                                             |
| `app/uninstalled`                                                 | `/app/uninstalled`        | App uninstall cleanup                                            |
| `bulk_operations/finish` (GraphQL topic `BULK_OPERATIONS_FINISH`) | `/bulk-operations/finish` | Bulk import/export processing                                    |

### Voyado webhooks (inbound)

| Event           | Endpoint                                | What it triggers                                  |
| --------------- | --------------------------------------- | ------------------------------------------------- |
| Contact updated | `/voyado/webhooks/contact/{shopDomain}` | Sync marketing preferences from Voyado to Shopify |

<Warning>
  The `{shopDomain}` path segment is encrypted, and the webhook body is encrypted with the shop's contact webhook encryption key.
</Warning>

## APIs mapped between Shopify and Voyado

### Engage API (`/api/v2`)

| Resource            | Method and path                                     | Used for                                                 |
| ------------------- | --------------------------------------------------- | -------------------------------------------------------- |
| Contact lookup      | `GET contacts/id`                                   | Find a contact by email or mobile phone and contact type |
| Contact detail      | `GET contacts/{id}`                                 | Read the current contact record                          |
| Create contact      | `POST contacts`                                     | Create a new contact                                     |
| Update contact      | `POST contacts/{id}`                                | Update an existing contact                               |
| Promote to member   | `POST contacts/{id}/promoteToMember`                | Upgrade a `contact` to `member`                          |
| Receipts            | `POST receipts`                                     | Create order receipts and refund (return) receipts       |
| Promotions          | `GET contacts/{id}/promotions`                      | Read available promotions                                |
| Redeem promotion    | `POST contacts/{id}/promotions/{promoId}/redeem`    | Redeem a matched promotion                               |
| Vouchers            | `GET contacts/{id}/bonuschecks/available`           | Read available loyalty vouchers                          |
| Redeem voucher      | `POST contacts/{id}/bonuschecks/{voucherId}/redeem` | Redeem a matched voucher                                 |
| Order notifications | Voyado order notification endpoint                  | Send order lifecycle updates                             |

<Note>
  The exact path for the order notification endpoint could not be verified from the repository. Confirm this with the implementation owner.
</Note>

### Shopify Admin API

The app uses both the REST Admin API and the GraphQL Admin API.

| Area            | API            | Examples                                                            |
| --------------- | -------------- | ------------------------------------------------------------------- |
| Customers       | GraphQL + REST | `CustomerQuery`, `customers/search.json`, customer metafields       |
| Orders          | GraphQL        | `OrderQuery`, `OrdersQuery`, `OrdersBulkImportQuery`                |
| Refunds         | GraphQL        | `RefundQuery`                                                       |
| Fulfillments    | GraphQL        | `FulfillmentQuery`                                                  |
| Locales         | GraphQL        | `LocalesQuery`                                                      |
| Discounts       | GraphQL        | `CodeDiscountNodeByCode`                                            |
| Bulk operations | GraphQL        | `BulkOperation`, `CurrentBulkOperation`, `CustomersBulkImportQuery` |
| Webhooks        | GraphQL + REST | `WebhookSubscriptionCreate`, `webhooks.json`                        |
| Web pixel       | GraphQL        | `WebPixelQuery`, `WebPixelDelete`                                   |
| Script tags     | REST           | `script_tags.json`                                                  |
| Metafields      | REST           | `customers/{id}/metafields`                                         |

## Data mappings

### Contact (Shopify customer → Voyado contact)

Built in `ContactService::getContactData`. Empty or null values are omitted so that existing Voyado data isn't overwritten unnecessarily.

| Voyado field                                | Shopify source                                                             |
| ------------------------------------------- | -------------------------------------------------------------------------- |
| `externalId`                                | Shopify customer ID                                                        |
| `contactType`                               | `member` for POS customers, otherwise the shop's configured match-key type |
| `firstName` / `lastName`                    | Customer first and last name                                               |
| `email`                                     | Customer email                                                             |
| `mobilePhone`                               | Customer phone, formatted for the store's country code                     |
| `street`                                    | Formatted street address                                                   |
| `city`, `country`, `countryCode`, `zipCode` | Corresponding customer address fields                                      |
| `language`                                  | Derived from customer locale                                               |
| `storeExternalId`                           | Resolved Voyado store (non-POS contacts)                                   |
| `consents[hasShopifyAccount]`               | `true` unless the customer is a guest; source `Shopify` or `Shopify POS`   |
| `preferences.acceptsEmail`                  | Email marketing subscribed state                                           |
| `preferences.acceptsSms`                    | SMS marketing subscribed state                                             |
| `preferences.acceptsPostal`                 | Always `false`                                                             |

**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](#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 in `SyncOrder`. Posted to `POST receipts` with `type: PURCHASE` line items.

| Voyado field                        | Shopify source                                                     |
| ----------------------------------- | ------------------------------------------------------------------ |
| `contact.matchKey` / `matchKeyType` | Voyado contact ID (`contactId`)                                    |
| `uniqueReceiptId`                   | Shopify order ID                                                   |
| `receiptNumber`                     | Order name                                                         |
| `createdDate`                       | Order created timestamp (ISO 8601)                                 |
| `storeExternalId`                   | Resolved Voyado store from country mapping                         |
| `exchangeRateToGroupCurrency`       | Conversion from order currency to the shop's Voyado group currency |
| `currency`                          | Order currency code                                                |
| `totalGrossPrice`                   | Order total price                                                  |
| `taxDetails[]`                      | Order tax lines (shipping tax removed)                             |
| `paymentMethods[]`                  | Order transactions (gateway and amount), with fallbacks            |
| `items[].grossPaidPrice`            | Discounted line total minus non-script discount allocations        |
| `items[].taxAmount` / `taxPercent`  | Line tax amount and rate                                           |
| `items[].sku` / `articleNumber`     | Line SKU (`n/a` when missing)                                      |
| `items[].articleName`               | Line title                                                         |
| `items[].discounts[]`               | Per-line discount allocations (as negative values)                 |
| `usedPromotions[]`                  | Matched Voyado promotion IDs                                       |
| `usedBonusChecks[]`                 | Matched Voyado voucher check numbers                               |

<Warning>
  Script discount allocations are intentionally excluded from the per-line discount total to avoid discounting the amount twice, because Shopify's GraphQL `discountedTotalSet` already deducts them.
</Warning>

### Refund (Shopify refund → Voyado return receipt)

Built in `SyncRefund`. Posted to `POST receipts` with `type: RETURN` line items and negative quantities.

| Voyado field       | Shopify source                                                                                                     |
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `contact.matchKey` | Voyado contact ID from the related order                                                                           |
| `uniqueReceiptId`  | Shopify refund ID                                                                                                  |
| `receiptNumber`    | Related order name                                                                                                 |
| `createdDate`      | Refund created timestamp                                                                                           |
| `storeExternalId`  | Resolved Voyado store                                                                                              |
| `totalGrossPrice`  | Negative total refunded amount                                                                                     |
| `taxDetails[]`     | Single "Tax Refund" line summing line-item tax                                                                     |
| `paymentMethods[]` | Refund transactions (negative), with a `shopify_payments` fallback                                                 |
| `items[]`          | Refunded line items with negative quantities; a "Custom Refund Amount" line is used when no line items are present |

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 in `SyncOrderNotification` and `NotificationFormatterService`. These are sent only when order notifications are enabled for the shop.

| Voyado field                                         | Shopify source                                                                                      |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `contact.matchKey`                                   | Order's overridden contact ID, otherwise customer email                                             |
| `orderStatus`                                        | Derived status (`FULFILLED`, `PARTIALLY_FULFILLED`, `CANCELLED`, with `_POS` suffix for POS orders) |
| `paymentStatus`                                      | Derived from order transactions                                                                     |
| `orderNumber`                                        | Order name                                                                                          |
| `createdDate` / `statusChangedDate` / `shippingDate` | Order and fulfillment timestamps                                                                    |
| `storeId`                                            | Resolved Voyado store                                                                               |
| `currency`, `language`                               | Order currency and customer language                                                                |
| `totalGrossPrice`, `totalTax`                        | Order totals                                                                                        |
| `freightFee`                                         | Shipping price and shipping tax                                                                     |
| `taxDetails[]`                                       | Order tax lines                                                                                     |
| `extraData`                                          | Shipping and billing address, tracking info, shipping method, and payment method                    |
| `items[]`                                            | Line items enriched with description, image URL, and product target URL                             |

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}/redeem` and added to the receipt as `usedPromotions`.
* Matched **vouchers** are redeemed through `POST contacts/{id}/bonuschecks/{voucherId}/redeem` and added to the receipt as `usedBonusChecks`.

Redemption uses the `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_at` is set) are skipped, and notifications are guarded by per-type "already sent" checks.
* The Voyado `uniqueReceiptId` prevents duplicate receipts on the Voyado side.

### Retries and resilience

| Concern                 | Behavior                                                                                                     |
| ----------------------- | ------------------------------------------------------------------------------------------------------------ |
| Voyado API retries      | Up to 3 retries with a 2-second delay on HTTP 429, server errors, and connection failures; 30-second timeout |
| `SyncContact`           | 2 tries, 2-minute backoff                                                                                    |
| `SyncOrder`             | 3 tries, 5-minute backoff                                                                                    |
| `SyncRefund`            | 3 tries, 10-minute backoff; releases if the related order isn't synced                                       |
| `SyncOrderNotification` | 2 tries, 2-minute backoff                                                                                    |
| Store mapping missing   | Record is marked `sync_ignored`, and a mapping error is logged for the merchant to resolve                   |

### 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_error` or `last_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 (`OrderQuery` and `OrdersBulkImportQuery`) together.

## Related documentation

<Card title="Contacts" icon="https://mintcdn.com/voyado-partners/0IxEIB2Y6a--gNYY/icons/developer-link.png?fit=max&auto=format&n=0IxEIB2Y6a--gNYY&q=85&s=36f1df27b0269657d842f3301d440083" href="https://developer.voyado.com/en/contacts.html" width="128" height="128" data-path="icons/developer-link.png" />

<Card title="Promotions" icon="https://mintcdn.com/voyado-partners/0IxEIB2Y6a--gNYY/icons/developer-link.png?fit=max&auto=format&n=0IxEIB2Y6a--gNYY&q=85&s=36f1df27b0269657d842f3301d440083" href="https://developer.voyado.com/en/promotions.html" width="128" height="128" data-path="icons/developer-link.png" />

<Card title="Rewards and vouchers" icon="https://mintcdn.com/voyado-partners/0IxEIB2Y6a--gNYY/icons/developer-link.png?fit=max&auto=format&n=0IxEIB2Y6a--gNYY&q=85&s=36f1df27b0269657d842f3301d440083" href="https://developer.voyado.com/en/rewards-and-vouchers.html" width="128" height="128" data-path="icons/developer-link.png" />
