Webhook Security Guide
Ovrtxt uses webhooks to push real-time events to your server the moment a user's inbound SMS is matched to a session. This guide explains exactly how to receive and — critically — how to cryptographically verify every payload so you never act on spoofed data.
What Are Ovrtxt Webhooks?
When a user completes their SMS authentication, Ovrtxt's inbound pipeline matches the cryptographic token in their message to an active session. The instant that match is confirmed, we fire an HTTP POST to your registered webhookUrl — typically within 500ms of the user tapping Send.
This inbound model is architecturally superior to polling because your server reacts to an event rather than burning API quota on status checks every second.
📡 Delivery Guarantee
Ovrtxt will retry failed webhook deliveries up to 5 times with exponential backoff (5s, 30s, 2m, 10m, 30m). Your endpoint must return a 2xx status within 10 seconds to be considered delivered.
Event Flow
User taps Send in native SMS compose sheet
Ovrtxt extracts and validates the HMAC token
Session moves to approved
webhookUrl —Your server verifies the signature, logs the user in
Payload Anatomy
Every Ovrtxt webhook is an HTTP POST with a JSON body and three security-relevant headers you must inspect before trusting any payload data.
Request Headers
x-ovrtxt-signature
VERIFY THIS
HMAC-SHA256 signature of the raw request body, hex-encoded. Prefixed with sha256=. Compute this locally and compare to reject forgeries.
x-ovrtxt-timestampUnix epoch (seconds) at which Ovrtxt fired the event. Check this to prevent replay attacks — reject requests older than 300 seconds.
x-ovrtxt-eventThe event type string. Currently verification.completed or verification.expired.
JSON Payload
{
"event": "verification.completed",
"timestamp": 1723250981,
"data": {
"id": "sess_01HZXVK29S8T4MFQNPQ7RBK93A",
"status": "approved",
"channel": "sms_fallback",
"phone": "+15550192834",
"brandName": "Joe's Gym",
"latencyMs": 610,
"approvedAt": "2026-08-10T01:29:41.610Z",
"metadata": {
"userId": "usr_8a2f3d"
}
}
}
🛡️ Signature Verification
⚠️ This step is non-negotiable.
Any server that processes Ovrtxt webhook payloads without verifying the HMAC-SHA256 signature is vulnerable to spoofed requests. An attacker could POST a fake payload to your endpoint and log in as any user. Always verify before acting.
How Signature Verification Works
Construct the Signed String
Concatenate the x-ovrtxt-timestamp header value, a literal period (.), and the raw, unmodified request body as bytes.
{timestamp}.{raw_body}
Compute the HMAC
Hash the signed string using HMAC-SHA256 with your Webhook Secret as the key. This is separate from your API key — find it in the Dashboard → Webhooks.
Compare Safely
Compare your computed hex digest against the value from x-ovrtxt-signature (strip the sha256= prefix). Use a timing-safe comparison to prevent timing attacks.
Node.js
Express.js// npm install express const express = require('express'); const crypto = require('crypto'); const app = express(); // CRITICAL: Use raw body middleware — not json() — for signature verification. // Parsing the body first (json()) can alter whitespace and break the hash. app.use('/webhooks/ovrtxt', express.raw({ type: 'application/json' })); const WEBHOOK_SECRET = process.env.OVRTXT_WEBHOOK_SECRET; const TOLERANCE_SECONDS = 300; // Reject payloads older than 5 minutes function verifyOvrtxtSignature(rawBody, headers) { const sigHeader = headers['x-ovrtxt-signature'] ?? ''; const timestamp = headers['x-ovrtxt-timestamp'] ?? ''; // 1. Reject stale payloads to prevent replay attacks const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp, 10)) > TOLERANCE_SECONDS) { throw new Error('Webhook timestamp too old — possible replay attack.'); } // 2. Construct the signed string: "{timestamp}.{raw_body}" const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`; // 3. Compute HMAC-SHA256 using your Webhook Secret const expectedSig = 'sha256=' + crypto .createHmac('sha256', WEBHOOK_SECRET) .update(signedPayload) .digest('hex'); // 4. Timing-safe comparison — prevents timing oracle attacks const receivedBuf = Buffer.from(sigHeader, 'utf8'); const expectedBuf = Buffer.from(expectedSig, 'utf8'); if (receivedBuf.length !== expectedBuf.length) throw new Error('Signature mismatch.'); if (!crypto.timingSafeEqual(receivedBuf, expectedBuf)) { throw new Error('Invalid signature — request rejected.'); } } app.post('/webhooks/ovrtxt', (req, res) => { try { verifyOvrtxtSignature(req.body, req.headers); } catch (err) { console.error('Webhook auth failed:', err.message); return res.status(403).json({ error: err.message }); } // Signature verified — safe to parse and act on the payload const event = JSON.parse(req.body.toString()); switch (event.event) { case 'verification.completed': const { data } = event; console.log(`✓ User ${data.phone} authenticated in ${data.latencyMs}ms`); // → Log the user in, set your session cookie, emit WS event, etc. break; case 'verification.expired': console.warn(`Session ${event.data.id} expired.`); // → Notify the waiting browser UI to refresh break; } // Must respond 2xx within 10 seconds or Ovrtxt will retry res.status(200).json({ received: true }); }); app.listen(3000);
Python
FastAPI# pip install fastapi uvicorn python-dotenv import hashlib, hmac, os, time from fastapi import FastAPI, Request, HTTPException from dotenv import load_dotenv load_dotenv() app = FastAPI() WEBHOOK_SECRET: str = os.environ["OVRTXT_WEBHOOK_SECRET"] TOLERANCE_SECONDS: int = 300 # Reject payloads older than 5 minutes def verify_ovrtxt_signature(raw_body: bytes, headers: dict) -> None: sig_header: str = headers.get("x-ovrtxt-signature", "") timestamp: str = headers.get("x-ovrtxt-timestamp", "") # 1. Reject stale payloads age = abs(int(time.time()) - int(timestamp)) if age > TOLERANCE_SECONDS: raise HTTPException( status_code=403, detail=f"Timestamp too old ({age}s). Possible replay attack." ) # 2. Build the signed string: "{timestamp}.{raw_body}" signed_payload: bytes = f"{timestamp}.".encode() + raw_body # 3. Compute HMAC-SHA256 expected_sig: str = "sha256=" + hmac.new( WEBHOOK_SECRET.encode("utf-8"), signed_payload, hashlib.sha256, ).hexdigest() # 4. Constant-time comparison — prevents timing attacks if not hmac.compare_digest(sig_header, expected_sig): raise HTTPException( status_code=403, detail="Invalid signature. Request rejected." ) @app.post("/webhooks/ovrtxt") async def handle_ovrtxt_webhook(request: Request): # CRITICAL: Read raw bytes before any parsing raw_body: bytes = await request.body() verify_ovrtxt_signature(raw_body, dict(request.headers)) # Signature verified — safe to act on payload import json event = json.loads(raw_body) if event["event"] == "verification.completed": data = event["data"] print( f"✓ {data['phone']} authenticated" f" in {data['latencyMs']}ms via {data['channel']}" ) # → Log user in, create session, push WS event, etc. # Must respond 2xx within 10 seconds return {"received": True}
Replay Attack Prevention
A valid signature alone doesn't protect you if an attacker captures a legitimate payload and re-sends it later. Timestamp validation closes this window.
❌ Vulnerable (no timestamp check)
Attacker intercepts a valid webhook, stores it, and replays it 10 minutes later. Your server verifies the signature, sees verification.completed, and logs the attacker in as the original user.
✓ Protected (with timestamp check)
Your server checks that |now - x-ovrtxt-timestamp| ≤ 300s. The replayed payload is 10 minutes old — your handler rejects it with a 403 before doing anything.
IP Allowlist (Defense in Depth)
For maximum security, restrict your webhook endpoint to only accept traffic from Ovrtxt's infrastructure IPs. This is an additional layer — not a replacement for signature verification.
IPs are subject to change. Subscribe to our infrastructure changelog for advance notice of any rotation.
Webhook Event Types
verification.completed
BILLABLE
Fired when an inbound SMS cryptographic token is matched to an active session and the session status transitions to approved. This is the primary event your login flow should listen to.
verification.expired
Fired when a session's TTL elapses without a matching inbound SMS. Use this to dismiss your waiting UI and prompt the user to restart.
verification.denied
Fired when the user taps the "Deny" action button on an RCS rich card. No inbound SMS was sent. Treat as a failed authentication.
Retries & Idempotency
Ovrtxt will retry failed deliveries (non-2xx or timeouts) on this schedule:
| Attempt | Delay |
|---|---|
| 1st retry | 5 seconds |
| 2nd retry | 30 seconds |
| 3rd retry | 2 minutes |
| 4th retry | 10 minutes |
| 5th retry (final) | 30 minutes |
🔁 Design for Idempotency
Because retries can deliver the same event multiple times, always deduplicate on the session id field. A simple Redis set or database unique constraint is sufficient.
Testing Webhooks Locally
Use our CLI or a tunneling tool to forward Ovrtxt webhook events to your localhost during development.
# Install the CLI npm install -g @ovrtxt/cli # Authenticate ovrtxt login # Forward events to your local server ovrtxt webhooks forward --to http://localhost:3000/webhooks/ovrtxt # Or use ngrok as an alternative ngrok http 3000 # Then set webhookUrl to your ngrok HTTPS URL