Rate limiting is a foundational API pattern — every production endpoint needs it. But the traditional approach means standing up Redis, wiring it to your API gateway, and building a separate alerting pipeline. That's three services before you've written a single line of business logic.
This sample shows a different approach: a sliding-window rate limiter that runs entirely on Telnyx Edge Compute using the Agent SDK. KV counters track request counts per key, TTL-based windows auto-expire without cleanup code, over-limit requests get rejected with HTTP 429, and SMS alerts fire automatically when rejections cross a threshold — all from a single actor with zero external dependencies.
What We're Building
A rate limiter that:
- Tracks request counts per key (phone number, IP address, tenant ID) in Edge KV
- Uses sliding time windows with TTL-based auto-expiry — no cleanup jobs
- Returns HTTP 429 with metadata when a key exceeds its limit
- Sends SMS alerts via a zero-credential
[telnyx]binding when rejections sustain - Isolates each key in its own Agent SDK actor — no contention between keys
- Provides a
/simulateendpoint for testing without real traffic
Architecture
Client Request
│
▼
POST /check
│
▼
┌────────────────────┐
│ index.ts │
│ (HTTP router) │
└────┬───────────────┘
│
▼
┌──────────────────────┐
│ RateLimitAgent │ (one actor per key)
│ │
│ 1. checkLimit() │──► KV: GET current window count
│ if under limit │
│ 2a. allow() │──► KV: PUT incremented count (TTL) → 200
│ if over limit │
│ 2b. reject() │──► 429 + track rejection count
│ if rejections │
│ >= threshold │
│ 3. sendAlert() │──► SMS via [telnyx] binding
└──────────────────────┘
The Sliding Window Pattern
The key insight is how KV keys are structured. Each key encodes the rate-limited identifier and the window start time:
rate:+18005551234:1718928000 ← count for the 14:00–14:01 window
rate:+18005551234:1718928060 ← count for the 14:01–14:02 window
rate:+18005559876:1718928000 ← different key, same window
Each KV entry has a TTL of WINDOW_SECONDS (default: 60). When the window passes, the entry auto-expires. No cron jobs, no cleanup code, no Redis eviction policies to tune. This is a fixed-window approximation of a sliding window — simpler than a true sliding window log, but it uses exactly one KV get and one KV put per request.
The Agent SDK Pipeline
Each rate-limited key gets its own RateLimitAgent actor instance. When /check is called, the agent queues a non-blocking pipeline:
Stage 1: checkLimit()
async checkLimit(): Promise<void> {
const state = await this.getState();
const windowStart = Math.floor(Date.now() / 1000 / state.windowSeconds) * state.windowSeconds;
const kvKey = `rate:${state.key}:${windowStart}`;
const currentStr = await this.env.RATE_KV.get(kvKey);
const currentCount = currentStr ? parseInt(currentStr, 10) : 0;
await this.setState({
...state,
currentCount,
totalRequests: state.totalRequests + 1,
lastRequestAt: Date.now(),
});
if (currentCount < state.limit) {
await this.queue("allow");
} else {
await this.queue("reject");
}
}
The agent reads the current window count from KV, updates its state, and then queues the next stage based on whether the key is under or over the limit.
Stage 2a: allow()
async allow(): Promise<void> {
const state = await this.getState();
const windowStart = Math.floor(Date.now() / 1000 / state.windowSeconds) * state.windowSeconds;
const kvKey = `rate:${state.key}:${windowStart}`;
const newCount = state.currentCount + 1;
await this.env.RATE_KV.put(kvKey, String(newCount), {
expirationTtl: state.windowSeconds,
});
await this.setState({
...state,
currentCount: newCount,
allowedRequests: state.allowedRequests + 1,
status: "allowed",
});
await this.queue("finalize");
}
The counter is incremented and written back to KV with a TTL. The request is marked as allowed.
Stage 2b: reject()
async reject(): Promise<void> {
const state = await this.getState();
const newRejectionCount = state.rejectionCount + 1;
await this.setState({
...state,
rejectedRequests: state.rejectedRequests + 1,
rejectionCount: newRejectionCount,
status: newRejectionCount >= state.alertThreshold ? "alerting" : "rejected",
});
if (newRejectionCount >= state.alertThreshold && !state.alertTriggered) {
await this.queue("sendAlert");
} else {
await this.queue("finalize");
}
}
When a request is rejected, the agent increments its rejection counter. If the rejection count crosses ALERT_THRESHOLD and no alert has been sent yet, the alert stage is queued.
Stage 3: sendAlert()
async sendAlert(): Promise<void> {
const state = await this.getState();
const smsText =
`Rate limit alert: key "${state.key}" has been rejected ${state.rejectionCount} times. ` +
`Limit is ${state.limit} requests per ${state.windowSeconds}s window. ` +
`Total requests: ${state.totalRequests}. Allowed: ${state.allowedRequests}. Rejected: ${state.rejectedRequests}.`;
await this.env.TELNYX.messages.send({
from: this.env.SENDER_PHONE,
to: this.env.ALERT_PHONE,
text: smsText,
});
await this.setState({ ...state, alertTriggered: true, status: "done" });
}
The SMS is sent via the [telnyx] binding — no API key in code, no HTTP client, no auth header. The binding handles credentials at the platform level.
Quick Start
1. Clone and install
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/kv-backed-rate-limiter
npm install
2. Configure environment
Copy .env.example and fill in your values:
cp .env.example .env
| Variable | Description | Example |
|---|---|---|
TELNYX_API_KEY |
Telnyx API key | KEY019... |
SENDER_PHONE |
Telnyx number sending alerts | +18005551234 |
ALERT_PHONE |
Ops phone receiving alerts | +18005559876 |
RATE_LIMIT |
Max requests per window per key | 10 |
WINDOW_SECONDS |
Sliding window duration |
60 (1 minute) |
ALERT_THRESHOLD |
Rejections before SMS fires | 5 |
3. Deploy
telnyx-edge secret set TELNYX_API_KEY KEY0123456789ABCDEF
telnyx-edge ship
4. Test the rate limit
# Check a single request
curl -X POST https://your-deployment.telnyxcompute.com/check \
-H "Content-Type: application/json" \
-d '{"key":"+18005551234"}'
# Simulate a burst of 15 requests (limit is 10 → 10 allowed, 5 rejected)
curl -X POST https://your-deployment.telnyxcompute.com/simulate \
-H "Content-Type: application/json" \
-d '{"key":"+18005551234","count":15}'
The simulate response shows each request, whether it was allowed or rejected, and the current count:
{
"key": "+18005551234",
"simulated": 15,
"allowed": 10,
"rejected": 5,
"alertTriggered": true,
"results": [
{ "request": 1, "action": "allowed", "currentCount": 1 },
{ "request": 2, "action": "allowed", "currentCount": 2 },
...
{ "request": 11, "action": "rejected", "currentCount": 10 },
{ "request": 12, "action": "rejected", "currentCount": 10 },
...
]
}
API Endpoints
| Method | Path | Description |
|---|---|---|
POST |
/check |
Check a request against the rate limit |
GET |
/status/:key |
Get agent state for a specific key |
GET |
/count/:key |
Get current window count for a key |
GET |
/keys |
List all tracked keys with aggregate stats |
POST |
/reset/:key |
Reset a key's counter |
POST |
/simulate |
Simulate a burst of requests for testing |
GET |
/config |
Get current rate limit configuration |
GET |
/health/liveness |
Liveness probe |
GET |
/health/readiness |
Readiness probe |
Why This Matters
No Redis, no external services
The traditional rate limiter needs Redis for the counter store and a separate alerting service (SNS, PagerDuty, etc.) for threshold notifications. This sample replaces both with Edge KV (built-in binding) and the [telnyx] messaging binding (zero-credential SMS). One deploy, zero external dependencies.
TTL-based cleanup
The sliding window is implemented as a fixed-window approximation: each window gets its own KV key with a TTL. When the window expires, the key auto-expires. No cleanup cron jobs, no eviction policy tuning, no memory management. The window boundary is computed from Math.floor(Date.now() / 1000 / windowSeconds) * windowSeconds.
Per-key actor isolation
Each rate-limited key gets its own RateLimitAgent actor instance. This means:
- No contention between concurrent keys
- Per-key state (allowed/rejected counts, alert status) survives across requests
- The
RateLimitRegistrysingleton actor aggregates across keys for the/keysendpoint
Non-blocking pipeline
The this.queue() pattern from the Agent SDK means each stage runs asynchronously. The HTTP request that triggers checkRequest() returns immediately — the pipeline stages execute in the background. This is critical for rate limiting: you don't want the client to wait for the alert SMS to send before getting their 429.
Use Cases
- API rate limiting — Limit requests per API key, IP, or tenant
- Call Control protection — Limit inbound call rate per phone number before they hit your IVR
- SMS flood prevention — Rate limit outbound SMS per user before sending
- Webhook throttling — Limit incoming webhook rate per source to protect downstream services
- Tenant quotas — Per-tenant request caps on multi-tenant platforms
Frequently Asked Questions
How is this different from a Redis-based rate limiter?
Redis requires a separate service to provision, manage, and pay for. Edge KV is a built-in binding — no provisioning, no connection pooling, no network hops. The TTL-based cleanup is native to KV. The trade-off: Edge KV is eventually consistent, so there's a small window where concurrent requests might both read the same count. For most rate limiting use cases, this is acceptable.
Can I use a true sliding window instead of a fixed window?
Yes. Replace the single counter per window with a sorted set of timestamps (one per request) and count entries within the window. This is more accurate but uses more KV storage and requires listing keys within a range. The fixed-window approximation is simpler and uses exactly one KV get and one KV put per request.
What happens if the SMS alert fails?
The agent catches the error and sets its status to "error" with the error message. The alert is not retried automatically, but the alertTriggered flag is only set on success, so the next rejection will queue another alert attempt. You can monitor for the "error" status via the /status/:key endpoint.
How do I reset a key's counter?
Use the POST /reset/:key endpoint. This deletes the current window's KV entry for that key, effectively resetting the counter to zero.
Resources
- Code sample: kv-backed-rate-limiter
- Agent SDK docs: developers.telnyx.com/docs/agent-sdk
- Edge KV docs: developers.telnyx.com/docs/edge-compute/kv
- Messaging docs: developers.telnyx.com/docs/messaging
- Telnyx Portal: portal.telnyx.com
Top comments (0)