Log Telnyx Call Control events to a shared SQL database, track per-region call volume in Edge KV counters, and trigger SMS alerts when a region exceeds a configurable threshold — all on Telnyx Edge Compute with the Agent SDK, in under 400 lines of TypeScript.
What You'll Build
A geo-distributed call logger that:
- Receives Telnyx Call Control webhooks (
call.initiated,call.answered,call.hangup) - Logs every call to a per-call SQL database
- Increments per-region counters in Edge KV with rolling-window TTL
- Detects the caller's region from the E.164 country code prefix
- Sends an SMS alert to your ops phone when a region's call count exceeds a threshold
- Exposes HTTP endpoints for querying call status, recent calls, and region statistics
The entire system runs on Telnyx Edge Compute — no external database, no separate alerting service, no cross-cloud latency. The Call Control webhook, the SQL write, the KV increment, and the SMS delivery all happen inside the same network boundary.
Why This Architecture Matters
If you've ever tried to build call analytics across multiple regions, you've probably wired together:
- A webhook receiver (Lambda function or small server)
- A time-series database (InfluxDB, TimescaleDB, or DynamoDB)
- A separate counter service (Redis)
- An alerting pipeline (PagerDuty + SNS + a Lambda)
- A dashboard (Grafana or a custom React app)
That's five services, five deploys, five billing lines, and cross-region latency at every hop. The webhook arrives in one region, the database is in another, the counter service is in a third, and the SMS gateway is a fourth API call.
On Edge Compute with the Agent SDK, this collapses to one deploy. The webhook handler, the SQL database, the KV counters, and the SMS sender are all bindings on the same actor — running at the carrier edge, in the same network boundary as the Call Control platform that sends the webhook.
Architecture
Telnyx Call Control
│
▼
POST /webhooks/voice
│
▼
┌────────────────────┐
│ index.ts │
│ (webhook router) │
└────┬───────────────┘
│
▼
┌──────────────────────┐
│ GeoLoggerAgent │ (one actor per call)
│ │
│ 1. logCall() │──► SQL DB: INSERT call record
│ │──► KV: INCR region counter
│ 2. checkThreshold() │──► compare count vs threshold
│ 3. alert() │──► SMS via [telnyx] binding
└──────────────────────┘
│
▼
┌──────────────────────┐
│ CallRegistry │ (singleton actor)
│ cross-call listing │──► GET /calls
└──────────────────────┘
Each call gets its own GeoLoggerAgent actor instance with isolated state and its own SQL database. When call.hangup fires, the agent queues a 3-stage pipeline:
logCall()— Inserts the call record into the actor's SQL DB and increments the region counter in KV. The KV key includes a window timestamp so counters auto-expire after the rolling window.checkThreshold()— Compares the post-increment region count againstREGION_THRESHOLD. If exceeded, queues the alert stage.alert()— Sends an SMS via the zero-credential[telnyx]binding — no API key needed in code.
Prerequisites
- Node.js 18+
- Telnyx CLI (
npm i -g @telnyx/cli) - A Telnyx account with:
- A phone number configured for Call Control - An API key - A messaging-enabled number (for SMS alerts)
Step 1: Scaffold the Project
Create the directory structure:
geo-distributed-call-logger/
├── src/
│ ├── geoLogger.ts # GeoLoggerAgent + CallRegistry actors + region detection
│ └── index.ts # Webhook handler + HTTP routes
├── telnyx.toml # Edge Compute config (KV, actors, env vars)
├── package.json
├── tsconfig.json
├── telnyx-env.d.ts # Ambient type declarations (KvNamespace)
├── .env.example
└── .gitignore
Step 2: Configure the Edge Compute Bindings
The telnyx.toml file declares the bindings your agent needs:
name = "geo-distributed-call-logger"
main = "src/index.ts"
compatibility_date = "2026-05-01"
[[actors]]
binding = "GEO_LOGGER"
type = "GeoLoggerAgent"
[[actors]]
binding = "REGISTRY"
type = "CallRegistry"
[telnyx]
binding = "TELNYX"
[storage.kv.REGION_KV]
id = "<kv-namespace-uuid>"
[env_vars]
ALERT_PHONE = "+18005551234"
SENDER_PHONE = "+18005551234"
REGION_THRESHOLD = "100"
WINDOW_SECONDS = "3600"
[[secrets]]
binding = "TELNYX_API_KEY"
name = "TELNYX_API_KEY"
Four bindings:
GEO_LOGGER— Actor namespace forGeoLoggerAgent(one actor per call)REGISTRY— Actor namespace forCallRegistry(singleton for cross-call listing)TELNYX— Zero-credential binding for SMS (this.env.TELNYX.messages.send())REGION_KV— KV namespace for per-region rolling-window counters
Step 3: Region Detection from E.164 Prefixes
The detectRegion() function maps E.164 country code prefixes to named regions:
const COUNTRY_TO_REGION: Record<string, string> = {
"1": "us-east-1", // US/Canada (+1)
"44": "eu-west-1", // UK (+44)
"33": "eu-west-1", // France (+33)
"49": "eu-central-1", // Germany (+49)
"31": "eu-west-1", // Netherlands (+31)
"81": "ap-northeast-1", // Japan (+81)
"82": "ap-northeast-1", // South Korea (+82)
"86": "ap-east-1", // China (+86)
"91": "ap-south-1", // India (+91)
"61": "ap-southeast-1", // Australia (+61)
"55": "sa-east-1", // Brazil (+55)
};
export function detectRegion(phoneNumber: string): string {
const digits = phoneNumber.replace(/^\+/, "");
const cc2 = digits.slice(0, 2);
const cc1 = digits.slice(0, 1);
if (COUNTRY_TO_REGION[cc2]) return COUNTRY_TO_REGION[cc2];
if (COUNTRY_TO_REGION[cc1]) return COUNTRY_TO_REGION[cc1];
return "unknown";
}
In production, replace this with a carrier lookup or Number Insight API for precise geo-routing. For a sample, country-code prefixes are enough to demonstrate the pattern.
Step 4: The GeoLoggerAgent Pipeline
The GeoLoggerAgent extends the Agent SDK's Agent class. Each call gets its own actor instance, initialized when call.initiated arrives:
export class GeoLoggerAgent extends Agent<GeoLoggerEnv, GeoLoggerState> {
protected override initialState(): GeoLoggerState {
return {
callControlId: "",
fromNumber: "",
toNumber: "",
direction: "inbound",
region: "unknown",
status: "ringing",
startedAt: 0,
answeredAt: 0,
endedAt: 0,
durationSec: 0,
logged: false,
alertTriggered: false,
regionCount: 0,
threshold: 0,
error: "",
};
}
async onCallStart(params: {
callControlId: string;
fromNumber: string;
toNumber: string;
direction: "inbound" | "outbound";
}): Promise<void> {
const region = detectRegion(params.fromNumber);
const threshold = parseInt(this.env.REGION_THRESHOLD, 10) || 100;
await this.setState({
...await this.getState(),
callControlId: params.callControlId,
fromNumber: params.fromNumber,
toNumber: params.toNumber,
direction: params.direction,
region,
status: "ringing",
startedAt: Date.now(),
threshold,
});
}
}
When call.hangup fires, the agent queues the 3-stage pipeline:
async onHangup(): Promise<void> {
const state = await this.getState();
const endedAt = Date.now();
const durationSec = state.answeredAt
? Math.round((endedAt - state.answeredAt) / 1000)
: 0;
await this.setState({ ...state, status: "hungup", endedAt, durationSec });
await this.queue("logCall");
}
Stage 1: logCall — SQL INSERT + KV INCR
async logCall(): Promise<void> {
const state = await this.getState();
try {
// SQL: insert call record
this.ctx.storage.sql.exec(
`CREATE TABLE IF NOT EXISTS calls (
call_control_id TEXT PRIMARY KEY,
from_number TEXT NOT NULL,
to_number TEXT NOT NULL,
direction TEXT NOT NULL,
region TEXT NOT NULL,
duration_sec INTEGER NOT NULL,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL,
status TEXT NOT NULL
)`
);
this.ctx.storage.sql.exec(
`INSERT OR REPLACE INTO calls
(call_control_id, from_number, to_number, direction, region,
duration_sec, started_at, ended_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
state.callControlId, state.fromNumber, state.toNumber,
state.direction, state.region, state.durationSec,
state.startedAt, state.endedAt, "completed"
);
// KV: increment region counter (rolling window)
const windowSec = parseInt(this.env.WINDOW_SECONDS, 10) || 3600;
const windowStart = Math.floor(Date.now() / 1000 / windowSec) * windowSec;
const kvKey = `region:${state.region}:${windowStart}`;
const currentStr = await this.env.REGION_KV.get(kvKey);
const current = currentStr ? parseInt(currentStr, 10) : 0;
const newCount = current + 1;
await this.env.REGION_KV.put(kvKey, String(newCount), {
expirationTtl: windowSec,
});
await this.setState({ ...state, logged: true, regionCount: newCount, status: "logged" });
await this.queue("checkThreshold");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
await this.setState({ ...state, status: "error", error: `logCall: ${msg}` });
}
}
Two things happen in one stage:
- SQL INSERT — The call record goes into the actor's per-call SQL DB via
ctx.storage.sql.exec()with parameterized queries. - KV INCR — The region counter increments in KV. The key is
region:{region}:{windowStart}wherewindowStartis the epoch seconds of the current window start (e.g.,region:eu-west-1:1718928000). TheexpirationTtlis set toWINDOW_SECONDS, so old windows auto-expire without cleanup code.
Stage 2: checkThreshold
async checkThreshold(): Promise<void> {
const state = await this.getState();
try {
if (state.regionCount >= state.threshold) {
await this.setState({ ...state, status: "alerting", alertTriggered: true });
await this.queue("alert");
} else {
await this.setState({ ...state, status: "done" });
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
await this.setState({ ...state, status: "error", error: `checkThreshold: ${msg}` });
}
}
If the region count meets or exceeds the threshold, the alert stage is queued. Otherwise, the pipeline ends.
Stage 3: alert — Zero-Credential SMS
async alert(): Promise<void> {
const state = await this.getState();
try {
const smsText =
`Geo Call Alert: region "${state.region}" hit ${state.regionCount} calls ` +
`in the current window (threshold: ${state.threshold}). ` +
`Last call: ${state.fromNumber} → ${state.toNumber}, ${state.durationSec}s. ` +
`Check the dashboard.`;
await this.env.TELNYX.messages.send({
from: this.env.SENDER_PHONE,
to: this.env.ALERT_PHONE,
text: smsText,
});
await this.setState({ ...state, status: "done" });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
await this.setState({ ...state, status: "error", error: `alert: ${msg}` });
}
}
The SMS is sent via this.env.TELNYX.messages.send() — the zero-credential [telnyx] binding. No API key in code, no auth header to set. The binding carries the auth from the TELNYX_API_KEY secret declared in telnyx.toml.
Step 5: The Webhook Handler
The src/index.ts file exports the actor classes and a default fetch handler that routes webhooks and HTTP requests:
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
// Call Control webhook handler
if (req.method === "POST" && url.pathname === "/webhooks/voice") {
return handleCallWebhook(req, env);
}
// Get call status
if (req.method === "GET" && url.pathname.startsWith("/status/")) {
const callId = url.pathname.split("/status/")[1];
const state = await env.GEO_LOGGER.idFromName(callId).getStatus();
return Response.json(state);
}
// List recent calls
if (req.method === "GET" && url.pathname === "/calls") {
const records = await env.REGISTRY.idFromName("global").list(50);
return Response.json({ calls: records });
}
// Region statistics
if (req.method === "GET" && url.pathname === "/regions/stats") {
// ... returns per-region counts for the current window
}
// Simulate a call (for testing without a real call)
if (req.method === "POST" && url.pathname === "/simulate") {
// ... simulates a call webhook
}
return new Response("not found", { status: 404 });
},
};
The webhook handler dispatches on event_type:
async function handleCallWebhook(req: Request, env: Env): Promise<Response> {
const event = (await req.json()) as CallControlEvent;
const data = event.data;
const callControlId = data.call_control_id;
const agent = env.GEO_LOGGER.idFromName(actorName(callControlId));
switch (data.event_type) {
case "call.initiated":
await agent.onCallStart({
callControlId,
fromNumber: data.from,
toNumber: data.to,
direction: data.direction,
});
break;
case "call.answered":
await agent.onAnswered();
break;
case "call.hangup":
await agent.onHangup(); // queues logCall → checkThreshold → alert
break;
}
return Response.json({ received: true, eventType: data.event_type });
}
Step 6: Run and Test
Install and configure
cd geo-distributed-call-logger
npm install
cp .env.example .env
# Fill in TELNYX_API_KEY, SENDER_PHONE, ALERT_PHONE
Run locally
npm start
Simulate a call (no real phone needed)
curl -X POST http://localhost:3000/simulate \
-H "Content-Type: application/json" \
-d '{"from":"+31612345678","to":"+18005551234","direction":"inbound","duration":42}'
Check region statistics
curl http://localhost:3000/regions/stats
Response:
{
"threshold": 100,
"windowSeconds": 3600,
"regions": [
{ "region": "us-east-1", "count": 47, "windowStart": 1718928000 },
{ "region": "eu-west-1", "count": 103, "windowStart": 1718928000 }
]
}
Trigger an alert
Set a low threshold in .env:
REGION_THRESHOLD=2
Simulate 3 calls from the same region — the third will trigger an SMS to ALERT_PHONE.
Rolling-Window KV Counters
The KV key scheme is the key insight that makes this work without a cleanup job:
region:eu-west-1:1718928000 ← count for 14:00–15:00 window
region:eu-west-1:1718931600 ← count for 15:00–16:00 window
Each key has a TTL of WINDOW_SECONDS. When the window expires, the key auto-deletes. No cron job, no cleanup Lambda, no stale counters. The windowStart is computed as:
const windowStart = Math.floor(Date.now() / 1000 / windowSec) * windowSec;
This floors the current epoch seconds to the window boundary — so all calls within the same hour (or minute, or day) share the same key.
Why Actors?
Each call is isolated in its own GeoLoggerAgent actor instance. This means:
- No contention between concurrent calls — 100 simultaneous calls get 100 actor instances
- State survives retries — if a webhook is retried, the actor state persists
- Per-call SQL DB — each actor has its own SQL instance, so no database locking
- The
CallRegistrysingleton aggregates across calls for the/callsendpoint
This is the Agent SDK's core value: stateful, durable execution without a database server.
API Endpoints
| Method | Path | Description |
|---|---|---|
POST | /webhooks/voice | Call Control webhook receiver |
GET | /status/:callId | Get agent state for a specific call |
GET | /calls | List recent calls (from registry actor) |
GET | /regions/stats | Per-region call counts in the current window |
GET | /regions | List supported regions and country codes |
POST | /simulate | Simulate a call webhook (for testing) |
GET | /health/liveness | Liveness probe |
GET | /health/readiness | Readiness probe |
Related Examples
- Edge Cache Invalidation Agent — Agent SDK + KV + Cloud Storage + SMS
- Edge Call Transcription Agent — Call Control webhooks + Agent SDK