Build a Geo-Distributed Call Logger on Edge Compute
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 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.
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. On Edge Compute with the Agent SDK, this collapses to one deploy.
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 -
checkThreshold()— Compares the post-increment region count againstREGION_THRESHOLD -
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 Call Control number, an API key, and a messaging-enabled number
Step 1: 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: two actor namespaces (GEO_LOGGER, REGISTRY), the zero-credential messaging binding (TELNYX), and the KV namespace (REGION_KV).
Step 2: Region Detection from E.164 Prefixes
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";
}
Country-code prefix to named region. 10 lines of code. For production, swap in a carrier lookup or Number Insight API for city-level precision.
Step 3: The GeoLoggerAgent Pipeline
The GeoLoggerAgent extends the Agent SDK's Agent class. Each call gets its own actor instance:
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: "",
};
}
}
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 in one stage: SQL INSERT via ctx.storage.sql.exec() and KV INCR with rolling-window TTL.
Stage 2: checkThreshold
async checkThreshold(): Promise<void> {
const state = await this.getState();
if (state.regionCount >= state.threshold) {
await this.setState({ ...state, status: "alerting", alertTriggered: true });
await this.queue("alert");
} else {
await this.setState({ ...state, status: "done" });
}
}
Stage 3: alert — Zero-Credential SMS
async alert(): Promise<void> {
const state = await this.getState();
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" });
}
The SMS is sent via this.env.TELNYX.messages.send() — the zero-credential [telnyx] binding. No API key in code, no auth header. The binding carries auth from the TELNYX_API_KEY secret.
Step 4: The Webhook Handler
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 5: 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","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 REGION_THRESHOLD=2, simulate 3 calls from the same region, and the third triggers an SMS to your phone.
Rolling-Window KV Counters
The KV key scheme is the key insight:
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 expirationTtl: WINDOW_SECONDS. When the window expires, the key auto-deletes. No cron job, no cleanup Lambda, no stale counters. The windowStart is Math.floor(Date.now() / 1000 / windowSec) * windowSec — floor to the window boundary, so all calls in the same hour share the same key.
Why Actors?
Each call is isolated in its own GeoLoggerAgent actor instance:
- No contention — 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, 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) |
Frequently Asked Questions
How does region detection work?
The sample maps E.164 country code prefixes to named regions. For production-grade geo-routing, replace detectRegion() with a carrier lookup or Number Insight API.
What happens when the rolling window expires?
The KV key auto-deletes via TTL. No cleanup code. The next call in the new window starts a fresh counter at 1.
Can I change the threshold at runtime?
Yes — REGION_THRESHOLD is an environment variable. Update it via telnyx-edge secret set and the next call picks up the new value.
Do I need a database server?
No. The SQL DB is built into the Edge Compute actor runtime — this.ctx.storage.sql.exec(). No connection string, no pool, no server to manage.
Resources
- Code sample on GitHub
- Telnyx Edge Compute docs
- Agent SDK docs
- KV docs
- Call Control docs
- Messaging docs
- Telnyx Portal
If you found this helpful, follow me for more Edge Compute and Voice API content. The full code is on GitHub — clone it, run it, break it. The /simulate endpoint means you don't even need a real call to see it work.
Top comments (0)