Technical Architecture
Technical Architecture
API Key Format
Structure: pm_live_{32_random_chars}
Example: pm_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Components:
-
pm- PenguinMails prefix -
live- Environment indicator (live, test) -
{32_random_chars}- Cryptographically secure random string (base62: a-z, A-Z, 0-9)
Generation:
import crypto from 'crypto';
function generateAPIKey(): string {
const randomBytes = crypto.randomBytes(24); // 24 bytes = 32 base62 chars
const base62 = randomBytes.toString('base64')
.replace(/\+/g, '')
.replace(/\//g, '')
.replace(/=/g, '')
.substring(0, 32);
return `pm_live_${base62}`;
}
Vault Secret Structure
Path: /api_keys/{tenant_id}/{key_id}
Secret Fields:
{
"key_hash": "bcrypt_hash_of_api_key",
"permissions": ["send_email", "read_analytics", "manage_contacts"],
"rate_limit": 300,
"created_at": "2025-11-26T10:00:00Z",
"last_used": "2025-11-26T15:30:00Z",
"request_count": 15234,
"error_count": 42,
"rotation_policy": "on_demand",
"status": "active",
"name": "Production Server",
"created_by": "user_id_123"
}
Field Descriptions:
-
key_hash- bcrypt hash of API key (salt rounds: 12) -
permissions- Array of permission scopes -
rate_limit- Requests per minute allowed (tier-based) -
created_at- ISO 8601 timestamp of key creation -
last_used- ISO 8601 timestamp of last API request -
request_count- Total API requests made with this key -
error_count- Total errors (4xx, 5xx responses) -
rotation_policy- “on_demand” (manual regeneration only) -
status- “active” or “revoked” -
name- User-provided key name -
created_by- User ID who created the key
Bcrypt Hashing
Why bcrypt?
-
Slow hashing algorithm (prevents brute force attacks)
-
Adaptive (can increase cost factor over time)
-
Industry standard for password/key hashing
Configuration:
-
Salt rounds: 12 (2^12 = 4096 iterations)
-
Hashing time: ~250ms per key (acceptable for key generation)
Implementation:
import bcrypt from 'bcrypt';
async function hashAPIKey(apiKey: string): Promise<string> {
const saltRounds = 12;
const hash = await bcrypt.hash(apiKey, saltRounds);
return hash;
}
async function verifyAPIKey(apiKey: string, hash: string): Promise<boolean> {
return await bcrypt.compare(apiKey, hash);
}
Permission Scopes
| Scope | Description | API Endpoints |
|---|---|---|
send_email | Send emails via API | POST /api/v1/emails/send |
read_analytics | Read campaign and email analytics | GET /api/v1/analytics/* |
manage_contacts | Create, update, delete contacts | POST/PUT/DELETE /api/v1/contacts/* |
manage_campaigns | Create, update, delete campaigns | POST/PUT/DELETE /api/v1/campaigns/* |
manage_templates | Create, update, delete templates | POST/PUT/DELETE /api/v1/templates/* |
manage_domains | Add, verify, delete domains | POST/PUT/DELETE /api/v1/domains/* |
read_inbox | Read unified inbox messages | GET /api/v1/inbox/* |
manage_webhooks | Configure webhooks | POST/PUT/DELETE /api/v1/webhooks/* |
Scope Validation:
-
Each API endpoint checks required scope
-
Returns 403 Forbidden if scope missing
-
Multiple scopes can be assigned to single key
Example:
// API endpoint with scope validation
app.post('/api/v1/emails/send',
authenticateAPIKey,
requireScope('send_email'),
async (req, res) => {
// Send email logic
}
);
// Middleware to check scope
function requireScope(scope: string) {
return (req, res, next) => {
if (!req.apiKey.permissions.includes(scope)) {
return res.status(403).json({
error: 'Insufficient permissions',
required_scope: scope,
available_scopes: req.apiKey.permissions
});
}
next();
};
}
Rate Limiting
Tier-Based Limits:
| Subscription Tier | Rate Limit | Burst Limit | Overage Behavior |
|---|---|---|---|
| Starter | 60 requests/min | 100 requests | 429 Too Many Requests |
| Pro | 300 requests/min | 500 requests | 429 Too Many Requests |
| Enterprise | 1000 requests/min | 2000 requests | 429 Too Many Requests |
Rate Limit Algorithm: Token Bucket
Implementation:
import Redis from 'ioredis';
const redis = new Redis();
async function checkRateLimit(
apiKeyId: string,
rateLimit: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `rate_limit:${apiKeyId}`;
const now = Date.now();
const windowStart = now - 60000; // 1 minute window
// Remove old requests outside window
await redis.zremrangebyscore(key, 0, windowStart);
// Count requests in current window
const requestCount = await redis.zcard(key);
if (requestCount >= rateLimit) {
return { allowed: false, remaining: 0 };
}
// Add current request
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.expire(key, 60); // Expire after 1 minute
return {
allowed: true,
remaining: rateLimit - requestCount - 1
};
}
Rate Limit Headers:
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 1732618800
Usage Tracking
Tracked Metrics:
-
Total requests (lifetime)
-
Error count (4xx, 5xx responses)
-
Last used timestamp
-
Requests per day (last 30 days)
-
Most common endpoints
-
Geographic distribution (IP-based)
Storage: PostgreSQL + Redis
Database Schema:
CREATE TABLE api_key_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
api_key_id UUID NOT NULL,
endpoint VARCHAR(255) NOT NULL,
method VARCHAR(10) NOT NULL,
status_code INTEGER NOT NULL,
response_time_ms INTEGER NOT NULL,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_api_key_usage_tenant_key
ON api_key_usage(tenant_id, api_key_id, created_at DESC);
CREATE INDEX idx_api_key_usage_created_at
ON api_key_usage(created_at DESC);
Usage Aggregation:
async function getAPIKeyUsageStats(
tenantId: string,
apiKeyId: string
): Promise<UsageStats> {
const stats = await db.query(`
SELECT
COUNT(*) as total_requests,
COUNT(*) FILTER (WHERE status_code >= 400) as error_count,
MAX(created_at) as last_used,
AVG(response_time_ms) as avg_response_time
FROM api_key_usage
WHERE tenant_id = $1 AND api_key_id = $2
`, [tenantId, apiKeyId]);
return stats.rows[0];
}