This API uses secure opaque tokens for authentication. These tokens are cryptographically secure random strings that are validated against database sessions.
Authorization header as a Bearer token for all
protected endpoints.Example authorization header:
Authorization: Bearer ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr...
The authentication system uses opaque tokens with dual expiration times for enhanced security and user experience:
| Token Type | Purpose | Lifetime | Storage |
|---|---|---|---|
| Access Token | API request authentication | 30 minutes | Client memory/secure storage |
| Refresh Token | Obtain new access tokens | 6 months (sliding window) | Secure client storage only |
| CSRF Token | Request validation | Same as access token | Client memory |
For persistent authentication, clients should implement automatic token refresh:
/api/auth/refreshToken Refresh Example:
// Client detects 401 response
if (response.status === 401) {
const refreshResponse = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Authorization': `Bearer ${currentAccessToken}`, // Required for consistency
'Content-Type': 'application/json'
},
body: JSON.stringify({
refreshToken: storedRefreshToken // Takes priority over header token
})
});
if (refreshResponse.ok) {
const newTokens = await refreshResponse.json();
// Update stored tokens and retry original request
updateStoredTokens(newTokens);
return retryOriginalRequest();
} else {
// Refresh failed, redirect to login
redirectToLogin();
}
}
For a seamless user experience, implement automatic token refresh in your client application:
Token Manager Class:
class TokenManager {
setTokens(authResponse) {
this.accessToken = authResponse.token; // API returns 'token'
this.refreshToken = authResponse.refreshToken;
this.csrfToken = authResponse.csrfToken;
this.expiresAt = new Date(Date.now() + authResponse.expiresIn * 1000);
// Store in secure storage
localStorage.setItem('tokens', JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
csrfToken: this.csrfToken,
expiresAt: this.expiresAt.toISOString()
}));
}
isAccessTokenExpired() {
return !this.expiresAt || new Date() >= this.expiresAt;
}
}
API Client with Auto-Refresh:
class ApiClient {
async makeRequest(url, options = {}) {
// Check if token needs refresh
if (tokenManager.isAccessTokenExpired()) {
await authService.refreshTokens();
}
// Add auth headers
const headers = {
...options.headers,
'Authorization': `Bearer ${tokenManager.getAccessToken()}`,
'x-csrf-token': tokenManager.getCsrfToken()
};
const response = await fetch(url, { ...options, headers });
// Handle 401 with retry
if (response.status === 401) {
const newToken = await authService.refreshTokens();
if (newToken) {
headers.Authorization = `Bearer ${newToken}`;
return fetch(url, { ...options, headers });
}
}
return response;
}
}
Key Implementation Features:
This API implements CSRF (Cross-Site Request Forgery) protection for all mutation operations (POST, PUT, PATCH, DELETE).
x-csrf-token header for all subsequent mutation
requests.Example authentication response with CSRF token:
{
"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr...",
"refreshToken": "XYZABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn...",
"csrfToken": "a8d7f9c6e5b4a3c2d1e0f9a8d7f6c5b4a3",
"expiresIn": 1800,
"user": {
"id": 1,
"username": "John Doe",
"email": "john@example.com",
"typeCode": "SUBS",
"typeName": "Subscribed",
"firstName": "John",
"lastName": "Doe",
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"verifiedAt": "2023-01-15T08:35:00Z",
"updatedAt": "2023-01-15T08:30:00Z",
"authTypeCode": "EMAI",
"authTypeName": "Email",
"subscriptionExemptionStartsAt": null,
"subscriptionExemptionEndsAt": null,
"legacyUserId": null,
"lastLoginAt": "2023-01-15T08:35:00Z",
"subscription": {
"id": 1,
"userId": 1,
"email": "john@example.com",
"username": "John Doe",
"plan": {
"id": 1,
"stripePriceId": "price_...",
"name": "Pro Plan",
"interval": "month",
"amount": 999,
"currency": "usd",
"trialPeriodDays": 14,
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"updatedAt": null
},
"status": "active",
"currentPeriodStart": "2023-06-01T00:00:00Z",
"currentPeriodEnd": "2023-07-01T00:00:00Z",
"trialStart": null,
"trialEnd": null,
"cancelAtPeriodEnd": false,
"canceledAt": null,
"createdAt": "2023-06-01T00:00:00Z",
"updatedAt": null,
"pauseCollectionResumesAt": "2026-04-15T00:00:00Z",
"nextBillingDate": "2026-05-01T00:00:00Z",
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"coupon": {
"id": 3,
"type": "DISC",
"name": "Discount Percentage",
"percentOff": 20,
"trialDays": null
}
},
"discount": {
"percentOff": 20,
"amountOff": 200,
"discountedAmount": 799
},
"shopifyPromotion": {
"id": 12,
"shopifyOrderId": 99001,
"name": "Shopify-99001",
"durationDays": 30,
"percentOff": null,
"appliedAt": "2026-03-15T09:00:00Z"
}
},
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"code": "SUMMER23",
"appliedAt": "2026-03-10T12:00:00Z",
"validUntil": null
}
}
}
Note: The subscription field will be null if the user does not have an active subscription.
Example request with CSRF protection:
// Request headers Authorization: Bearer ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr... x-csrf-token: a8d7f9c6e5b4a3c2d1e0f9a8d7f6c5b4a3 Content-Type: application/json
When a user logs in or registers, the system automatically attempts to migrate any existing Stripe subscription linked to their email address. This is called legacy subscription migration and runs transparently in the background with a 5-second timeout — it never blocks the auth response. The depth of what gets migrated, particularly promotions, depends on whether the user has a corresponding record in the legacy_users table.
Migration is triggered via migrateLegacySubscription(userId, email) automatically during login and registration, but only for SUBS type users.
typeCode = "SUBS"Once triggered, migrateLegacySubscription performs the following steps in sequence. Any step that finds no data marks the audit as SKIP and returns early — all subsequent steps are skipped.
stripe.customers.list({ email, limit: 1 })active, trialing, past_due. Canceled, incomplete, and unpaid subscriptions are discarded.active > trialing > past_due, then latest current_period_end. Skipped subscription IDs are logged to a separate audit record for manual review.subscription.items.data[0].price.idsubscription_plans for this Stripe price. If new, fetches product name, interval, amount, and currency from Stripe.canceled_at. Checks both root-level and subscription item fields with fallbacks.user_subscriptions with all Stripe data. Sets migration_status_code = 'MIGR' and stamps migrated_at and synced_at. Idempotent — safe to run on every login.MIGR status with the Stripe customer and subscription IDspromotion_redemptions for this user where user_subscription_id IS NULL or stripe_discount_id IS NULL and the promotion has a Stripe coupon. Re-fetches the subscription from Stripe with expand: ['discounts'] and backfills both fields. Failures here do not affect migration success — the subscription is already written.{ success: true, subscriptionId, stripeSubscriptionId, status }. The subscription is included in the auth response via a fresh getUserSubscription(userId, forceSync: true) call.
The legacy_users table contains records imported from the old system. The users.legacy_user_id column links a user to their legacy record and is set by a one-time admin bulk migration. It is NULL for every user who registers fresh on the new system.
Step 10 of the migration behaves very differently depending on this field — specifically whether promotion_redemptions rows were pre-seeded for the user before they ever logged in.
legacy_userslegacy_user_id IS NOT NULL
migrateLegacyUserGroups() reads discount % and group data from legacy_users, creates the matching promotions, and inserts promotion_redemptions rows with user_id set but user_subscription_id = NULL
user_subscriptions
promotion_redemptions. Re-fetches subscription from Stripe with expand: ['discounts']. Backfills user_subscription_id and stripe_discount_id on each row.
legacy_userslegacy_user_id IS NULL
promotion_redemptions rows exist for this user.
user_subscriptions
promotion_redemptions for this user — returns zero rows. Nothing to update. Any Stripe discount on the subscription has no corresponding local record.
| Scenario | Subscription Migrated? | Promotion Linked? | Notes |
|---|---|---|---|
In legacy_users, has Stripe subscription |
✓ Yes | ✓ Yes | Admin bulk op pre-seeds redemptions; Step 10 backfills subscription and Stripe discount IDs |
In legacy_users, no Stripe subscription (or only cancelled/unpaid) |
✗ No | ✗ No | Migration returns SKIP — no qualifying Stripe subscription found |
NOT in legacy_users, has Stripe subscription with discount |
✓ Yes | ⚠ Partial | Subscription fully migrated; Stripe discount exists in Stripe but has no local promotion_redemptions record |
NOT in legacy_users, has Stripe subscription, no discount |
✓ Yes | N/A | Subscription fully migrated; no promotion to link |
| No Stripe customer found for email | ✗ No | ✗ No | Migration returns SKIP — user has no Stripe history. Auth response succeeds normally with no subscription. |
Note on the one-discount assumption: When Step 10 backfills Stripe discount IDs, it reads the first discount from the subscription's discounts array and applies that same ID to all unsynced promotion_redemptions rows for that user. If a user has multiple Stripe discounts, only the first one is written across all rows.
/api/auth/apple/callback
Handle Apple OAuth callback and authenticate or register user
Request Body:
{
"code": "c1234567890abcdef...",
"idToken": "eyJraWQiOiJXNldjT0tC...",
"nonce": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
"redirectUri": "https://yourdomain.com/auth/apple/callback",
"mode": "login",
"platform": "ios"
}
Field Descriptions:
code (required) - Authorization code received from Apple OAuth flowidToken (optional) - Apple ID token (included in response_mode form_post)nonce (required) - Nonce used in the authorization request for replay attack preventionredirectUri (required) - Must match the redirect URI used in the authorization requestmode (required) - Authentication mode, either "login" or "register"platform (optional) - Platform identifier: "ios", "android", or "web" (defaults to "web")Response (200 OK for login, 201 Created for register):
{
"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr...",
"refreshToken": "XYZABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn...",
"csrfToken": "a8d7f9c6e5b4a3c2d1e0f9a8d7f6c5b4a3",
"expiresIn": 1800,
"user": {
"id": 1,
"username": "John Doe",
"email": "john@privaterelay.appleid.com",
"typeCode": "SUBS",
"typeName": "Subscribed",
"firstName": "John",
"lastName": "Doe",
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"verifiedAt": "2023-01-15T08:30:00Z",
"updatedAt": null,
"authTypeCode": "APPE",
"authTypeName": "Apple OAuth",
"subscriptionExemptionStartsAt": null,
"subscriptionExemptionEndsAt": null,
"legacyUserId": null,
"lastLoginAt": "2023-01-15T08:30:00Z",
"subscription": {
"id": 1,
"userId": 1,
"email": "john@example.com",
"username": "John Doe",
"plan": {
"id": 1,
"stripePriceId": "price_...",
"name": "Pro Plan",
"interval": "month",
"amount": 999,
"currency": "usd",
"trialPeriodDays": 14,
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"updatedAt": null
},
"status": "active",
"currentPeriodStart": "2023-06-01T00:00:00Z",
"currentPeriodEnd": "2023-07-01T00:00:00Z",
"trialStart": null,
"trialEnd": null,
"cancelAtPeriodEnd": false,
"canceledAt": null,
"createdAt": "2023-06-01T00:00:00Z",
"updatedAt": null,
"pauseCollectionResumesAt": "2026-04-15T00:00:00Z",
"nextBillingDate": "2026-05-01T00:00:00Z",
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"coupon": {
"id": 3,
"type": "DISC",
"name": "Discount Percentage",
"percentOff": 20,
"trialDays": null
}
},
"discount": {
"percentOff": 20,
"amountOff": 200,
"discountedAmount": 799
},
"shopifyPromotion": {
"id": 12,
"shopifyOrderId": 99001,
"name": "Shopify-99001",
"durationDays": 30,
"percentOff": null,
"appliedAt": "2026-03-15T09:00:00Z"
}
},
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"code": "SUMMER23",
"appliedAt": "2026-03-10T12:00:00Z",
"validUntil": null
}
}
}
Error Responses:
{
"error": "Missing required parameters",
"message": "code, nonce, redirectUri, and mode are required",
"code": "APPLE_MISSING_PARAMS"
}
{
"error": "Invalid mode",
"message": "mode must be 'login' or 'register'",
"code": "APPLE_INVALID_MODE"
}
{
"error": "Invalid Apple ID token",
"message": "Failed to verify Apple ID token",
"code": "APPLE_INVALID_ID_TOKEN"
}
{
"error": "User not found",
"message": "No account found with this Apple ID. Please register first.",
"code": "AUTH_APPLE_USER_NOT_FOUND"
}
{
"error": "Email already exists",
"message": "An account with this Apple email already exists. Please sign in instead.",
"code": "AUTH_EMAIL_EXISTS"
}
{
"error": "Too many OAuth attempts",
"message": "Too many OAuth attempts from this IP, please try again later.",
"code": "RATE_LIMIT_EXCEEDED"
}
Notes:
Apple OAuth Flow Overview:
nonce and state valuesnonce and statecode, id_token, and statecode, id_token, and nonce to this endpoint/api/auth/apple/form-post
Handle Apple OAuth form_post callback (receives data from Apple and redirects to frontend)
Request Body (sent by Apple):
{
"code": "c1234567890abcdef...",
"id_token": "eyJraWQiOiJXNldjT0tC...",
"state": "{\"state\":\"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk\",\"mode\":\"login\",\"returnUrl\":\"http://localhost:5173\"}",
"user": "{\"name\":{\"firstName\":\"John\",\"lastName\":\"Doe\"},\"email\":\"john@privaterelay.appleid.com\"}"
}
State Parameter Format:
The state parameter contains JSON-encoded data:
{
"state": "random_string_for_csrf_protection",
"mode": "login" | "register",
"returnUrl": "https://yourdomain.com" // Optional, frontend URL to redirect to
}
Response (200 OK - HTML redirect):
<!DOCTYPE html>
<html>
<head>
<title>Apple Sign In</title>
</head>
<body>
<p>Completing Apple Sign-In...</p>
<script>
window.location.replace('https://yourdomain.com/auth/apple/callback#code=...&id_token=...&state=...');
</script>
</body>
</html>
Error Response (400 Bad Request):
<!DOCTYPE html>
<html>
<body>
<script>
sessionStorage.setItem('appleAuthError', JSON.stringify({
title: 'Apple Authentication Error',
description: 'Missing required parameters from Apple',
type: 'error'
}));
window.location.replace('/login');
</script>
</body>
</html>
Notes:
Apple Developer Configuration:
In your Apple Services ID, configure the Return URL WITHOUT query parameters:
https://yourdomain.com/api/auth/apple/form-post
Important: Do NOT include query parameters like ?returnUrl=... in the Apple Developer Console configuration. Apple requires exact URL matching.
/api/auth/google/callback-pkce
Handle Google OAuth callback with PKCE (Proof Key for Code Exchange) - recommended for mobile apps
Request Body:
{
"code": "4/0AfJohXlxxx...",
"codeVerifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
"redirectUri": "com.googleusercontent.apps.123456789:redirect",
"mode": "login",
"platform": "android"
}
Field Descriptions:
code (required) - Authorization code received from Google OAuth flowcodeVerifier (required) - PKCE code verifier (proves ownership of the auth request)redirectUri (required) - Must match the redirect URI used in the authorization requestmode (required) - Authentication mode, either "login" or "register"platform (optional) - Platform identifier: "ios", "android", or "web" (defaults to "web")Response (200 OK for login, 201 Created for register):
{
"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr...",
"refreshToken": "XYZABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn...",
"csrfToken": "a8d7f9c6e5b4a3c2d1e0f9a8d7f6c5b4a3",
"expiresIn": 1800,
"user": {
"id": 1,
"username": "John Doe",
"email": "john@gmail.com",
"typeCode": "SUBS",
"typeName": "Subscribed",
"firstName": "John",
"lastName": "Doe",
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"verifiedAt": "2023-01-15T08:30:00Z",
"updatedAt": null,
"authTypeCode": "GOOG",
"authTypeName": "Google OAuth",
"subscriptionExemptionStartsAt": null,
"subscriptionExemptionEndsAt": null,
"legacyUserId": null,
"lastLoginAt": "2023-01-15T08:30:00Z",
"subscription": {
"id": 1,
"userId": 1,
"email": "john@example.com",
"username": "John Doe",
"plan": {
"id": 1,
"stripePriceId": "price_...",
"name": "Pro Plan",
"interval": "month",
"amount": 999,
"currency": "usd",
"trialPeriodDays": 14,
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"updatedAt": null
},
"status": "active",
"currentPeriodStart": "2023-06-01T00:00:00Z",
"currentPeriodEnd": "2023-07-01T00:00:00Z",
"trialStart": null,
"trialEnd": null,
"cancelAtPeriodEnd": false,
"canceledAt": null,
"createdAt": "2023-06-01T00:00:00Z",
"updatedAt": null,
"pauseCollectionResumesAt": "2026-04-15T00:00:00Z",
"nextBillingDate": "2026-05-01T00:00:00Z",
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"coupon": {
"id": 3,
"type": "DISC",
"name": "Discount Percentage",
"percentOff": 20,
"trialDays": null
}
},
"discount": {
"percentOff": 20,
"amountOff": 200,
"discountedAmount": 799
},
"shopifyPromotion": {
"id": 12,
"shopifyOrderId": 99001,
"name": "Shopify-99001",
"durationDays": 30,
"percentOff": null,
"appliedAt": "2026-03-15T09:00:00Z"
}
},
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"code": "SUMMER23",
"appliedAt": "2026-03-10T12:00:00Z",
"validUntil": null
}
}
}
Error Responses:
{
"error": "Missing required parameters",
"message": "code, codeVerifier, redirectUri, and mode are required",
"code": "PKCE_MISSING_PARAMS"
}
{
"error": "Invalid mode",
"message": "mode must be 'login' or 'register'",
"code": "PKCE_INVALID_MODE"
}
{
"error": "Invalid redirect URI",
"message": "Redirect URI does not match expected pattern for platform",
"code": "PKCE_INVALID_REDIRECT_URI"
}
{
"error": "Failed to exchange authorization code",
"message": "Token exchange failed",
"code": "PKCE_TOKEN_EXCHANGE_FAILED"
}
{
"error": "User not found",
"message": "No account found with this Google email. Please register first.",
"code": "AUTH_GOOGLE_USER_NOT_FOUND"
}
{
"error": "Email already exists",
"message": "An account with this Google email already exists. Please sign in instead.",
"code": "AUTH_EMAIL_EXISTS"
}
{
"error": "Too many OAuth attempts",
"message": "Too many OAuth attempts from this IP, please try again later.",
"code": "RATE_LIMIT_EXCEEDED"
}
Notes:
PKCE Flow Overview:
codeVerifier (random string)codeChallenge from verifier (SHA256 hash)codeChallengecodecode + codeVerifier to this endpoint/api/auth/google/callback
Handle Google OAuth callback and exchange authorization code for tokens
Request Body:
{
"code": "4/0AfJohXlxxx...",
"mode": "login",
"redirectUri": "http://localhost:3000/auth/google/callback"
}
Field Descriptions:
code (required) - Authorization code received from Google OAuth flowmode (required) - Authentication mode, either "login" or "register"redirectUri (required) - Redirect URI that must match the one configured in Google Cloud ConsoleResponse (200 OK):
{
"googleIdToken": "eyJzdWIiOiIxMjM0NTY3ODkw...",
"message": "Google authentication successful"
}
Error Responses:
{
"error": "Missing required parameters",
"message": "code, mode, and redirectUri are required",
"code": "GOOGLE_AUTH_MISSING_PARAMS"
}
{
"error": "Invalid mode",
"message": "mode must be 'login' or 'register'",
"code": "GOOGLE_AUTH_INVALID_MODE"
}
{
"error": "Failed to exchange authorization code",
"message": "Google authentication failed",
"code": "GOOGLE_AUTH_TOKEN_EXCHANGE_FAILED"
}
{
"error": "Failed to retrieve user information",
"message": "Could not get user profile from Google",
"code": "GOOGLE_AUTH_USER_INFO_FAILED"
}
Notes:
googleIdToken can be passed to /api/auth/google/login or /api/auth/google/register/api/auth/google/login
Authenticate with Google ID token
Request Body:
{
"googleIdToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"platform": "android" // Optional: "web", "android", or "ios"
}
Field Descriptions:
googleIdToken (required) - Google ID token obtained from OAuth flowplatform (optional) - Platform identifier for multi-platform support
"web" - Web application (default if not specified)"android" - Android application"ios" - iOS applicationResponse (200 OK):
{
"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr...", // Valid for 30 minutes
"refreshToken": "XYZABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn...", // Valid for 6 months
"csrfToken": "a8d7f9c6e5b4a3c2d1e0f9a8d7f6c5b4a3",
"expiresIn": 1800, // 30 minutes in seconds
"user": {
"id": 1,
"username": "John Doe",
"email": "john@gmail.com",
"typeCode": "SUBS",
"typeName": "Subscribed",
"firstName": "John",
"lastName": "Doe",
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"verifiedAt": "2023-01-15T08:35:00Z",
"updatedAt": "2023-01-15T08:30:00Z",
"authTypeCode": "GOOG",
"authTypeName": "Google OAuth",
"subscriptionExemptionStartsAt": null,
"subscriptionExemptionEndsAt": null,
"legacyUserId": null,
"lastLoginAt": "2023-01-15T08:35:00Z",
"subscription": {
"id": 1,
"userId": 1,
"email": "john@example.com",
"username": "John Doe",
"plan": {
"id": 1,
"stripePriceId": "price_...",
"name": "Pro Plan",
"interval": "month",
"amount": 999,
"currency": "usd",
"trialPeriodDays": 14,
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"updatedAt": null
},
"status": "active",
"currentPeriodStart": "2023-06-01T00:00:00Z",
"currentPeriodEnd": "2023-07-01T00:00:00Z",
"trialStart": null,
"trialEnd": null,
"cancelAtPeriodEnd": false,
"canceledAt": null,
"createdAt": "2023-06-01T00:00:00Z",
"updatedAt": null,
"pauseCollectionResumesAt": "2026-04-15T00:00:00Z",
"nextBillingDate": "2026-05-01T00:00:00Z",
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"coupon": {
"id": 3,
"type": "DISC",
"name": "Discount Percentage",
"percentOff": 20,
"trialDays": null
}
},
"discount": {
"percentOff": 20,
"amountOff": 200,
"discountedAmount": 799
},
"shopifyPromotion": {
"id": 12,
"shopifyOrderId": 99001,
"name": "Shopify-99001",
"durationDays": 30,
"percentOff": null,
"appliedAt": "2026-03-15T09:00:00Z"
}
},
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"code": "SUMMER23",
"appliedAt": "2026-03-10T12:00:00Z",
"validUntil": null
}
}
}
Error Responses:
{
"message": "Invalid Google ID token",
"code": "AUTH_INVALID_GOOGLE_TOKEN"
}
{
"message": "No account found with this Google email. Please register first.",
"code": "AUTH_GOOGLE_USER_NOT_FOUND"
}
Notes:
/api/auth/google/register
Register a new user account with Google ID token
Request Body:
{
"googleIdToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"platform": "ios" // Optional: "web", "android", or "ios"
}
Field Descriptions:
googleIdToken (required) - Google ID token obtained from OAuth flowplatform (optional) - Platform identifier for multi-platform support
"web" - Web application (default if not specified)"android" - Android application"ios" - iOS applicationResponse (201 Created):
{
"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr...", // Valid for 30 minutes
"refreshToken": "XYZABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn...", // Valid for 6 months
"csrfToken": "f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3",
"expiresIn": 1800, // 30 minutes in seconds
"user": {
"id": 2,
"username": "John Doe",
"email": "john@gmail.com",
"typeCode": "SUBS",
"typeName": "Subscribed",
"firstName": "John",
"lastName": "Doe",
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"verifiedAt": "2023-01-15T08:30:00Z",
"updatedAt": null,
"authTypeCode": "GOOG",
"authTypeName": "Google OAuth",
"subscriptionExemptionStartsAt": null,
"subscriptionExemptionEndsAt": null,
"legacyUserId": null,
"lastLoginAt": null,
"subscription": {
"id": 1,
"userId": 1,
"email": "john@example.com",
"username": "John Doe",
"plan": {
"id": 1,
"stripePriceId": "price_...",
"name": "Pro Plan",
"interval": "month",
"amount": 999,
"currency": "usd",
"trialPeriodDays": 14,
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"updatedAt": null
},
"status": "active",
"currentPeriodStart": "2023-06-01T00:00:00Z",
"currentPeriodEnd": "2023-07-01T00:00:00Z",
"trialStart": null,
"trialEnd": null,
"cancelAtPeriodEnd": false,
"canceledAt": null,
"createdAt": "2023-06-01T00:00:00Z",
"updatedAt": null,
"pauseCollectionResumesAt": "2026-04-15T00:00:00Z",
"nextBillingDate": "2026-05-01T00:00:00Z",
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"coupon": {
"id": 3,
"type": "DISC",
"name": "Discount Percentage",
"percentOff": 20,
"trialDays": null
}
},
"discount": {
"percentOff": 20,
"amountOff": 200,
"discountedAmount": 799
},
"shopifyPromotion": {
"id": 12,
"shopifyOrderId": 99001,
"name": "Shopify-99001",
"durationDays": 30,
"percentOff": null,
"appliedAt": "2026-03-15T09:00:00Z"
}
},
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"code": "SUMMER23",
"appliedAt": "2026-03-10T12:00:00Z",
"validUntil": null
}
}
}
Error Responses:
{
"message": "Invalid Google ID token",
"code": "AUTH_INVALID_GOOGLE_TOKEN"
}
{
"message": "Email address is already registered. Please use login instead.",
"code": "AUTH_EMAIL_EXISTS"
}
Notes:
/api/auth/login
Log in with email and password
Request Body:
{
"email": "user@example.com",
"password": "yourpassword"
}
Response (200 OK):
{
"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr...", // Valid for 30 minutes
"refreshToken": "XYZABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn...", // Valid for 6 months
"csrfToken": "a8d7f9c6e5b4a3c2d1e0f9a8d7f6c5b4a3",
"expiresIn": 1800, // 30 minutes in seconds
"user": {
"id": 1,
"username": "John Doe",
"email": "john@example.com",
"typeCode": "SUBS",
"typeName": "Subscribed",
"firstName": "John",
"lastName": "Doe",
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"verifiedAt": "2023-01-15T08:35:00Z",
"updatedAt": "2023-01-15T08:30:00Z",
"authTypeCode": "EMAI",
"authTypeName": "Email",
"subscriptionExemptionStartsAt": null,
"subscriptionExemptionEndsAt": null,
"legacyUserId": null,
"lastLoginAt": "2023-01-15T08:35:00Z",
"subscription": {
"id": 1,
"userId": 1,
"email": "john@example.com",
"username": "John Doe",
"plan": {
"id": 1,
"stripePriceId": "price_...",
"name": "Pro Plan",
"interval": "month",
"amount": 999,
"currency": "usd",
"trialPeriodDays": 14,
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"updatedAt": null
},
"status": "active",
"currentPeriodStart": "2023-06-01T00:00:00Z",
"currentPeriodEnd": "2023-07-01T00:00:00Z",
"trialStart": null,
"trialEnd": null,
"cancelAtPeriodEnd": false,
"canceledAt": null,
"createdAt": "2023-06-01T00:00:00Z",
"updatedAt": null,
"pauseCollectionResumesAt": "2026-04-15T00:00:00Z",
"nextBillingDate": "2026-05-01T00:00:00Z",
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"coupon": {
"id": 3,
"type": "DISC",
"name": "Discount Percentage",
"percentOff": 20,
"trialDays": null
}
},
"discount": {
"percentOff": 20,
"amountOff": 200,
"discountedAmount": 799
},
"shopifyPromotion": {
"id": 12,
"shopifyOrderId": 99001,
"name": "Shopify-99001",
"durationDays": 30,
"percentOff": null,
"appliedAt": "2026-03-15T09:00:00Z"
}
},
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"code": "SUMMER23",
"appliedAt": "2026-03-10T12:00:00Z",
"validUntil": null
}
}
}
Error Responses:
{
"message": "Invalid credentials",
"code": "AUTH_INVALID_CREDENTIALS",
"isLegacyUser": true,
"hasLoggedInBefore": false
}
{
"message": "Your email address has not been verified. A verification code has been sent to your email address.",
"code": "AUTH_EMAIL_NOT_VERIFIED"
}
Notes:
isLegacyUser - true if user was migrated from legacy system (has legacy user ID), false otherwisehasLoggedInBefore - true if user has successfully logged in before (lastLoginAt is not null), false otherwiseisLegacyUser: true and hasLoggedInBefore: false, frontend should direct user to reset their password as they likely need to set a new password for the migrated account/api/auth/logout
Log out the current user (invalidate token)
Headers (Optional):
Authorization: Bearer ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr...
Note: Authorization header is optional. If provided, the specific session will be invalidated. If not provided, logout will still succeed.
Response (200 OK):
{
"success": true,
"message": "Logged out successfully"
}
/api/auth/refresh-token
/api/auth/refresh
Refresh authentication token when the current one is about to expire or has expired. Both endpoints are supported for compatibility.
Headers (Optional):
Authorization: Bearer [current_access_token_or_refresh_token]
Request Body (Optional - takes priority if provided):
{
"refreshToken": "XYZABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn..."
}
Notes:
refreshToken is provided in the request body, it will be used instead of the token from the Authorization header/api/auth/refresh-token is the legacy endpoint, /api/auth/refresh is preferredResponse (200 OK):
{
"token": "DEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstu...", // New token valid for 30 minutes
"refreshToken": "XYZABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn...", // New refresh token valid for 6 months
"csrfToken": "f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3", // New CSRF token
"expiresIn": 1800, // 30 minutes in seconds
"user": {
"id": 1,
"username": "John Doe",
"email": "john@example.com",
"typeCode": "SUBS",
"typeName": "Subscribed",
"firstName": "John",
"lastName": "Doe",
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"verifiedAt": "2023-01-15T08:35:00Z",
"updatedAt": "2023-01-15T08:30:00Z",
"authTypeCode": "EMAI",
"authTypeName": "Email",
"subscriptionExemptionStartsAt": null,
"subscriptionExemptionEndsAt": null,
"legacyUserId": null,
"lastLoginAt": "2023-01-15T08:35:00Z",
"subscription": {
"id": 1,
"userId": 1,
"email": "john@example.com",
"username": "John Doe",
"plan": {
"id": 1,
"stripePriceId": "price_...",
"name": "Pro Plan",
"interval": "month",
"amount": 999,
"currency": "usd",
"trialPeriodDays": 14,
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"updatedAt": null
},
"status": "active",
"currentPeriodStart": "2023-06-01T00:00:00Z",
"currentPeriodEnd": "2023-07-01T00:00:00Z",
"trialStart": null,
"trialEnd": null,
"cancelAtPeriodEnd": false,
"canceledAt": null,
"createdAt": "2023-06-01T00:00:00Z",
"updatedAt": null,
"pauseCollectionResumesAt": "2026-04-15T00:00:00Z",
"nextBillingDate": "2026-05-01T00:00:00Z",
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"coupon": {
"id": 3,
"type": "DISC",
"name": "Discount Percentage",
"percentOff": 20,
"trialDays": null
}
},
"discount": {
"percentOff": 20,
"amountOff": 200,
"discountedAmount": 799
},
"shopifyPromotion": {
"id": 12,
"shopifyOrderId": 99001,
"name": "Shopify-99001",
"durationDays": 30,
"percentOff": null,
"appliedAt": "2026-03-15T09:00:00Z"
}
},
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"code": "SUMMER23",
"appliedAt": "2026-03-10T12:00:00Z",
"validUntil": null
}
}
}
Error Responses:
{
"message": "No token provided",
"code": "AUTH_NO_TOKEN"
}
{
"message": "Invalid token format",
"code": "AUTH_INVALID_TOKEN_FORMAT"
}
{
"message": "No active session found. Please log in again.",
"code": "AUTH_SESSION_NOT_FOUND",
"requiresLogout": true
}
{
"message": "Your session has been revoked. Please log in again.",
"code": "AUTH_SESSION_REVOKED",
"requiresLogout": true
}
{
"message": "Your session has expired. Please log in again.",
"code": "AUTH_REFRESH_EXPIRED",
"requiresLogout": true
}
{
"message": "User account issue. Please log in again.",
"code": "AUTH_USER_NOT_FOUND",
"requiresLogout": true
}
{
"message": "Authentication error. Please log in again.",
"code": "AUTH_ERROR",
"requiresLogout": true
}
/api/auth/register
Register a new user account
Request Body:
{
"username": "johnsmith",
"email": "john@example.com",
"password": "securePassword123",
"firstName": "John",
"lastName": "Smith"
}
Field Descriptions:
username (required) - User's display name, will be trimmed of whitespaceemail (required) - Valid email address, must be uniquepassword (required) - Must be at least 8 characters longfirstName (optional) - User's first name, max 50 characterslastName (optional) - User's last name, max 50 charactersResponse (201 Created):
{
"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr...", // Valid for 30 minutes
"refreshToken": "XYZABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn...", // Valid for 6 months
"csrfToken": "f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3",
"expiresIn": 1800, // 30 minutes in seconds
"user": {
"id": 2,
"username": "johnsmith",
"email": "john@example.com",
"typeCode": "SUBS",
"typeName": "Subscribed",
"firstName": "John",
"lastName": "Smith",
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"verifiedAt": null,
"updatedAt": null,
"authTypeCode": "EMAI",
"authTypeName": "Email",
"subscriptionExemptionStartsAt": null,
"subscriptionExemptionEndsAt": null,
"legacyUserId": null,
"lastLoginAt": null,
"subscription": {
"id": 1,
"userId": 1,
"email": "john@example.com",
"username": "John Doe",
"plan": {
"id": 1,
"stripePriceId": "price_...",
"name": "Pro Plan",
"interval": "month",
"amount": 999,
"currency": "usd",
"trialPeriodDays": 14,
"isActive": true,
"createdAt": "2023-01-15T08:30:00Z",
"updatedAt": null
},
"status": "active",
"currentPeriodStart": "2023-06-01T00:00:00Z",
"currentPeriodEnd": "2023-07-01T00:00:00Z",
"trialStart": null,
"trialEnd": null,
"cancelAtPeriodEnd": false,
"canceledAt": null,
"createdAt": "2023-06-01T00:00:00Z",
"updatedAt": null,
"pauseCollectionResumesAt": "2026-04-15T00:00:00Z",
"nextBillingDate": "2026-05-01T00:00:00Z",
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"coupon": {
"id": 3,
"type": "DISC",
"name": "Discount Percentage",
"percentOff": 20,
"trialDays": null
}
},
"discount": {
"percentOff": 20,
"amountOff": 200,
"discountedAmount": 799
},
"shopifyPromotion": {
"id": 12,
"shopifyOrderId": 99001,
"name": "Shopify-99001",
"durationDays": 30,
"percentOff": null,
"appliedAt": "2026-03-15T09:00:00Z"
}
},
"promotion": {
"id": 5,
"name": "Summer Sale 2023",
"code": "SUMMER23",
"appliedAt": "2026-03-10T12:00:00Z",
"validUntil": null
}
}
}
Error Responses:
{
"message": "Email address is already registered",
"code": "AUTH_EMAIL_EXISTS"
}
{
"message": "Validation error",
"code": "VALIDATION_ERROR",
"errors": [
{
"type": "field",
"value": "",
"msg": "Username is required",
"path": "username",
"location": "body"
}
]
}
Notes:
/api/auth/reset-password
Request a password reset code
Request Body:
{
"email": "user@example.com"
}
Response (200 OK):
{
"message": "Password reset code has been sent to your email address"
}
Error Responses:
{
"message": "Email address not found"
}
Notes:
/api/auth/verify-password
Verify user's current password (requires authentication and CSRF token)
Headers:
Authorization: Bearer ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr... x-csrf-token: a8d7f9c6e5b4a3c2d1e0f9a8d7f6c5b4a3
Request Body:
{
"password": "currentUserPassword"
}
Response (200 OK):
{
"message": "Password verified successfully"
}
Error Responses:
{
"message": "Authentication required"
}
{
"message": "Password is required"
}
{
"message": "Invalid password"
}
{
"message": "Account has been deactivated"
}
{
"message": "Password verification is not available for Google accounts"
}
Notes:
/api/auth/verify-registration
Verify a registration with the verification code
Request Body:
{
"email": "user@example.com",
"verificationCode": "123456"
}
Response (200 OK):
{
"message": "Email verified successfully"
}
Error Responses:
{
"message": "Invalid email address"
}
{
"message": "No verification request found"
}
{
"message": "Maximum attempts exceeded"
}
{
"message": "Verification code has expired"
}
{
"message": "Invalid verification code"
}
Notes:
/api/auth/verify-reset-password
Reset password using verification code
POST/api/auth/verify-reset-password
Reset password using verification code
Request Body:
{
"email": "user@example.com",
"verificationCode": "123456",
"newPassword": "newSecurePassword"
}
Response (200 OK):
{
"message": "Password has been successfully reset"
}
Error Responses:
{
"message": "Password must be at least 8 characters long"
}
{
"message": "Invalid email address"
}
{
"message": "No password reset request found"
}
{
"message": "Maximum attempts exceeded. Please request a new verification code"
}
{
"message": "Verification code has expired. Please request a new one"
}
{
"message": "Invalid verification code"
}
Notes: