Guest Users

Guest checkout and email-only user support

Overview

Zooly Auth supports a Shopify-style purchase flow where users can buy with just an email (no login). These guest users can later register and link their accounts to preserve purchase history.

Guest User Model

Two Identity States

The identities table supports two identity states:

  1. Cognito-linked users (authenticated)

    • user_id is Cognito sub
    • Normal auth flow
    • Email comes from Cognito ID token (source of truth)
  2. Email-only users (guest checkout)

    • user_id is generated by our system
    • guest_email is stored
    • No Cognito sub yet
    • Can make purchases without registration

Identity Structure

Email-only user (before linking):

{
  "user_id": "user-guest-123",
  "guest_email": "guest@example.com",
  "display_name": "Optional Name",
  "cognito_sub": null,
  "roles": [],
  "created_at": 1700000000,
  "updated_at": 1700000000
}

Linked user (after registration):

{
  "user_id": "user-guest-123",
  "guest_email": "guest@example.com",
  "display_name": "Optional Name",
  "cognito_sub": "cognito-sub-xyz",
  "roles": [],
  "created_at": 1700000000,
  "updated_at": 1700600000
}

Important: Email is NOT stored in DynamoDB. For Cognito-linked users, email comes from Cognito ID token (source of truth). The guest_email field is retained for historical reference.

Creating Guest Users

getOrCreateByEmail Function

Use getOrCreateByEmail to get or create a guest identity:

import { getOrCreateByEmail } from '@zooly/auth-db';

// In your checkout endpoint
export async function POST(request: Request) {
  const { email, name } = await request.json();
  
  // Get existing identity or create new guest identity
  const identity = await getOrCreateByEmail(email, name);
  
  // Use identity.user_id for purchase association
  const purchase = await createPurchase({
    userId: identity.user_id,
    // ... other purchase data
  });
  
  return Response.json({ 
    userId: identity.user_id,
    purchaseId: purchase.id 
  });
}

Behavior

  • If a user exists for the email (by guest_email), returns their user_id
  • If not, creates a new row in DynamoDB with guest_email and optional display_name
  • Returns the user_id for purchase association
  • These users do not have a Cognito user

Guest Checkout Flow

1. User enters email at checkout
2. Backend calls getOrCreateByEmail(email, name?)
3. System returns user_id (existing or new)
4. Purchase is associated with user_id
5. User completes purchase without registration

Linking on Registration

When a user registers with an email that was previously used for a guest checkout:

Automatic Linking

The system automatically links Cognito identities to existing guest identities:

  1. User registers with email guest@example.com
  2. System checks for existing identity with matching guest_email
  3. If found:
    • Links Cognito identity to existing user_id
    • Sets cognito_sub on existing identity
    • Preserves all purchase history and data
    • Email now comes from Cognito (source of truth)
    • guest_email is retained for historical reference

Implementation

The linking happens automatically in the signup/login flow:

// In @zooly/auth-srv signup/login functions
import { findIdentityByEmail, linkCognitoIdentity } from '@zooly/auth-db';

async function signUp(email: string, password: string) {
  // Create Cognito user
  const cognitoUser = await cognito.signUp(email, password);
  
  // Check for existing guest identity
  const existingIdentity = await findIdentityByEmail(email);
  
  if (existingIdentity && !existingIdentity.cognito_sub) {
    // Link Cognito identity to existing guest identity
    await linkCognitoIdentity(existingIdentity.user_id, cognitoUser.sub);
    
    // Return existing user_id (stable across linking)
    return existingIdentity.user_id;
  }
  
  // Create new identity for new user
  const identity = await upsertIdentity({
    user_id: cognitoUser.sub,
    cognito_sub: cognitoUser.sub,
    // ... other fields
  });
  
  return identity.user_id;
}

Benefits

This design enables:

  • Guest purchase now - Users can buy without registration
  • Optional registration later - Users can register anytime
  • Full purchase history visible after login - All purchases linked to same user_id
  • Seamless experience - No data loss when linking accounts

API Endpoint

POST /api/users/get-or-create

Resolves an email to a stable user_id. The endpoint checks in order:

  1. DynamoDB guest_email -- returns existing guest identity if found
  2. Cognito user pool -- queries for a registered user with that email, then looks up their DynamoDB identity by sub. This ensures registered users get their real user_id, not a duplicate guest.
  3. Create guest -- if neither exists, creates a new guest identity via getOrCreateByEmail

If the Cognito lookup fails (e.g. network issue), it falls through to guest creation with a warning log.

Usage:

// In your checkout app
const response = await fetch('https://auth.zooly.ai/api/users/get-or-create', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ 
    email: 'guest@example.com',
    name: 'Guest User' // optional
  }),
});

const { user_id } = await response.json();
// Use user_id for purchase association

Purchase Association

Always associate purchases with user_id, regardless of login state:

// Purchase table
{
  purchase_id: "purchase-123",
  user_id: "user-guest-123", // Same user_id before and after linking
  // ... other purchase data
}

// After user registers and links:
// - user_id remains the same
// - All purchases remain associated
// - User sees full purchase history

User Profile After Linking

After linking, the user profile combines:

  • Email: From Cognito ID token (source of truth)
  • Display Name: From DynamoDB (can be updated)
  • Avatar: From DynamoDB (can be updated)
  • Roles: From DynamoDB (can be updated)
  • Purchase History: All purchases associated with user_id

Migration Considerations

If you're adding guest user support to an existing system:

  1. Existing users: Already have cognito_sub, no changes needed
  2. New guest users: Created with generated user_id and guest_email
  3. Linking: Happens automatically on first registration/login
  4. Data integrity: user_id remains stable across linking

Merch Integration

The merch purchase flow uses guest users to associate orders with a stable identity:

  1. When POST /api/merch/create-payment-intent is called, the route calls the auth service (POST /api/users/get-or-create) with the buyer's email and name from shipping info.
  2. The returned user_id is stored on the merch_session and passed to createMerchPaymentIntent as buyerUserId.
  3. When POST /api/merch/order/complete is called, the user_id from the session is stored on the merch_order.

This means:

  • stripe_payments.buyerUserId contains the guest user_id (not the session ID).
  • merch_order.user_id links the order to the guest identity.
  • If the buyer later registers with the same email, all their orders are visible via the same user_id.

The guest user resolution is non-blocking: if the auth service is unreachable, the payment proceeds without a user_id and falls back to using the session ID.

Best Practices

  1. Always use user_id - Never use email as foreign key
  2. Preserve guest_email - Keep for historical reference after linking
  3. Email from Cognito - Always get email from JWT, not DynamoDB
  4. Automatic linking - Handle in signup/login flow, not manually
  5. Purchase association - Use user_id consistently across all purchases