Skip to main content

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": "[email protected]",
  "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": "[email protected]",
  "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": "[email protected]",
      "isPrimary": true,
      "isVerified": true,
      "verifiedAt": "2026-01-15T10:35:00Z",
      "createdAt": "2026-01-15T10:30:00Z"
    },
    {
      "emailId": "email-002",
      "email": "[email protected]",
      "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": "[email protected]"
}
Response:
{
  "emailId": "email-003",
  "email": "[email protected]",
  "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.