Credit Loading via Shopify Checkout
UberLotto uses Shopify's native checkout system to let users purchase "Load Credits" (UBL Points). This approach leverages Shopify's PCI-compliant checkout infrastructure for credit card processing while using a dedicated Shopify product collection to represent credit denominations.
Key Files
| File | Purpose |
|---|---|
app/routes/api.shopify-checkout.ts | POST /api/shopify-checkout — creates a checkout cart |
app/utils/shopify-checkout.server.ts | Server utilities: fetch variants, create cart, validate prices |
app/utils/validation.server.ts | Request validation: body structure, email, token, size checks |
app/graphql/load-credits/LoadCreditsQuery.ts | GraphQL queries for collection + cart creation |
How It Works
Instead of a custom payment form, UberLotto creates a Shopify cart containing a single "Load Credits" product variant (e.g., "$50 UBL Points") and redirects the user to Shopify's hosted checkout.
User selects credit amount (e.g., $50)
│
▼
┌──────────────────────────┐
│ POST /api/shopify- │
│ checkout │
│ { amount: "50" } │
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ Security Checks: │
│ • Content-Type check │
│ • Body size ≤ 1KB │
│ • CSRF origin check │
│ • Auth required │
│ • Input validation │
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ Fetch "load-credits" │ ← Storefront API: collection query
│ collection variants │ returns available denominations
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ Validate amount against │ ← Amount must match a real variant
│ available variants │ AND variant price must match
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ Create cart via │ ← Storefront API: cartCreate mutation
│ Storefront API │ with variant ID + buyer identity
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ Return checkoutUrl │ ← User redirected to Shopify checkout
│ to client │
└──────────────────────────┘Security Layers
The checkout endpoint implements multiple security checks before processing:
1. Content-Type Validation
if (!isValidContentType(contentType)) {
return Response.json({ error: 'Content-Type must be application/json' }, { status: 415 });
}2. Request Body Size Limit
Maximum body size of 1KB to prevent payload-based attacks:
if (!isWithinSizeLimit(request, 1024)) {
return Response.json({ error: 'Request body too large' }, { status: 413 });
}3. CSRF Protection (Origin Validation)
Validates the Origin or Referer header against a whitelist of allowed domains:
const allowedDomains = [
'localhost', '127.0.0.1',
'playuberlotto.myshopify.com',
'uberlotto.com', 'www.uberlotto.com',
'dev.uberlotto.com',
'uberlotto.promesolutions.com',
];Requests missing both Origin and Referer headers are rejected to prevent CSRF bypass.
Development Mode
CSRF validation is relaxed in development (when PUBLIC_STORE_DOMAIN includes localhost or promesolutions.com).
4. Authentication Required
await context.customerAccount.handleAuthStatus();Users must be logged in via Shopify Customer Account API. Unauthenticated requests receive a 401 response.
5. Input Validation
The validateCheckoutRequest() function performs comprehensive checks:
- amount (required): must be a non-empty string, max 20 characters
- email (optional): RFC 5322 format, max 254 characters, no header injection characters
- customerAccessToken (optional): 10-500 characters, alphanumeric only
- Unexpected fields are rejected (prevents injection of arbitrary data)
6. Price Validation
After fetching the variant from Shopify, the endpoint verifies the variant's actual price matches the requested amount:
const variantPrice = parseFloat(variant.price.amount);
const requestedAmount = parseFloat(amount);
if (Math.abs(variantPrice - requestedAmount) > 0.01) {
// Price mismatch — possible manipulation attempt
return Response.json({ error: 'Price validation failed' }, { status: 400 });
}This prevents attacks where a user might try to purchase a higher-value credit for a lower price.
Load Credits Collection
The "load-credits" Shopify collection contains products representing different credit denominations. Each product has a single variant whose price equals the credit amount.
Variant Fetching
const variantsMap = await fetchLoadCreditVariants(storefront, 'load-credits');
// Returns: { "50": LoadCreditVariant, "100": LoadCreditVariant, ... }The fetchLoadCreditVariants() function:
- Queries the Shopify Storefront API for the collection
- Iterates all products and their variants
- Builds a map keyed by rounded price amount (e.g.,
"50","100") - Only includes variants that are
availableForSale
Cart Creation
const { checkoutUrl, cart } = await createCheckoutForVariant(
storefront,
variant.id, // gid://shopify/ProductVariant/...
1, // Quantity is always 1
buyerIdentity, // Optional: email, customerAccessToken, countryCode
);The function validates:
- Variant ID format (must start with
gid://shopify/ProductVariant/) - Quantity bounds (1-100)
Request / Response
Request
POST /api/shopify-checkout
Content-Type: application/json
{
"amount": "50",
"email": "user@example.com",
"customerAccessToken": "abc123def456"
}Success Response
{
"success": true,
"checkoutUrl": "https://checkout.playuberlotto.myshopify.com/cart/c/...",
"cart": {
"id": "gid://shopify/Cart/...",
"totalQuantity": 1,
"totalAmount": "50.00",
"currencyCode": "USD"
}
}Error Codes
| Code | Status | Description |
|---|---|---|
METHOD_NOT_ALLOWED | 405 | Not a POST request |
INVALID_CONTENT_TYPE | 415 | Content-Type not application/json |
PAYLOAD_TOO_LARGE | 413 | Body exceeds 1KB |
MISSING_ORIGIN | 403 | No Origin or Referer header |
INVALID_ORIGIN | 403 | Origin not in whitelist |
AUTHENTICATION_REQUIRED | 401 | User not logged in |
VALIDATION_FAILED | 400 | Input validation errors |
AMOUNT_UNAVAILABLE | 400 | Credit amount not in collection |
PRICE_MISMATCH | 400 | Variant price doesn't match amount |
CHECKOUT_FAILED | 500 | Cart creation failed |
Environment Notes
Oxygen Deployment
In-memory rate limiting does not work on Shopify Oxygen because it runs on stateless edge workers with no persistent memory. The checkout endpoint relies on Shopify's built-in API rate limits instead.
No additional environment variables are required beyond the standard Shopify Hydrogen configuration (PUBLIC_STORE_DOMAIN, PUBLIC_STOREFRONT_API_TOKEN, etc.).