🔒 Security-Critical Documentation

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

1

User taps Send in native SMS compose sheet

— inbound MO-SMS arrives at Ovrtxt's carrier endpoint —
2

Ovrtxt extracts and validates the HMAC token

— token matched to active session in session store —
3

Session moves to approved

— signed webhook fired to your webhookUrl
4

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-timestamp

Unix epoch (seconds) at which Ovrtxt fired the event. Check this to prevent replay attacks — reject requests older than 300 seconds.

x-ovrtxt-event

The event type string. Currently verification.completed or verification.expired.

JSON Payload

verification.completed 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

1

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}
2

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.

3

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
webhook-handler.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
webhook_handler.py
# 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.

# Ovrtxt Webhook Egress IPs 34.82.110.201 34.82.220.154 35.247.120.88

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:

AttemptDelay
1st retry5 seconds
2nd retry30 seconds
3rd retry2 minutes
4th retry10 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.

// Idempotency check (Node.js / Redis) const key = `webhook:${event.data.id}`; const isDupe = await redis.set(key, 1, 'EX', 86400, 'NX'); if (!isDupe) return res.json({ status: 'duplicate' });

Testing Webhooks Locally

Use our CLI or a tunneling tool to forward Ovrtxt webhook events to your localhost during development.

Forward events to localhost using the Ovrtxt CLI
# 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