Commerce and Order Management
Eventuall integrates with Shopify to sell event tickets and automatically grant room access when customers complete purchases. The system also supports importing orders from CSV files for retailers that don't have direct webhook integrations. This document covers the Shopify integration, the webhook pipeline, the order import system, and the SKU mapping layer that ties external commerce to internal access control.
Shopify Integration Overview
The Shopify integration is a server-side system that lives entirely in the webapp. It uses the Shopify Admin GraphQL API (version 2025-04) through the @shopify/admin-api-client package. There's no client-side Shopify code — all Shopify communication happens in tRPC procedures and webhook handlers.
The integration serves two purposes:
- Outbound: Creating products in Shopify that represent event tickets, so organizers can sell tickets through their Shopify store
- Inbound: Receiving webhook notifications when orders are paid, and automatically granting room access to customers
graph LR
subgraph "Eventuall"
Admin["Admin Dashboard"]
WebhookHandler["Webhook Handler"]
ShopifyService["ShopifyService"]
DB["D1 Database"]
EmailService["EmailService"]
end
subgraph "Shopify"
Store["Shopify Store"]
AdminAPI["Admin GraphQL API"]
Webhooks["Webhook System"]
end
Admin -->|Create event product| ShopifyService
ShopifyService -->|GraphQL mutations| AdminAPI
AdminAPI -->|Product created| Store
Store -->|Customer pays| Webhooks
Webhooks -->|orders/paid| WebhookHandler
WebhookHandler -->|Process order| ShopifyService
ShopifyService -->|Grant access| DB
ShopifyService -->|Send reminder| EmailService
API Client Setup
The Shopify client is initialized in apps/webapp/src/server/integrations/shopify.ts. It creates an Admin API client configured with the store domain, API version, and access token from environment variables:
import { createAdminApiClient } from "@shopify/admin-api-client";
export async function createShopifyClient() {
const client = createAdminApiClient({
storeDomain: process.env.SHOPIFY_SHOP_NAME,
apiVersion: "2025-04",
accessToken: process.env.SHOPIFY_ADMIN_ACCESS_TOKEN,
});
return { client };
}
The client is used exclusively in ShopifyService — no other part of the codebase talks to Shopify directly.
Environment Variables
| Variable | Purpose |
|---|---|
SHOPIFY_SHOP_NAME |
Store domain (e.g., 9d1e1b-e5.myshopify.com) |
SHOPIFY_ADMIN_ACCESS_TOKEN |
App password for Admin API access (prefixed shpat_) |
SHOPIFY_STOREFRONT_ACCESS_TOKEN |
For storefront queries |
SHOPIFY_API_KEY |
App API key |
SHOPIFY_API_SECRET |
App API secret |
SHOPIFY_VENDOR_IDENTIFIER |
Vendor name set on created products |
Creating Event Products in Shopify
When an organizer wants to sell tickets through Shopify, the system creates a product in their Shopify store. This is handled by ShopifyService.createEventInShopify().
The process:
- Generate a unique SKU via
SkuService.createUniqueSku()— this becomes the identifier that links a Shopify line item back to an Eventuall room - Create a room access ticket in the database that maps the SKU to a specific room and role (defaults to "Participant" role with a "General Admission" label)
- Create the Shopify product using the
productSetGraphQL mutation with:- Title matching the event name
- Product type "Event Ticket"
- A single variant with the generated SKU
- Custom metafields storing
eventId,roomId,eventUrl, anddeepLinkUrl - Assigned to "Events" and "Home page" collections
- Publish the product to sales channels: Online Store, Google & YouTube, Facebook & Instagram, and Shop Button
sequenceDiagram
participant Admin as Admin Dashboard
participant Service as ShopifyService
participant SkuService as SkuService
participant DB as D1 Database
participant Shopify as Shopify Admin API
Admin->>Service: createEventInShopify(event, room)
Service->>SkuService: createUniqueSku()
SkuService-->>Service: SKU ID
Service->>SkuService: createRoomAccessTicket(roomId, roleId, skuId)
SkuService->>DB: Insert roomAccessTicket + skuProductLink
Service->>Shopify: getCollectionsQuery
Shopify-->>Service: Collection IDs
Service->>Shopify: productSetMutation (title, SKU, metafields)
Shopify-->>Service: Product ID
Service->>Shopify: getPublicationsQuery
Shopify-->>Service: Publication IDs
Service->>Shopify: publishablePublishMutation
Shopify-->>Service: Published
The product is created in DRAFT status with inventoryPolicy: "CONTINUE" (meaning it can be sold even when out of stock) and requiresShipping: false (since it's a digital ticket).
Architect's Note: The metafields on the Shopify product (
custom.eventId,custom.roomId,custom.eventUrl,custom.deepLinkUrl) serve as the bridge between the two systems. When querying Shopify for products related to an event, the system searches bymetafields.custom.eventIdto find matches. ThedeepLinkUrlmetafield gives Shopify theme developers a URL to link customers directly to their event room after purchase.
Shopify Webhook Pipeline
The webhook handler is the core of the automated access-granting system. When a customer pays for an order in Shopify, Shopify sends a webhook to Eventuall, which processes the order and grants room access.
Webhook Endpoint
The handler lives at apps/webapp/src/app/api/webhook/shopify/[...event]/route.ts. The catch-all route segment [...event] captures the webhook event type (e.g., orders/paid).
POST /api/webhook/shopify/orders/paid
The handler:
- Parses the incoming JSON body
- Joins the route segments to determine the event type
- For
orders/paid, validates the payload againstwebhookOrderSchema(a Zod schema) - Delegates to
ShopifyService.orderPaid() - Returns
200with"Webhook received"
Currently, orders/paid is the only webhook event handled. The catch-all route structure makes it straightforward to add more events in the future.
Order Processing (`orderPaid`)
When a paid order comes in, the processing flow walks through each line item's SKU and resolves it to internal access grants:
sequenceDiagram
participant Shopify as Shopify Webhook
participant Handler as Webhook Route
participant Service as ShopifyService
participant DB as D1 Database
participant Users as UsersService
participant Rooms as RoomService
participant Email as EmailService
Shopify->>Handler: POST /api/webhook/shopify/orders/paid
Handler->>Handler: Validate with webhookOrderSchema
Handler->>Service: orderPaid(order)
loop For each line item SKU
Service->>DB: Find skuProductLinks by SKU
Service->>DB: Find productType for link
alt productType is roomAccessTickets
Service->>DB: Find roomAccessTicket
Service->>DB: Find room (for eventId)
Service->>Users: getUserByEmail(customer.email)
alt User doesn't exist
Service->>Users: createUserProfile(customer data)
end
Service->>Rooms: grantRoomAccessTicketByEmail(ticketId, email)
end
end
Service->>Email: sendEventRemindersGroupedByEvent()
Handler-->>Shopify: 200 "Webhook received"
The key steps in ShopifyService.orderPaid():
- Extract SKUs from
order.line_items[].sku - Look up each SKU in the
skuProductLinkstable to find what product it maps to - Resolve the product type — currently only
roomAccessTicketsis supported, but the switch statement is designed to support additional product types - Find the room access ticket and its associated room
- Find or create the user — if no user exists with the customer's email, a new profile is created from the Shopify customer data (name, email)
- Grant room access via
RoomService.grantRoomAccessTicketByEmail() - Send event reminder emails grouped by event to avoid duplicate emails when an order contains multiple line items for the same event
Pitfall: The webhook handler does not currently validate the Shopify HMAC signature. The payload is validated structurally via the Zod schema, but there's no cryptographic verification that the request actually came from Shopify. The webhook URL is unguessable but not authenticated. This is a known gap.
Architect's Note: Email sending is wrapped in a try-catch that logs failures but doesn't fail the order processing. This is intentional — access granting is the critical operation, and email delivery failures shouldn't prevent a customer from getting access to their event.
Webhook Schema Validation
The webhookOrderSchema in apps/webapp/src/server/schemas/shopify.schema.ts is a comprehensive Zod schema that validates the full Shopify order webhook payload. It covers:
- Order metadata (id, name, email, status, timestamps)
- Customer information
- Line items with SKU, quantity, and pricing
- Billing and shipping addresses
- Discount codes and applications
- Tax lines
- Fulfillment information
- Refund data
- Transaction records
Every field is marked as .nullable().optional() because Shopify's webhook payloads vary depending on the order type and the store's configuration. The schema is permissive on purpose — it ensures the data is structurally valid without rejecting orders that happen to be missing optional fields.
Order Import from CSV
Not all ticket sales come through Shopify's webhook pipeline. Organizers may sell tickets through other retailers (Eventbrite, Stripe, manual sales) or need to backfill historical orders. The CSV import system handles these cases.
How CSV Import Works
The import process is triggered from the admin dashboard's Orders tab via the Import Orders modal (ImportOrdersModal.tsx). Users upload a CSV file, select a retailer, and the system processes it through the importOrdersFromCsv tRPC procedure.
sequenceDiagram
participant Admin as Admin Dashboard
participant Modal as ImportOrdersModal
participant tRPC as order.importOrdersFromCsv
participant DB as D1 Database
participant Rooms as RoomService
participant Users as UsersService
participant Email as EmailService
Admin->>Modal: Upload CSV file
Modal->>tRPC: csvContent, eventId, retailerId
tRPC->>tRPC: Parse CSV (csv-parse/sync)
tRPC->>DB: Load SKU mappings for event + retailer
tRPC->>DB: Load event room access ticket SKUs
loop For each order group (by order ID or email)
tRPC->>DB: Insert order record
tRPC->>DB: Insert line items (with SKU resolution)
alt Order status is "paid"
loop For each mapped line item
tRPC->>Users: Find or create user profile
tRPC->>Rooms: grantRoomAccessTicketByEmail()
end
end
end
tRPC->>Email: sendEventRemindersGroupedByEvent()
tRPC-->>Modal: Import results (success/failed/unmapped)
CSV Format
The import supports both Shopify's native export format and a generic CSV format. Column matching is case-insensitive and handles multiple naming conventions:
| Data | Accepted Column Names |
|---|---|
| Email (required) | customer_email, email, customeremail |
| Order ID | order_id, orderid, order_number, ordernumber, name |
| Status | status, order_status, financial_status |
| Order date | order_date, orderdate, date, created_at |
| Customer name | customer_name, customername, billing_name, shipping_name |
| First name | first_name, firstname, given_name |
| Last name | last_name, lastname, family_name |
| SKU | sku, product_sku, item_sku, lineitem_sku |
| Item name | item_name, itemname, product_name, lineitem_name |
| Quantity | quantity, qty, lineitem_quantity |
| Unit price | unit_price, unitprice, price, lineitem_price |
| Total price | total_price, totalprice, total |
Valid order statuses: pending, paid, partially_fulfilled, fulfilled, cancelled, refunded.
Order Grouping
CSV rows are grouped into orders. If a row has an order_id, all rows with the same order ID become line items under a single order. If there's no order ID, rows are grouped by email address. This means a CSV from Shopify (which includes order numbers) will correctly group multi-item orders, while a simpler CSV with just email and SKU will create one order per email.
Access Granting on Import
When a CSV order has a status of paid, the import process automatically grants room access for each mapped line item — the same logic as the webhook pipeline. Users who don't yet have accounts get a pending profile created from their CSV data. Event reminder emails are sent after the import completes, deduplicated by event per user.
The import returns a results object with counts of successful imports, failures, errors, and a list of unmapped SKUs that need attention.
SKU Mapping System
The SKU mapping system is the bridge between external commerce platforms and Eventuall's internal access control. It solves a fundamental problem: external retailers use their own SKU identifiers, but Eventuall needs to map those to specific room access tickets.
How SKU Resolution Works
There are two ways a SKU gets resolved to internal access:
Direct match — The SKU in the order exactly matches an internal SKU created by
SkuService.createUniqueSku()and linked viaskuProductLinks. This is the path for orders coming through the Shopify webhook, where the SKU was generated by Eventuall.External mapping — The external SKU is manually mapped to one or more internal SKUs through the
externalSkuMappingstable. This is the path for CSV imports and other retailers where the external SKU doesn't match an internal one.
graph TB
subgraph "External Commerce"
ShopifyOrder["Shopify Order
SKU: EVT-ABC123"]
CSVOrder["CSV Import
SKU: RETAILER-XYZ"]
end
subgraph "SKU Resolution"
DirectMatch["Direct Match
skuProductLinks"]
ExternalMapping["External Mapping
externalSkuMappings"]
MappingSkus["Mapping SKUs
externalSkuMappingSkus"]
end
subgraph "Internal Access"
SkuProductLink["skuProductLinks"]
ProductType["productTypes"]
RoomAccessTicket["roomAccessTickets"]
AccessGrant["Room Access Grant"]
end
ShopifyOrder -->|SKU matches internal| DirectMatch
CSVOrder -->|SKU needs mapping| ExternalMapping
ExternalMapping --> MappingSkus
MappingSkus --> SkuProductLink
DirectMatch --> SkuProductLink
SkuProductLink --> ProductType
ProductType -->|tableName: roomAccessTickets| RoomAccessTicket
RoomAccessTicket --> AccessGrant
Database Tables
| Table | Purpose |
|---|---|
retailers |
Stores retailer/platform info (Shopify, Eventbrite, etc.) |
externalSkuMappings |
Maps an external SKU string to an event + retailer combination |
externalSkuMappingSkus |
Junction table linking a mapping to one or more internal SKUs |
orders |
Order records with customer info, status, and retailer reference |
orderLineItems |
Individual line items with external SKU, resolved internal SKU, and mapping reference |
skuProductLinks |
Links an internal SKU to a product type and product ID |
productTypes |
Defines product categories (currently only roomAccessTickets) |
roomAccessTickets |
Defines a ticket granting a specific role in a specific room |
Managing SKU Mappings
SKU mappings are managed per event and per retailer through the admin dashboard. When an organizer creates a mapping:
- The external SKU string is recorded (case-insensitive matching)
- One or more internal SKUs are linked via the junction table
- Any existing unmapped order line items matching that external SKU are retroactively updated
- For paid orders with newly mapped SKUs, room access is granted immediately
This retroactive mapping is important — it means organizers can import orders first, then set up mappings afterward. The system will catch up and grant access for all previously imported paid orders.
Architect's Note: The multi-retailer, multi-SKU mapping design is intentional even though most events currently use a single Shopify store. Event organizers sometimes sell tickets through multiple channels (their own website, third-party retailers, box office sales), each with different SKU schemes. The mapping system accommodates this without requiring every retailer to use the same SKUs.
Viewing Orders in the Dashboard
The admin dashboard provides two views for orders:
Shopify Orders View
The ShopifyOrdersSection component fetches orders directly from the Shopify Admin API in real-time. It calls getShopifyOrdersForEvent, which:
- Finds all room access ticket SKUs for the event
- Queries Shopify's GraphQL API for orders containing those SKUs
- Returns comprehensive order data including customer details, payments, fulfillments, refunds, and timeline events
This view shows a summary table with order status badges, and a detailed order viewer with tabs for Overview, Items, Payments, Fulfillment, Timeline, and Customer information. Each order links directly to the Shopify admin for the full order management experience.
Imported Orders View
The OrdersTable component shows orders stored in the local database — those imported via CSV or created manually. This view supports search, filtering by retailer and status, and pagination. Unmapped SKUs are highlighted so organizers know which mappings still need to be configured.
Key Files Reference
| File | Purpose |
|---|---|
server/integrations/shopify.ts |
Shopify Admin API client setup |
server/services/shopify.service.ts |
Core Shopify business logic |
server/schemas/shopify.schema.ts |
Webhook payload Zod validation |
server/schemas/order.schema.ts |
Order CRUD and import schemas |
server/api/routers/order/order.router.ts |
All order-related tRPC procedures |
shopify/graphql-queries.ts |
Shopify GraphQL queries and mutations |
app/api/webhook/shopify/[...event]/route.ts |
Webhook endpoint |
app/dashboard/.../\@orders/ShopifyOrdersSection.tsx |
Shopify orders UI |
app/dashboard/.../\@orders/ImportOrdersModal.tsx |
CSV import UI |