Skip to content

Latest commit

 

History

History
267 lines (193 loc) · 7.59 KB

File metadata and controls

267 lines (193 loc) · 7.59 KB

Webhook Signature Verification

Every webhook delivery from Texting Blue includes a cryptographic signature so you can verify the request is authentic and has not been tampered with. You should always verify webhook signatures before processing the payload.

How It Works

  1. When you create a webhook, Texting Blue generates a unique secret (e.g., whsec_a1b2c3d4...).
  2. For each delivery, Texting Blue computes an HMAC-SHA256 hash of the raw request body using your webhook secret as the key.
  3. The signature is sent in the x-textingblue-signature header in the format: sha256={hex_digest}.
  4. Your server recomputes the same HMAC and compares it to the header value.

Signature Header

x-textingblue-signature: sha256=a1b2c3d4e5f6...

The value after sha256= is the hex-encoded HMAC-SHA256 digest of the raw request body.


Verification Examples

Node.js

import crypto from "node:crypto";

function verifyWebhookSignature(rawBody, signature, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  const expectedBuffer = Buffer.from(expected, "hex");
  const receivedHex = signature.replace("sha256=", "");
  const receivedBuffer = Buffer.from(receivedHex, "hex");

  if (expectedBuffer.length !== receivedBuffer.length) {
    return false;
  }

  return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}

Python

import hashlib
import hmac


def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()

    received = signature.removeprefix("sha256=")

    return hmac.compare_digest(expected, received)

PHP

function verifyWebhookSignature(string $rawBody, string $signature, string $secret): bool
{
    $expected = hash_hmac('sha256', $rawBody, $secret);
    $received = str_replace('sha256=', '', $signature);

    return hash_equals($expected, $received);
}

Framework Examples

Express.js

Use express.raw() to get the raw request body before any JSON parsing.

import express from "express";
import crypto from "node:crypto";

const app = express();

const WEBHOOK_SECRET = process.env.TEXTINGBLUE_WEBHOOK_SECRET;

// Use raw body for the webhook route
app.post(
  "/webhooks/texting-blue",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-textingblue-signature"];
    if (!signature) {
      return res.status(401).json({ error: "Missing signature" });
    }

    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(req.body)
      .digest("hex");

    const expectedBuffer = Buffer.from(expected, "hex");
    const receivedHex = signature.replace("sha256=", "");
    const receivedBuffer = Buffer.from(receivedHex, "hex");

    if (
      expectedBuffer.length !== receivedBuffer.length ||
      !crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
    ) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(req.body);
    console.log(`Received event: ${event.type}`);

    // Process the event
    switch (event.type) {
      case "message.received":
        console.log(`New message from ${event.data.from}: ${event.data.content}`);
        break;
      case "message.delivered":
        console.log(`Message ${event.data.id} delivered`);
        break;
      case "message.failed":
        console.log(`Message ${event.data.id} failed`);
        break;
    }

    res.status(200).json({ received: true });
  }
);

app.listen(3000);

Flask (Python)

import hashlib
import hmac
import json
import os

from flask import Flask, abort, request

app = Flask(__name__)

WEBHOOK_SECRET = os.environ["TEXTINGBLUE_WEBHOOK_SECRET"]


@app.route("/webhooks/texting-blue", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("x-textingblue-signature")
    if not signature:
        abort(401, "Missing signature")

    raw_body = request.get_data()

    expected = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()

    received = signature.removeprefix("sha256=")

    if not hmac.compare_digest(expected, received):
        abort(401, "Invalid signature")

    event = json.loads(raw_body)
    print(f"Received event: {event['type']}")

    if event["type"] == "message.received":
        print(f"New message from {event['data']['from']}: {event['data']['content']}")
    elif event["type"] == "message.delivered":
        print(f"Message {event['data']['id']} delivered")
    elif event["type"] == "message.failed":
        print(f"Message {event['data']['id']} failed")

    return {"received": True}, 200

Laravel (PHP)

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
    public function handle(Request $request)
    {
        $signature = $request->header('x-textingblue-signature');
        if (!$signature) {
            return response()->json(['error' => 'Missing signature'], 401);
        }

        $rawBody = $request->getContent();
        $secret = config('services.textingblue.webhook_secret');

        $expected = hash_hmac('sha256', $rawBody, $secret);
        $received = str_replace('sha256=', '', $signature);

        if (!hash_equals($expected, $received)) {
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        $event = json_decode($rawBody, true);
        Log::info("Received webhook event: {$event['type']}");

        switch ($event['type']) {
            case 'message.received':
                Log::info("New message from {$event['data']['from']}: {$event['data']['content']}");
                break;
            case 'message.delivered':
                Log::info("Message {$event['data']['id']} delivered");
                break;
            case 'message.failed':
                Log::info("Message {$event['data']['id']} failed");
                break;
        }

        return response()->json(['received' => true]);
    }
}

Register the route in routes/api.php and exclude it from CSRF verification:

// routes/api.php
Route::post('/webhooks/texting-blue', [WebhookController::class, 'handle']);
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    'api/webhooks/texting-blue',
];

Best Practices

  • Always verify signatures. Never process a webhook payload without verifying the signature first. Without verification, an attacker could send forged payloads to your endpoint.

  • Use the raw request body. Compute the HMAC against the raw bytes of the request body, not a re-serialized JSON object. Parsing and re-serializing JSON can change whitespace or key ordering, which will cause the signature check to fail.

  • Use timing-safe comparison. Always use constant-time string comparison functions (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, hash_equals in PHP) to prevent timing attacks.

  • Return a 2xx response quickly. Process webhook payloads asynchronously if your handler takes more than a few seconds. Texting Blue will retry on non-2xx responses, which may result in duplicate deliveries.

  • Handle duplicate deliveries. Use the id field in the webhook payload to deduplicate events. Store processed event IDs and skip any you have already seen.

  • Keep your secret safe. Store the webhook secret in environment variables or a secrets manager. Never commit it to source control or log it.