API Authentication & Authorization - customers.dev

Technical documentation for developers integrating with the customers.dev API.

Table of Contents

  1. Authentication Overview
  2. API Key Authentication
  3. Authorization Flow
  4. Request/Response Examples
  5. Error Handling
  6. Rate Limiting
  7. Security Best Practices

Authentication Overview

Authentication Methods

customers.dev supports two authentication methods:

  1. Session-based (Dashboard): Clerk authentication for web application
  2. 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 logic

Endpoint Structure

Base URL: https://api.customers.dev/v1

Endpoints:
  /customers              - Customer operations
  /contacts               - Contact operations
  /lists                  - List operations
  /lists/:id/members      - List membership operations

API 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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Key Components

PartDescriptionExample
PrefixSecret key identifiersk_
EnvironmentTest or Livetest_ or live_
Random String32 character unique IDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Permission Scopes

API keys support fine-grained permissions:

type ApiKeyPermission =
  | "customers:read"
  | "customers:write"
  | "contacts:read"
  | "contacts:write"
  | "lists:read"
  | "lists:write"

Permission Hierarchy:

  • write permission includes read for the same resource
  • No cross-resource permissions (e.g., customers:write doesn't grant contacts: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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Successful 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 resets

Rate Limit Windows

WindowDefault LimitCalculation
Minute60 requestsRolling window, resets every minute
Hour1,000 requestsRolling window, resets every hour
Day10,000 requestsRolling 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:

  1. Implement exponential backoff
  2. Respect Retry-After header
  3. Cache responses when possible
  4. Batch operations
  5. Monitor rate limit headers
  6. 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 repository

Key Rotation

Recommended Schedule:

  • Development keys: Rotate every 90 days
  • Production keys: Rotate every 30-60 days
  • Compromised keys: Revoke immediately

Rotation Process:

  1. Create new API key with same permissions
  2. Update application configuration
  3. Deploy application with new key
  4. Verify new key is working
  5. Revoke old key
  6. 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/customers

Validate 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

On this page