Technical writing
Voidly API authentication: API keys, request signing, and rate limit tiers
Every request to the Voidly API passes through an authentication layer running in Cloudflare Workers at the edge before it reaches any data store. That layer handles key validation, plan tier lookup, rate limit enforcement, and IP allowlisting — in under 10 ms at p50. This post covers how it works: the key format and how keys are stored, the per-request verification flow, plan tiers and their rate limits, HMAC webhook signatures, rotation without downtime, the test sandbox, OAuth2 for third-party integrations, and IP allowlisting for Enterprise accounts.
Two access tiers
Voidly offers two access modes. The public (anonymous) tier requires no API key. Anyone can query country summaries, the recent incident list (last 24 hours), and the public domain history endpoint at 60 requests per hour per IP. No registration, no token.
The authenticated tier unlocks the full API surface: per-domain detail, measurement-level data, webhook configuration, and bulk exports. Authenticated requests carry an API key in the Authorization: Bearer voidly_... header. Rate limits on the authenticated tier vary by plan and are described below.
API key format and generation
Keys follow the format voidly_{environment}_{random_base58}where environment is live or test. The random suffix is 32 bytes encoded in base58 (43 characters), giving roughly 190 bits of entropy. The full key is shown exactly once at generation time; Voidly stores only a PBKDF2-HMAC-SHA256 hash (100,000 iterations) in the database. There is no way to recover the plaintext key from the stored hash.
Key generation runs in a Cloudflare Worker using the Web Crypto API so that random bytes never leave the edge runtime unprocessed:
async function generateApiKey(env: 'live' | 'test'): Promise<{key: string, hash: string}> {
const random = crypto.getRandomValues(new Uint8Array(32));
const base58chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
// base58 encode random bytes
const key = 'voidly_' + env + '_' + encodeBase58(random);
const salt = crypto.getRandomValues(new Uint8Array(16));
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(key),
'PBKDF2',
false,
['deriveBits']
);
const hash = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
256
);
return { key, hash: bufferToHex(hash) };
}The base58 alphabet omits 0, O, I, and l to prevent transcription errors when keys are read aloud or copied from screenshots. The environment prefix (live vs test) is human-readable so that a developer glancing at a log can immediately tell whether production traffic is using the correct key.
Request authentication flow
Each API request is authenticated in the Cloudflare Worker edge middleware before routing to the origin. The authentication path:
- Extract the
Authorization: Bearer voidly_...header. If absent, treat the request as anonymous; apply the public rate limit and restrict to public endpoints. - Slice the first 16 characters of the key as
key_prefix. Look up the key record in Cloudflare D1:SELECT plan_tier, rate_limit_rpm, is_active, key_hash, key_salt FROM api_keys WHERE key_prefix = ?. Using the prefix as the primary index avoids a full table scan while keeping the stored secret opaque. - Recompute PBKDF2-HMAC-SHA256(key, stored_salt, 100000) and compare against the stored hash with a constant-time comparison. A mismatch returns 401.
- Check the rate limit counter in Cloudflare KV at key
api_keys:{key_prefix}:{minute_bucket}. If the counter exceedsrate_limit_rpm, return 429 withRetry-After. - If all checks pass, attach the key's
plan_tierto the request context and forward to the origin handler.
The D1 lookup uses the 16-character key_prefix as the primary index. This means D1 reads a single row regardless of how many keys exist in the database, and the PBKDF2 verification step happens at the edge rather than in the origin service. Edge authentication keeps per-request latency flat as the key count grows.
Plan tiers and rate limits
All plan tiers access the same API endpoints. Limits differ in request throughput, webhook endpoint count, and bulk export availability:
| Tier | Requests/min | Webhooks | Bulk export | Monthly cost |
|---|---|---|---|---|
| Free | 20 | 3 endpoints | No | $0 |
| Research | 120 | 20 endpoints | Yes (daily) | $0 (apply) |
| Professional | 600 | 100 endpoints | Yes (hourly) | $49/mo |
| Enterprise | Unlimited | Unlimited | Real-time | Contact |
The Research tier requires an application with institutional affiliation verification — academic email address, department, and a brief description of the research use case. Approved applications are processed within 5 business days. The Research tier exists because many of the most valuable use cases for the Voidly censorship index are non-commercial research projects that cannot justify a $49/month line item but need more than the Free tier's 20 requests per minute.
HMAC webhook signature verification
Webhooks — for censorship incident alerts and entity change events — use HMAC-SHA256 request signing. Each POST includes an X-Voidly-Signature: sha256={hmac} header where hmac is HMAC-SHA256(webhook_secret, request_body_bytes). The signature covers the raw body bytes before JSON parsing so that any in-transit modification is detectable.
Verification in Python, using constant-time comparison to prevent timing attacks:
import hmac, hashlib
def verify_webhook(body: bytes, signature_header: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)And in JavaScript/TypeScript using the Web Crypto API for environments that do not have Node's crypto module:
async function verifyWebhook(
body: string,
signatureHeader: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
const hex = Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const expected = 'sha256=' + hex;
// Constant-time comparison
if (expected.length !== signatureHeader.length) return false;
let mismatch = 0;
for (let i = 0; i < expected.length; i++) {
mismatch |= expected.charCodeAt(i) ^ signatureHeader.charCodeAt(i);
}
return mismatch === 0;
}The webhook_secret is separate from the API key and is generated per webhook endpoint at registration time. If a webhook secret is compromised, rotating it does not affect the API key and vice versa.
API key rotation
Keys are designed for rotation without downtime. The API accepts two simultaneously valid keys per account: current and previous. The rotation flow:
POST /v1/api-keys/rotate— creates a new key and marks the existing key asprevious. The response contains the new key plaintext (shown once).- Update your application environment to use the new key. Deploy your service. Requests authenticated with either the new or the old key succeed during this window.
POST /v1/api-keys/retire— marks thepreviouskey as retired. Subsequent requests with the old key receive 401.
The previous key is valid for 7 days after rotation even without an explicit /retire call — it expires automatically. This covers the case where a team member rotates a key but forgets to retire the old one: the old key stops working after a week regardless. For emergency revocation (key leak), POST /v1/api-keys/revoke invalidates the key immediately without the 7-day grace period.
Test keys and sandbox mode
Keys with the test environment prefix (voidly_test_...) route to a sandbox environment that returns synthetic data. The sandbox exposes the same API surface as production — identical endpoints, identical response schemas — but measurements are generated from a static fixture dataset rather than live probe data. Test keys have no rate limits and no billing.
The fixture dataset covers 12 countries with pre-seeded incidents at each confidence tier, including ongoing incidents, resolved incidents, and incidents that span multiple interference types. This gives integration tests deterministic data: the same query always returns the same incident IDs, so assertions do not break when live data changes.
Use test keys in CI pipelines and local development. Keep production keys out of CI environments entirely — the environment prefix in the key format makes it easy to validate at startup that the correct key type is in use:
const VOIDLY_API_KEY = process.env.VOIDLY_API_KEY ?? '';
if (process.env.NODE_ENV === 'production' && VOIDLY_API_KEY.startsWith('voidly_test_')) {
throw new Error('Production build is using a test API key. Check VOIDLY_API_KEY.');
}
if (process.env.NODE_ENV !== 'production' && VOIDLY_API_KEY.startsWith('voidly_live_')) {
console.warn('Non-production build is using a live API key. Consider using voidly_test_...');
}OAuth2 for third-party integrations
For applications that embed Voidly data for end users — dashboards, compliance tools, newsroom research tools — Voidly supports the OAuth2 authorization code flow. Users grant access to their Voidly account and the application receives a scoped access token that expires and can be revoked independently of the account's API keys.
Available scopes: incidents:read, measurements:read, webhooks:write, export:read. Most read-only integrations only need incidents:read. Construct the authorization URL by appending the standard OAuth2 parameters to the authorization endpoint:
const params = new URLSearchParams({
response_type: 'code',
client_id: YOUR_CLIENT_ID,
redirect_uri: YOUR_REDIRECT_URI,
scope: 'incidents:read measurements:read',
state: generateSecureState(), // random value, stored in session
});
const authUrl = 'https://voidly.ai/oauth/authorize?' + params.toString();
// Redirect the user to authUrlAfter the user grants access, Voidly redirects to redirect_uriwith a code parameter. Exchange the code for an access token at POST https://voidly.ai/oauth/token using standard OAuth2 code exchange. Access tokens expire after 1 hour; use the refresh token to obtain a new access token without re-prompting the user.
IP allowlisting for Enterprise
Enterprise keys support IP allowlist restrictions. Requests from IPs not on the allowlist receive 403 even with a valid key. The allowlist supports IPv4 CIDR ranges and IPv6 prefixes. Configure it via the dashboard or via the API:
# Add a CIDR range to the IP allowlist
curl -X PATCH https://api.voidly.ai/v1/api-keys/allowlist \
-H "Authorization: Bearer voidly_live_..." \
-H "Content-Type: application/json" \
-d '{"add": ["203.0.113.0/24", "2001:db8::/32"]}'
# Response
{
"allowlist": ["203.0.113.0/24", "2001:db8::/32"],
"updated_at": "2025-01-01T00:00:00Z",
"effective_at": "2025-01-01T00:01:00Z"
}Allowlist changes propagate to all Cloudflare edge nodes via KV within 60 seconds. There is no propagation delay at the individual PoP level — once KV has the new value, all Workers at that PoP pick it up on the next request. The effective_at timestamp in the response reflects the worst-case propagation window, not a scheduled activation time.
Security recommendations
The most common sources of API key leakage are source control commits, CI logs, and error reporting tools. Practical mitigations:
- Store keys in environment variables, not hardcoded in source files. Use secrets management (GitHub Actions secrets, AWS Secrets Manager, Doppler) rather than
.envfiles committed to version control. - Use test keys in CI. CI pipelines that run against the Voidly API should use
voidly_test_...keys, which have no billing and no rate limits. Keep live keys out of CI environment variables entirely. - Rotate keys after team member departures. The two-key rotation flow lets you issue a new key, update all services, and retire the old key without any downtime.
- Rotate webhook secrets after security incidents independently of API key rotation. The two are separate credentials with separate rotation flows.
- Monitor rate limit consumption.
GET /v1/api-keys/usagereturns per-minute request counts for the trailing 24 hours. A sudden spike in usage from an unexpected time zone or IP range may indicate key leakage. Rate limit headers on every response (X-RateLimit-Remaining,X-RateLimit-Reset) also give you a live signal.
For how HMAC webhook signatures work in the alert delivery system — the same signing scheme used for API webhooks: Voidly's alert delivery system: PGP-encrypted email, webhooks, and RSS for censorship incidents →
For the REST API endpoints that this authentication layer protects: The Voidly REST API: querying the global censorship index in real time →
For the SSE streaming API that uses the same API key for long-lived connections: The Voidly SSE streaming API: real-time censorship event delivery →
For how the confidence tier system determines what data is available at which access tier: From anomaly to verified incident: the Voidly confidence tier system →