Skip to content

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

FilePurpose
app/routes/api.shopify-checkout.tsPOST /api/shopify-checkout — creates a checkout cart
app/utils/shopify-checkout.server.tsServer utilities: fetch variants, create cart, validate prices
app/utils/validation.server.tsRequest validation: body structure, email, token, size checks
app/graphql/load-credits/LoadCreditsQuery.tsGraphQL 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

typescript
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:

typescript
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:

typescript
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

typescript
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:

typescript
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

typescript
const variantsMap = await fetchLoadCreditVariants(storefront, 'load-credits');
// Returns: { "50": LoadCreditVariant, "100": LoadCreditVariant, ... }

The fetchLoadCreditVariants() function:

  1. Queries the Shopify Storefront API for the collection
  2. Iterates all products and their variants
  3. Builds a map keyed by rounded price amount (e.g., "50", "100")
  4. Only includes variants that are availableForSale

Cart Creation

typescript
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

json
POST /api/shopify-checkout
Content-Type: application/json

{
  "amount": "50",
  "email": "[email protected]",
  "customerAccessToken": "abc123def456"
}

Success Response

json
{
  "success": true,
  "checkoutUrl": "https://checkout.playuberlotto.myshopify.com/cart/c/...",
  "cart": {
    "id": "gid://shopify/Cart/...",
    "totalQuantity": 1,
    "totalAmount": "50.00",
    "currencyCode": "USD"
  }
}

Error Codes

CodeStatusDescription
METHOD_NOT_ALLOWED405Not a POST request
INVALID_CONTENT_TYPE415Content-Type not application/json
PAYLOAD_TOO_LARGE413Body exceeds 1KB
MISSING_ORIGIN403No Origin or Referer header
INVALID_ORIGIN403Origin not in whitelist
AUTHENTICATION_REQUIRED401User not logged in
VALIDATION_FAILED400Input validation errors
AMOUNT_UNAVAILABLE400Credit amount not in collection
PRICE_MISMATCH400Variant price doesn't match amount
CHECKOUT_FAILED500Cart 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.).

UberLotto Technical Documentation