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.
- When you create a webhook, Texting Blue generates a unique secret (e.g.,
whsec_a1b2c3d4...). - For each delivery, Texting Blue computes an HMAC-SHA256 hash of the raw request body using your webhook secret as the key.
- The signature is sent in the
x-textingblue-signatureheader in the format:sha256={hex_digest}. - Your server recomputes the same HMAC and compares it to the header value.
x-textingblue-signature: sha256=a1b2c3d4e5f6...
The value after sha256= is the hex-encoded HMAC-SHA256 digest of the raw request body.
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);
}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)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);
}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);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<?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',
];-
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.timingSafeEqualin Node.js,hmac.compare_digestin Python,hash_equalsin 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
idfield 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.