DEV Community

Harpreet Singh Seehra
Harpreet Singh Seehra

Posted on

Close the Migration-Visibility Gap: SMS Alerts for Failed SQL Migrations in 280 Lines of Python

The first time a database migration broke on me in production, I found out from a user.

Not from a dashboard, not from a Slack alert, not from anything I built. A user filed a ticket saying the orders page was throwing a 500. I SSH'd in, tailed the logs, found the migration that ran at 02:00, and saw it had failed two hours earlier. The schema version had not bumped. The deploy had "succeeded" because the migration runner swallowed the exception. Nobody looked.

That hour — the gap between the migration failing and someone knowing — is the whole problem. Not the migration itself. Migrations break. That's a fact of life. The bug is that nobody finds out fast enough to roll it back before users see it.

So I built the thing I wished I'd had that night. It is a 280-line Python + Flask service that runs schema migrations, checks the result, and text-messages the on-call the instant a step fails — over Telnyx SMS, with Ed25519-signed webhook delivery receipts so you know the alert actually reached the device.

The code is open source and cloneable: https://github.com/team-telnyx/telnyx-code-examples/tree/main/sql-migration-agent

What the Agent Does

The agent models a migration as a single in-code-path operation. It reads the current schema version from a SQL database. It fetches the migration script. It executes the steps in order. If every step succeeds, it bumps the schema version. If any step fails, it does not bump the version, it rolls back, and it sends an SMS to the on-call number the moment the migration breaks.

The key SDK calls in the Telnyx Python SDK v4:

import telnyx

client = telnyx.Telnyx(
    api_key=os.getenv("TELNYX_API_KEY"),
    public_key=os.getenv("TELNYX_PUBLIC_KEY"),
)

# Fire the failure alert the moment a step breaks
client.messages.send(
    from_=os.getenv("TELNYX_FROM_NUMBER"),
    to=notify_phone,
    text=f"MIGRATION FAILED: {migration_id}{error}",
)

# Verify the signed delivery receipt that comes back
raw_body = request.get_data(as_text=True)
event = client.webhooks.unwrap(payload=raw_body, headers=request.headers)
Enter fullscreen mode Exit fullscreen mode

The webhooks.unwrap call does Ed25519 signature verification against the public key set on the client at construction. If the signature does not verify, the request is rejected with a 400 — no delivery is recorded, no status is updated.

Why the Webhook Roundtrip Matters

Most alerting systems I've worked with treat "I sent the alert" as the end of the story. This one does not. Telnyx sends a signed webhook back when an SMS is delivered, and the app verifies the signature before recording the delivery. If verification fails, the request is rejected — the app does not record a delivery it cannot prove happened.

The difference between "I sent the alert" and "the on-call saw the alert" is the difference between an alerting system and an alert-hoping system. In production, the device could be offline, the carrier could drop the message, the SIM could be swapped. The signed webhook roundtrip is what closes that last loop.

Why Ed25519 and Not HMAC

Most webhook signature verification in the wild is HMAC-SHA256. You share a secret with the sender, the sender signs the payload, you verify with the same secret. It works, but it creates a key-distribution problem in any multi-service setup. Every service that verifies needs the secret. Rotating the secret means coordinating across all of them.

Ed25519 is asymmetric. The verifier only needs the public key. The sender keeps the private key. You can pin the public key in a config map, share it across services, put it in a README — it does not matter who sees it, because seeing the public key does not let you forge signatures.

For an alerting system that talks to a webhook endpoint you control, this matters less. For an alerting system that other services also verify — say, a multi-tenant setup where each tenant has their own webhook — it matters a lot.

Run It Locally — No Telnyx Account Needed

The demo launcher (demo/demo_server.py) is a single file that stands up a SQLite store, stubs the SMS client, generates an Ed25519 keypair on first run, and serves a dashboard at http://localhost:5555. The whole loop runs in-process — no network calls, no credentials.

git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/sql-migration-agent
cp .env.example .env
pip install -r requirements.txt
python demo/demo_server.py
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:5555. The dashboard boots with zero migrations and a baseline schema version. Click "Run 001: Add users table" for the happy path — the migration succeeds, the schema version bumps to 001, no SMS fires because nothing broke. Then click "Run 003: Add orders (FAIL)" — the migration has a deliberate syntax error, the schema version does not advance, and a new entry appears in the SMS log: the on-call just got a text reading MIGRATION FAILED: 003 — syntax error near column.

To verify the webhook roundtrip, click "Send webhook (delivery receipt)". The demo server signs a payload with the Ed25519 key it generated on startup, POSTs it to its own /webhooks endpoint, and the app verifies the signature before recording the delivery. The SMS log row updates to delivered with a green check. That is the full loop — migration runs, fails, SMS fires, signed webhook comes back, app verifies, status lands in the log. Every step that would happen in production, happening on your laptop.

To wire real SMS, drop your Telnyx API key, public key, messaging profile number, and a destination phone number into .env. The stubbed client is replaced automatically.

Where This Fits

Three places this pattern earns its keep:

  • Scheduled production migrations — Run them on a cron, get a text the second any step fails. No polling, no dashboards to babysit. The on-call either hears "all good" or hears "migration 003 broke" — never silence.
  • CI/CD gates — Wire this into your deploy pipeline so a failing migration blocks the release and pages the on-call before bad code reaches prod. The migration either succeeds and the deploy proceeds, or it fails and the on-call is notified before any user is.
  • Multi-tenant SaaS — Run a migration per customer database and get per-tenant failure alerts, so you know exactly which tenant's schema broke and can roll them back individually without affecting the others.

What I Left Out (and Why)

The sample ships a real Ed25519 roundtrip and a real SMS send path (when wired with credentials), but it deliberately stubs a few things to keep the demo runnable on a laptop:

  • SMS send is stubbed in the demo. The demo launcher replaces telnyx_client.messages with an in-memory recorder. The signed webhook roundtrip is real — the demo generates its own keypair and signs the delivery receipt — but the SMS itself is captured to a local log, not sent over the air.
  • Migration scripts come from an in-memory map. In production, you would fetch them from a shared store. The Telnyx CloudFS API is the natural fit — same API key, same platform.
  • Schema versioning is in-memory. The demo uses a Python dict for schema versions. In production, use a real SQL DB — the schema_versions table is the source of truth.

These stubs are intentional. They keep the demo under 280 lines, runnable with no credentials, and focused on the part that matters: the failure-to-notification loop.

What I'd Add Next

Two things I'd add for production:

  1. Per-tenant routing. Right now the on-call number is a single env var. In a multi-tenant setup, the alert should go to the team that owns the tenant. A tenant_to_oncall map, looked up before the SMS send, takes about 15 lines.
  2. Retry with backoff. If the SMS send itself fails, the agent should retry with exponential backoff and then escalate to a fallback channel. Maybe 30 lines.

Neither is in the sample, because neither is the point. The point is closing the visibility gap. The hour between "migration failed" and "someone knew" is the bug. This 280-line agent fixes it.


Top comments (0)