Voice AI

How to Build a Geo-Distributed Call Logger with Per-Region KV Counters and SMS Alerting 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 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:

  1. 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.
  2. checkThreshold() — Compares the post-increment region count against REGION_THRESHOLD. If exceeded, queues the alert stage.
  3. alert() — Sends an SMS via the zero-credential [telnyx] binding — no API key needed in code.

Prerequisites

- 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 for GeoLoggerAgent (one actor per call)
  • REGISTRY — Actor namespace for CallRegistry (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} where windowStart is the epoch seconds of the current window start (e.g., region:eu-west-1:1718928000). The expirationTtl is set to WINDOW_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 CallRegistry singleton aggregates across calls for the /calls endpoint

This is the Agent SDK's core value: stateful, durable execution without a database server.

API Endpoints

MethodPathDescription
POST/webhooks/voiceCall Control webhook receiver
GET/status/:callIdGet agent state for a specific call
GET/callsList recent calls (from registry actor)
GET/regions/statsPer-region call counts in the current window
GET/regionsList supported regions and country codes
POST/simulateSimulate a call webhook (for testing)
GET/health/livenessLiveness probe
GET/health/readinessReadiness probe

Related Examples

Frequently Asked Questions

How does the region detection work?

The sample maps E.164 country code prefixes (the 1-2 digits after the +) to named regions like us-east-1, eu-west-1, ap-northeast-1. 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 for that window auto-deletes via TTL. No cleanup code needed. 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 in the Telnyx Portal or via telnyx-edge secret set, and the next call will pick up the new value.

How many calls can this handle?

Each call gets its own actor instance, so there's no contention between concurrent calls. The KV increment is a single put() per call. The bottleneck is the SMS send rate — if you're alerting on every call in a high-volume region, you'll want to add rate limiting to the alert() stage.

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.

Ready to build with low-latency voice AI?

Join developers building the future of real-time conversations