Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.esakrissa.com/llms.txt

Use this file to discover all available pages before exploring further.

API Overview

The User Service exposes RESTful endpoints for managing users and their email addresses. All endpoints require authentication via Cognito JWT tokens. Base URL: https://api.example.com/v1

User Endpoints

Get Current User

Retrieve the authenticated user’s profile.
/users/me
Returns the current user’s profile data.
Response:
{
  "userId": "abc-123-def",
  "email": "john@example.com",
  "firstName": "John",
  "lastName": "Doe",
  "phone": "+1234567890",
  "status": "active",
  "createdAt": "2026-01-15T10:30:00Z",
  "updatedAt": "2026-01-20T14:22:00Z"
}
StatusDescription
200Success
401Missing or invalid JWT
404User not found in DynamoDB (data sync issue)

Update Current User

Update the authenticated user’s profile.
/users/me
Updates profile fields. Partial updates supported.
Request:
{
  "firstName": "Jonathan",
  "lastName": "Doe",
  "phone": "+1987654321"
}
Response:
{
  "userId": "abc-123-def",
  "email": "john@example.com",
  "firstName": "Jonathan",
  "lastName": "Doe",
  "phone": "+1987654321",
  "status": "active",
  "createdAt": "2026-01-15T10:30:00Z",
  "updatedAt": "2026-01-21T09:15:00Z"
}
StatusDescription
200Success
400Invalid request body
401Missing or invalid JWT
409Conflict (concurrent update, version mismatch)

Delete Current User

Soft-delete the authenticated user’s account.
/users/me
Sets user status to deleted. Does not remove data.
Response:
{
  "message": "Account scheduled for deletion",
  "deletedAt": "2026-01-21T09:30:00Z"
}
StatusDescription
200Success
401Missing or invalid JWT
Soft delete is used to maintain referential integrity. Downstream services receive a user.deleted event to handle cleanup (cancel pending bookings, final invoices, etc.).

Email Endpoints

List User Emails

Get all email addresses for the current user.
/users/me/emails
Returns all emails associated with the user.
Response:
{
  "emails": [
    {
      "emailId": "email-001",
      "email": "john@example.com",
      "isPrimary": true,
      "isVerified": true,
      "verifiedAt": "2026-01-15T10:35:00Z",
      "createdAt": "2026-01-15T10:30:00Z"
    },
    {
      "emailId": "email-002",
      "email": "john.work@company.com",
      "isPrimary": false,
      "isVerified": true,
      "verifiedAt": "2026-01-18T14:00:00Z",
      "createdAt": "2026-01-18T13:45:00Z"
    }
  ]
}

Add Email

Add a new email address to the user’s account.
/users/me/emails
Adds a new email. Verification required before use.
Request:
{
  "email": "john.personal@gmail.com"
}
Response:
{
  "emailId": "email-003",
  "email": "john.personal@gmail.com",
  "isPrimary": false,
  "isVerified": false,
  "createdAt": "2026-01-21T10:00:00Z"
}
StatusDescription
201Email added, verification sent
400Invalid email format
409Email already in use
429Too many emails (max 5 per user)

Delete Email

Remove an email address from the user’s account.
/users/me/emails/{emailId}
Removes the specified email.
StatusDescription
204Email deleted
400Cannot delete primary email
400Cannot delete last email
404Email not found

Request Verification

Send a verification code to an unverified email.
/users/me/emails/{emailId}/verify
Sends verification code. Rate limited.
Response:
{
  "message": "Verification code sent",
  "expiresIn": 900
}
StatusDescription
200Code sent
400Email already verified
429Rate limit (max 3/hour)

Confirm Verification

Confirm email ownership with verification code.
/users/me/emails/{emailId}/verify/confirm
Confirms the verification code.
Request:
{
  "code": "123456"
}
StatusDescription
200Email verified
400Invalid or expired code
429Too many attempts (max 5)

Set Primary Email

Set an email as the user’s primary email address.
/users/me/emails/{emailId}/primary
Designates this email as primary.
StatusDescription
200Primary email updated
400Email not verified
404Email not found

Edge Cases

Cannot Delete Primary Email

Scenario: User tries to delete their primary email. Handling:
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "Cannot delete primary email. Set another email as primary first."
}
Why: Primary email is used for account recovery and critical notifications. User must explicitly choose a replacement.

Cannot Delete Last Email

Scenario: User tries to delete their only email address. Handling:
{
  "statusCode": 400,
  "error": "Bad Request", 
  "message": "Cannot delete last email. Account must have at least one email."
}
Why: Email is required for account recovery. Deleting all emails would orphan the account.

Duplicate Email

Scenario: User tries to add an email that’s already in use (by them or another user). Handling:
{
  "statusCode": 409,
  "error": "Conflict",
  "message": "Email address is not available"
}
Why: Generic message prevents email enumeration attacks.

Unverified Email as Primary

Scenario: User tries to set an unverified email as primary. Handling:
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "Email must be verified before setting as primary"
}
Why: Primary email is used for critical account functions. Ownership must be confirmed.

Verification Rate Limiting

Scenario: User requests too many verification codes. Handling:
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Verification limit reached. Try again in 45 minutes.",
  "retryAfter": 2700
}
Limits:
  • 3 verification requests per email per hour
  • 5 confirmation attempts per code

Rate Limiting Implementation

Rate limiting in a serverless context requires stateful tracking. Here’s how to implement it.

Strategy: DynamoDB Token Bucket

DynamoDB is used to track request counts per user/email with TTL for automatic cleanup. Rate Limit Table Schema:
PKSKcountttl
RATELIMIT#verify#HOUR#21705845600
RATELIMIT#confirm#ATTEMPTS31705845600
Implementation:
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}

async function checkRateLimit(
  key: string,
  limit: number,
  windowSeconds: number
): Promise<RateLimitResult> {
  const now = Math.floor(Date.now() / 1000);
  const windowStart = now - (now % windowSeconds);
  const ttl = windowStart + windowSeconds + 60; // Extra 60s buffer
  
  try {
    const result = await dynamodb.update({
      TableName: RATE_LIMIT_TABLE,
      Key: {
        PK: `RATELIMIT#${key}`,
        SK: `WINDOW#${windowStart}`
      },
      UpdateExpression: 'SET #count = if_not_exists(#count, :zero) + :inc, #ttl = :ttl',
      ConditionExpression: 'attribute_not_exists(#count) OR #count < :limit',
      ExpressionAttributeNames: {
        '#count': 'count',
        '#ttl': 'ttl'
      },
      ExpressionAttributeValues: {
        ':zero': 0,
        ':inc': 1,
        ':limit': limit,
        ':ttl': ttl
      },
      ReturnValues: 'ALL_NEW'
    });
    
    return {
      allowed: true,
      remaining: limit - (result.Attributes?.count || 1),
      resetAt: windowStart + windowSeconds
    };
  } catch (error) {
    if (error.name === 'ConditionalCheckFailedException') {
      return {
        allowed: false,
        remaining: 0,
        resetAt: windowStart + windowSeconds
      };
    }
    throw error;
  }
}
Usage in handler:
const verifyEmailHandler = async (event: APIGatewayEvent) => {
  const { emailId } = event.pathParameters;
  const email = await getEmail(emailId);
  
  const rateLimit = await checkRateLimit(
    `verify#${email.email}`,
    3,      // 3 requests
    3600    // per hour
  );
  
  if (!rateLimit.allowed) {
    return {
      statusCode: 429,
      headers: {
        'Retry-After': String(rateLimit.resetAt - Math.floor(Date.now() / 1000))
      },
      body: JSON.stringify({
        error: 'Too Many Requests',
        message: 'Verification limit reached',
        retryAfter: rateLimit.resetAt
      })
    };
  }
  
  // Proceed with verification...
};

Alternative: API Gateway Usage Plans

For API-wide rate limiting (not user-specific):
# serverless.yml
resources:
  Resources:
    UsagePlan:
      Type: AWS::ApiGateway::UsagePlan
      Properties:
        UsagePlanName: UserServicePlan
        Throttle:
          RateLimit: 100      # requests per second
          BurstLimit: 200     # burst capacity
        Quota:
          Limit: 10000        # requests per day
          Period: DAY

Rate Limit Headers

All rate-limited endpoints return standard headers:
X-RateLimit-Limit: 3
X-RateLimit-Remaining: 1
X-RateLimit-Reset: 1705849200

Concurrent Update Conflict

Scenario: Two requests update the same user simultaneously. Handling:
{
  "statusCode": 409,
  "error": "Conflict",
  "message": "Resource was modified. Please refresh and try again."
}
Implementation: Optimistic locking with version number. Second request fails condition check.

Request Validation

Email Format

const emailSchema = z.string()
  .email()
  .max(254)
  .transform(email => email.toLowerCase().trim());

Phone Format

const phoneSchema = z.string()
  .regex(/^\+[1-9]\d{1,14}$/)  // E.164 format
  .optional();

Name Fields

const nameSchema = z.string()
  .min(1)
  .max(100)
  .regex(/^[\p{L}\p{M}\s'-]+$/u);  // Unicode letters, marks, spaces, hyphens, apostrophes

Error Response Format

All errors follow a consistent format:
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "Human-readable error message",
  "details": [
    {
      "field": "email",
      "message": "Invalid email format"
    }
  ],
  "requestId": "req-abc-123"
}
The requestId enables correlation with CloudWatch logs for debugging.