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.
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"
}
| Status | Description |
|---|
| 200 | Success |
| 401 | Missing or invalid JWT |
| 404 | User not found in DynamoDB (data sync issue) |
Update Current User
Update the authenticated user’s profile.
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"
}
| Status | Description |
|---|
| 200 | Success |
| 400 | Invalid request body |
| 401 | Missing or invalid JWT |
| 409 | Conflict (concurrent update, version mismatch) |
Delete Current User
Soft-delete the authenticated user’s account.
Sets user status to deleted. Does not remove data.
Response:
{
"message": "Account scheduled for deletion",
"deletedAt": "2026-01-21T09:30:00Z"
}
| Status | Description |
|---|
| 200 | Success |
| 401 | Missing 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.
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.
Adds a new email. Verification required before use.
Request:
Response:
{
"emailId": "email-003",
"email": "[email protected]",
"isPrimary": false,
"isVerified": false,
"createdAt": "2026-01-21T10:00:00Z"
}
| Status | Description |
|---|
| 201 | Email added, verification sent |
| 400 | Invalid email format |
| 409 | Email already in use |
| 429 | Too 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.
| Status | Description |
|---|
| 204 | Email deleted |
| 400 | Cannot delete primary email |
| 400 | Cannot delete last email |
| 404 | Email 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
}
| Status | Description |
|---|
| 200 | Code sent |
| 400 | Email already verified |
| 429 | Rate limit (max 3/hour) |
Confirm Verification
Confirm email ownership with verification code.
/users/me/emails/{emailId}/verify/confirm
Confirms the verification code.
Request:
| Status | Description |
|---|
| 200 | Email verified |
| 400 | Invalid or expired code |
| 429 | Too 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.
| Status | Description |
|---|
| 200 | Primary email updated |
| 400 | Email not verified |
| 404 | Email 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:
| PK | SK | count | ttl |
|---|
| RATELIMIT#verify# | HOUR# | 2 | 1705845600 |
| RATELIMIT#confirm# | ATTEMPTS | 3 | 1705845600 |
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
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
const emailSchema = z.string()
.email()
.max(254)
.transform(email => email.toLowerCase().trim());
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
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.