API Authentication & Authorization - customers.dev
Technical documentation for developers integrating with the customers.dev API.
Table of Contents
- Authentication Overview
- API Key Authentication
- Authorization Flow
- Request/Response Examples
- Error Handling
- Rate Limiting
- Security Best Practices
Authentication Overview
Authentication Methods
customers.dev supports two authentication methods:
- Session-based (Dashboard): Clerk authentication for web application
- API Key-based (Programmatic): Bearer token authentication for API access
Architecture
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client │────────>│ Convex API │────────>│ Convex │
│ Request │ API Key │ HTTP Action │ Verify │ Functions │
└─────────────┘ └──────────────┘ └─────────────┘
│
├──> validateApiKey()
├──> checkRateLimit()
├──> trackApiKeyUsage()
└──> Execute business logicEndpoint Structure
Base URL: https://api.customers.dev/v1
Endpoints:
/customers - Customer operations
/contacts - Contact operations
/lists - List operations
/lists/:id/members - List membership operationsAPI Key Authentication
Creating API Keys
API keys are created through the dashboard by Admins/Owners:
Dashboard Path: Settings → API Keys → Create API Key
Key Properties:
- Name (descriptive identifier)
- Permissions (granular access control)
- Rate limits (requests per minute/hour/day)
- Expiration (optional)
Key Format:
sk_{env}_{32_random_chars}
Examples:
sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKey Components
| Part | Description | Example |
|---|---|---|
| Prefix | Secret key identifier | sk_ |
| Environment | Test or Live | test_ or live_ |
| Random String | 32 character unique ID | xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
Permission Scopes
API keys support fine-grained permissions:
type ApiKeyPermission =
| "customers:read"
| "customers:write"
| "contacts:read"
| "contacts:write"
| "lists:read"
| "lists:write"Permission Hierarchy:
writepermission includesreadfor the same resource- No cross-resource permissions (e.g.,
customers:writedoesn't grantcontacts:write)
Authorization Flow
Step 1: API Key Validation
// Convex function: validateApiKey
export const validateApiKey = query({
args: { apiKey: v.string() },
handler: async (ctx, args) => {
// 1. Hash the provided API key
const keyHash = hashApiKey(args.apiKey)
const keyPrefix = args.apiKey.substring(0, 12)
// 2. Find API key in database
const apiKeys = await ctx.db
.query("apiKeys")
.withIndex("by_key_prefix", (q) => q.eq("keyPrefix", keyPrefix))
.collect()
const apiKey = apiKeys.find((key) => key.keyHash === keyHash)
if (!apiKey) {
return { valid: false, error: "Invalid API key" }
}
// 3. Check status
if (apiKey.status !== "active") {
return { valid: false, error: "API key is not active" }
}
// 4. Check expiration
if (apiKey.expiresAt && apiKey.expiresAt < Date.now()) {
await ctx.db.patch(apiKey._id, { status: "expired" })
return { valid: false, error: "API key has expired" }
}
// 5. Return key metadata
return {
valid: true,
apiKeyId: apiKey._id,
orgId: apiKey.orgId,
permissions: apiKey.permissions,
rateLimit: apiKey.rateLimit,
}
},
})Step 2: Rate Limit Check
// Convex function: checkRateLimit
export const checkRateLimit = query({
args: { apiKeyId: v.id("apiKeys") },
handler: async (ctx, args) => {
const apiKey = await ctx.db.get(args.apiKeyId)
if (!apiKey) {
return { allowed: false, error: "API key not found" }
}
const now = Date.now()
// Calculate time windows
const minuteWindow = Math.floor(now / (60 * 1000)) * (60 * 1000)
const hourWindow = Math.floor(now / (60 * 60 * 1000)) * (60 * 60 * 1000)
const dayWindow = Math.floor(now / (24 * 60 * 60 * 1000)) * (24 * 60 * 60 * 1000)
// Check minute limit
const minuteRequests = await ctx.db
.query("apiKeyUsage")
.withIndex("by_minute_window", (q) =>
q.eq("apiKeyId", args.apiKeyId).eq("minuteWindow", minuteWindow)
)
.collect()
if (minuteRequests.length >= apiKey.rateLimit.requestsPerMinute) {
return {
allowed: false,
error: "Rate limit exceeded for requests per minute",
limit: apiKey.rateLimit.requestsPerMinute,
used: minuteRequests.length,
resetAt: minuteWindow + 60 * 1000,
}
}
// Check hour limit
// ... (similar logic for hour and day)
return {
allowed: true,
remaining: {
perMinute: apiKey.rateLimit.requestsPerMinute - minuteRequests.length,
perHour: apiKey.rateLimit.requestsPerHour - hourRequests.length,
perDay: apiKey.rateLimit.requestsPerDay - dayRequests.length,
},
}
},
})Step 3: Permission Check
// Pseudo-code for HTTP Action
async function handleRequest(request: Request) {
// 1. Extract API key from Authorization header
const apiKey = extractApiKey(request.headers.get("Authorization"))
// 2. Validate API key
const validation = await ctx.runQuery(api.apiKeys.validateApiKey, { apiKey })
if (!validation.valid) {
return new Response(JSON.stringify({ error: validation.error }), {
status: 401,
headers: { "Content-Type": "application/json" }
})
}
// 3. Check rate limit
const rateLimit = await ctx.runQuery(api.apiKeys.checkRateLimit, {
apiKeyId: validation.apiKeyId
})
if (!rateLimit.allowed) {
return new Response(JSON.stringify({ error: rateLimit.error }), {
status: 429,
headers: {
"Content-Type": "application/json",
"X-RateLimit-Limit": rateLimit.limit.toString(),
"X-RateLimit-Used": rateLimit.used.toString(),
"X-RateLimit-Reset": rateLimit.resetAt.toString(),
}
})
}
// 4. Check endpoint permission
const requiredPermission = getRequiredPermission(request.method, request.url)
if (!validation.permissions.includes(requiredPermission)) {
return new Response(JSON.stringify({
error: "Insufficient permissions",
required: requiredPermission,
granted: validation.permissions
}), {
status: 403,
headers: { "Content-Type": "application/json" }
})
}
// 5. Track usage
await ctx.runMutation(api.apiKeys.trackApiKeyUsage, {
apiKeyId: validation.apiKeyId,
orgId: validation.orgId,
endpoint: request.url,
method: request.method,
statusCode: 200, // Will be updated after execution
ipAddress: request.headers.get("x-forwarded-for"),
userAgent: request.headers.get("user-agent"),
})
// 6. Execute business logic with orgId context
return await executeBusinessLogic(validation.orgId, request)
}Step 4: Usage Tracking
// Convex function: trackApiKeyUsage
export const trackApiKeyUsage = mutation({
args: {
apiKeyId: v.id("apiKeys"),
orgId: v.id("organizations"),
endpoint: v.string(),
method: v.string(),
statusCode: v.number(),
responseTime: v.optional(v.number()),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now()
// Calculate time windows for rate limiting
const minuteWindow = Math.floor(now / (60 * 1000)) * (60 * 1000)
const hourWindow = Math.floor(now / (60 * 60 * 1000)) * (60 * 60 * 1000)
const dayWindow = Math.floor(now / (24 * 60 * 60 * 1000)) * (24 * 60 * 60 * 1000)
// Insert usage record
await ctx.db.insert("apiKeyUsage", {
apiKeyId: args.apiKeyId,
orgId: args.orgId,
endpoint: args.endpoint,
method: args.method,
statusCode: args.statusCode,
responseTime: args.responseTime,
ipAddress: args.ipAddress,
userAgent: args.userAgent,
minuteWindow,
hourWindow,
dayWindow,
timestamp: now,
})
// Update lastUsedAt on API key
await ctx.db.patch(args.apiKeyId, {
lastUsedAt: now,
})
return { success: true }
},
})Request/Response Examples
Authentication Header
Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxSuccessful Request
Request:
curl -X GET https://api.customers.dev/v1/customers \
-H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json"Response:
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1704067200
{
"data": [
{
"id": "cust_abc123",
"name": "Acme Corp",
"domain": "acme.com",
"status": "customer",
"createdAt": 1704000000000
}
],
"cursor": "eyJpZCI6ImN1c3RfYWJjMTIzIn0",
"hasMore": true
}Create Customer with Authentication
Request:
curl -X POST https://api.customers.dev/v1/customers \
-H "Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"name": "TechStart Inc",
"domain": "techstart.io",
"status": "lead",
"industry": "SaaS",
"employeeCount": 50
}'Response:
HTTP/1.1 201 Created
Content-Type: application/json
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1704067200
{
"id": "cust_xyz789",
"name": "TechStart Inc",
"domain": "techstart.io",
"status": "lead",
"industry": "SaaS",
"employeeCount": 50,
"createdAt": 1704001000000,
"updatedAt": 1704001000000
}Error Handling
Authentication Errors
Invalid API Key:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": "Invalid API key",
"code": "INVALID_API_KEY",
"message": "The provided API key is not valid. Please check your credentials."
}Expired API Key:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": "API key has expired",
"code": "EXPIRED_API_KEY",
"message": "This API key expired on 2024-01-15. Please create a new key."
}Revoked API Key:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": "API key is not active",
"code": "REVOKED_API_KEY",
"message": "This API key has been revoked. Please create a new key."
}Authorization Errors
Insufficient Permissions:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "Insufficient permissions",
"code": "INSUFFICIENT_PERMISSIONS",
"message": "This API key does not have 'customers:write' permission.",
"required": "customers:write",
"granted": ["customers:read", "contacts:read"]
}Rate Limit Errors
Rate Limit Exceeded:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
X-RateLimit-Limit: 60
X-RateLimit-Used: 60
X-RateLimit-Reset: 1704067200
Retry-After: 45
{
"error": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded for requests per minute",
"limit": 60,
"used": 60,
"resetAt": 1704067200,
"retryAfter": 45
}Error Response Schema
interface ErrorResponse {
error: string // Human-readable error message
code: string // Machine-readable error code
message?: string // Detailed explanation
details?: Record<string, any> // Additional context
}
// Error codes
type ErrorCode =
| "INVALID_API_KEY"
| "EXPIRED_API_KEY"
| "REVOKED_API_KEY"
| "INSUFFICIENT_PERMISSIONS"
| "RATE_LIMIT_EXCEEDED"
| "VALIDATION_ERROR"
| "RESOURCE_NOT_FOUND"
| "INTERNAL_SERVER_ERROR"Rate Limiting
Rate Limit Headers
All API responses include rate limit information:
X-RateLimit-Limit: 60 # Maximum requests allowed
X-RateLimit-Remaining: 45 # Requests remaining in window
X-RateLimit-Reset: 1704067200 # Unix timestamp when limit resetsRate Limit Windows
| Window | Default Limit | Calculation |
|---|---|---|
| Minute | 60 requests | Rolling window, resets every minute |
| Hour | 1,000 requests | Rolling window, resets every hour |
| Day | 10,000 requests | Rolling window, resets every day |
Rate Limit Algorithm
// Time window calculation
const now = Date.now()
const minuteWindow = Math.floor(now / (60 * 1000)) * (60 * 1000)
const hourWindow = Math.floor(now / (60 * 60 * 1000)) * (60 * 60 * 1000)
const dayWindow = Math.floor(now / (24 * 60 * 60 * 1000)) * (24 * 60 * 60 * 1000)
// Query usage in current window
const requests = await ctx.db
.query("apiKeyUsage")
.withIndex("by_minute_window", (q) =>
q.eq("apiKeyId", apiKeyId).eq("minuteWindow", minuteWindow)
)
.collect()
// Check if limit exceeded
if (requests.length >= rateLimit.requestsPerMinute) {
return {
allowed: false,
resetAt: minuteWindow + 60 * 1000 // Next minute
}
}Handling Rate Limits
Client-Side Strategy:
async function makeApiRequest(url: string, options: RequestInit, retries = 3) {
for (let i = 0; i < retries; i++) {
const response = await fetch(url, options)
if (response.status === 429) {
// Rate limit exceeded
const retryAfter = parseInt(response.headers.get("Retry-After") || "60")
if (i < retries - 1) {
// Wait with exponential backoff
const delay = Math.min(retryAfter * 1000 * Math.pow(2, i), 60000)
await new Promise(resolve => setTimeout(resolve, delay))
continue
}
}
return response
}
throw new Error("Rate limit exceeded after retries")
}Best Practices:
- Implement exponential backoff
- Respect
Retry-Afterheader - Cache responses when possible
- Batch operations
- Monitor rate limit headers
- Upgrade plan if consistently hitting limits
Security Best Practices
API Key Storage
✅ Secure Storage:
# Environment variables
export CUSTOMERS_API_KEY="sk_test_xxxx..."
# .env file (gitignored)
CUSTOMERS_API_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Secret management systems
aws secretsmanager get-secret-value --secret-id customers-api-key# Python example
import os
from dotenv import load_dotenv
load_dotenv()
api_key = os.environ["CUSTOMERS_API_KEY"]// Node.js example
import { config } from 'dotenv'
config()
const apiKey = process.env.CUSTOMERS_API_KEY❌ Insecure Storage:
// NEVER do this!
const apiKey = "sk_test_xxxx..." // Hardcoded// NEVER commit this!
// .env file in git repositoryKey Rotation
Recommended Schedule:
- Development keys: Rotate every 90 days
- Production keys: Rotate every 30-60 days
- Compromised keys: Revoke immediately
Rotation Process:
- Create new API key with same permissions
- Update application configuration
- Deploy application with new key
- Verify new key is working
- Revoke old key
- Monitor for errors
Least Privilege Principle
Grant minimum required permissions:
// Good: Specific permissions for analytics service
{
"name": "Analytics Service",
"permissions": ["customers:read", "contacts:read"]
}
// Bad: Overly broad permissions
{
"name": "Analytics Service",
"permissions": [
"customers:read",
"customers:write",
"contacts:read",
"contacts:write",
"lists:read",
"lists:write"
]
}Monitoring & Alerts
Monitor for suspicious activity:
- Unusual request patterns
- Failed authentication attempts
- Permission violations
- Rate limit violations
- Requests from unexpected IP addresses
Example Alert Rules:
alerts:
- name: "High Failed Auth Rate"
condition: "failed_auth_requests > 10 in 5 minutes"
action: "Send email to security team"
- name: "Unusual IP Address"
condition: "request from new IP and production key"
action: "Send email to security team"
- name: "Permission Violations"
condition: "403 responses > 5 in 1 minute"
action: "Send email to API key owner"Network Security
Use HTTPS exclusively:
# Good
https://api.customers.dev/v1/customers
# Bad - API will redirect to HTTPS
http://api.customers.dev/v1/customersValidate SSL certificates:
import requests
# Good - verify SSL
response = requests.get(
"https://api.customers.dev/v1/customers",
headers={"Authorization": f"Bearer {api_key}"},
verify=True # Default, but explicit is better
)
# Bad - disable SSL verification
response = requests.get(
"https://api.customers.dev/v1/customers",
headers={"Authorization": f"Bearer {api_key}"},
verify=False # NEVER do this in production!
)IP Whitelisting (Enterprise)
// Configure IP whitelist for API key (Enterprise feature)
{
"name": "Production API Key",
"permissions": ["customers:read", "customers:write"],
"ipWhitelist": [
"203.0.113.0/24",
"198.51.100.42"
]
}Appendix: Complete Flow Diagram
┌──────────────────────────────────────────────────────────────────┐
│ Client Application │
└──────────────────────────────────────────────────────────────────┘
│
│ 1. HTTP Request
│ Authorization: Bearer sk_test_...
▼
┌──────────────────────────────────────────────────────────────────┐
│ Convex HTTP Action Handler │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 1: Extract API Key from Authorization Header │ │
│ │ const apiKey = extractApiKey(request.headers) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 2: Validate API Key │ │
│ │ validateApiKey(apiKey) │ │
│ │ - Hash key │ │
│ │ - Find in database │ │
│ │ - Check status (active/revoked/expired) │ │
│ │ - Return: valid, orgId, permissions, rateLimit │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 3: Check Rate Limit │ │
│ │ checkRateLimit(apiKeyId) │ │
│ │ - Count requests in minute/hour/day windows │ │
│ │ - Compare against limits │ │
│ │ - Return: allowed, remaining, resetAt │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 4: Check Permissions │ │
│ │ - Determine required permission for endpoint │ │
│ │ - Check if API key has permission │ │
│ │ - Return 403 if insufficient │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 5: Execute Business Logic │ │
│ │ - Call Convex functions with orgId context │ │
│ │ - Process request │ │
│ │ - Generate response │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 6: Track Usage │ │
│ │ trackApiKeyUsage(...) │ │
│ │ - Record request details │ │
│ │ - Update lastUsedAt │ │
│ │ - Store time windows for rate limiting │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│
│ 6. HTTP Response
│ X-RateLimit-* headers
▼
┌──────────────────────────────────────────────────────────────────┐
│ Client Application │
└──────────────────────────────────────────────────────────────────┘Last Updated: January 2025 Version: 1.0.0