Webhook Signatures
Every webhook request includes a signature in theX-Sturdy-Signature header. This signature allows you to verify that the webhook was sent by Sturdy Technologies and hasn’t been tampered with.
How Signatures Work
We generate signatures using HMAC-SHA256 with your webhook secret key. The signature is calculated directly from the raw request body.Verification Steps
1. Extract the Signature
Get the signature from the request header:const signature = request.headers['x-sturdy-signature'];
2. Calculate Expected Signature
const crypto = require('crypto');
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(requestBody)
.digest('hex');
3. Compare Signatures
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
if (!isValid) {
throw new Error('Invalid signature');
}
Complete Example (Node.js)
const express = require('express');
const crypto = require('crypto');
const app = express();
// Raw body needed for signature verification
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-sturdy-signature'];
const webhookSecret = process.env.WEBHOOK_SECRET;
// Verify signature
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(req.body)
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Process webhook
const event = JSON.parse(req.body);
console.log('Received event:', event.event_type);
res.status(200).send('OK');
});
Preventing Replay Attacks
Use theevent_id fields in the webhook payload to prevent replay attacks:
const event = JSON.parse(req.body);
// Check if event was already processed
if (await isEventProcessed(event.event_id)) {
return res.status(200).send('Already processed');
}
// Mark event as processed
await markEventAsProcessed(event.event_id);
IP Allowlisting
For additional security, you can allowlist our webhook IP addresses: 203.0.113.10 203.0.113.11 203.0.113.12Contact support for the current list of webhook IP addresses for production.
Additional Examples
import hmac
import hashlib
def verify_webhook(request, webhook_secret):
signature = request.headers.get('X-Sturdy-Signature')
# Calculate expected signature
expected_signature = hmac.new(
webhook_secret.encode('utf-8'),
request.body,
hashlib.sha256
).hexdigest()
# Compare signatures
if not hmac.compare_digest(signature, expected_signature):
raise ValueError('Invalid signature')
return True
<?php
function verifyWebhook($signature, $payload, $webhookSecret) {
$expectedSignature = hash_hmac('sha256', $payload, $webhookSecret);
if (!hash_equals($expectedSignature, $signature)) {
throw new Exception('Invalid signature');
}
return true;
}
// Usage
$signature = $_SERVER['HTTP_X_STURDY_SIGNATURE'];
$payload = file_get_contents('php://input');
$webhookSecret = getenv('WEBHOOK_SECRET');
verifyWebhook($signature, $payload, $webhookSecret);
?>
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
)
func verifyWebhook(signature string, payload []byte, webhookSecret string) error {
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(payload)
expectedSignature := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
return errors.New("invalid signature")
}
return nil
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Arrays;
public class WebhookVerifier {
public static boolean verifyWebhook(String signature, String payload, String webhookSecret)
throws Exception {
Mac sha256Hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
webhookSecret.getBytes("UTF-8"),
"HmacSHA256"
);
sha256Hmac.init(secretKey);
byte[] hash = sha256Hmac.doFinal(payload.getBytes("UTF-8"));
String expectedSignature = bytesToHex(hash);
return MessageDigest.isEqual(
signature.getBytes(),
expectedSignature.getBytes()
);
}
private static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
}
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
private final String webhookSecret = System.getenv("WEBHOOK_SECRET");
@PostMapping
public ResponseEntity<String> handleWebhook(
@RequestHeader("X-Sturdy-Signature") String signature,
@RequestBody String payload
) {
try {
if (!verifySignature(signature, payload)) {
return ResponseEntity.status(401).body("Invalid signature");
}
// Process webhook
ObjectMapper mapper = new ObjectMapper();
WebhookEvent event = mapper.readValue(payload, WebhookEvent.class);
return ResponseEntity.ok("OK");
} catch (Exception e) {
return ResponseEntity.status(500).body("Error processing webhook");
}
}
private boolean verifySignature(String signature, String payload) throws Exception {
Mac sha256Hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
webhookSecret.getBytes("UTF-8"),
"HmacSHA256"
);
sha256Hmac.init(secretKey);
byte[] hash = sha256Hmac.doFinal(payload.getBytes("UTF-8"));
String expectedSignature = bytesToHex(hash);
return MessageDigest.isEqual(
signature.getBytes(),
expectedSignature.getBytes()
);
}
private String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
}
using System;
using System.Security.Cryptography;
using System.Text;
public class WebhookVerifier
{
public static bool VerifyWebhook(string signature, string payload, string webhookSecret)
{
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret)))
{
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var expectedSignature = BitConverter.ToString(hash)
.Replace("-", "")
.ToLower();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature),
Encoding.UTF8.GetBytes(expectedSignature)
);
}
}
}
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using System.IO;
[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
private readonly string _webhookSecret;
public WebhookController(IConfiguration configuration)
{
_webhookSecret = configuration["WEBHOOK_SECRET"];
}
[HttpPost]
public async Task<IActionResult> HandleWebhook()
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
var payload = await reader.ReadToEndAsync();
Request.Body.Position = 0;
var signature = Request.Headers["X-Sturdy-Signature"].ToString();
if (!VerifySignature(signature, payload))
{
return Unauthorized("Invalid signature");
}
// Process webhook
var webhookEvent = JsonSerializer.Deserialize<WebhookEvent>(payload);
return Ok("OK");
}
private bool VerifySignature(string signature, string payload)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_webhookSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var expectedSignature = BitConverter.ToString(hash)
.Replace("-", "")
.ToLower();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature),
Encoding.UTF8.GetBytes(expectedSignature)
);
}
}
use hmac::{Hmac, Mac};
use sha2::Sha256;
use hex;
type HmacSha256 = Hmac<Sha256>;
pub fn verify_webhook(
signature: &str,
payload: &[u8],
webhook_secret: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut mac = HmacSha256::new_from_slice(webhook_secret.as_bytes())?;
mac.update(payload);
let result = mac.finalize();
let expected_signature = hex::encode(result.into_bytes());
// Constant-time comparison
Ok(signature == expected_signature)
}
// Usage with Actix Web
use actix_web::{post, web, HttpRequest, HttpResponse, Result};
#[post("/webhooks")]
async fn handle_webhook(
req: HttpRequest,
body: web::Bytes,
) -> Result<HttpResponse> {
let signature = req
.headers()
.get("X-Sturdy-Signature")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| actix_web::error::ErrorBadRequest("Missing signature"))?;
let webhook_secret = std::env::var("WEBHOOK_SECRET")
.expect("WEBHOOK_SECRET must be set");
if !verify_webhook(signature, &body, &webhook_secret)
.map_err(actix_web::error::ErrorInternalServerError)?
{
return Ok(HttpResponse::Unauthorized().body("Invalid signature"));
}
// Process webhook
Ok(HttpResponse::Ok().body("OK"))
}
import crypto from 'crypto';
import express, { Request, Response } from 'express';
function verifyWebhook(
signature: string,
payload: string | Buffer,
webhookSecret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express middleware
const app = express();
app.post(
'/webhooks',
express.raw({ type: 'application/json' }),
(req: Request, res: Response) => {
const signature = req.headers['x-sturdy-signature'] as string;
const webhookSecret = process.env.WEBHOOK_SECRET!;
if (!verifyWebhook(signature, req.body, webhookSecret)) {
return res.status(401).send('Invalid signature');
}
// Process webhook
const event = JSON.parse(req.body.toString());
console.log('Received event:', event.event_type);
res.status(200).send('OK');
}
);
require 'openssl'
require 'sinatra'
def verify_webhook(signature, payload, webhook_secret)
expected_signature = OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'),
webhook_secret,
payload
)
Rack::Utils.secure_compare(signature, expected_signature)
end
# Sinatra endpoint
post '/webhooks' do
request.body.rewind
payload = request.body.read
signature = request.env['HTTP_X_STURDY_SIGNATURE']
webhook_secret = ENV['WEBHOOK_SECRET']
unless verify_webhook(signature, payload, webhook_secret)
halt 401, 'Invalid signature'
end
# Process webhook
event = JSON.parse(payload)
puts "Received event: #{event['event_type']}"
status 200
body 'OK'
end
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.security.MessageDigest
object WebhookVerifier {
fun verifyWebhook(signature: String, payload: String, webhookSecret: String): Boolean {
val sha256Hmac = Mac.getInstance("HmacSHA256")
val secretKey = SecretKeySpec(webhookSecret.toByteArray(Charsets.UTF_8), "HmacSHA256")
sha256Hmac.init(secretKey)
val hash = sha256Hmac.doFinal(payload.toByteArray(Charsets.UTF_8))
val expectedSignature = hash.joinToString("") { "%02x".format(it) }
return MessageDigest.isEqual(
signature.toByteArray(),
expectedSignature.toByteArray()
)
}
}
// Spring Boot usage
@RestController
@RequestMapping("/webhooks")
class WebhookController {
@Value("\${webhook.secret}")
private lateinit var webhookSecret: String
@PostMapping
fun handleWebhook(
@RequestHeader("X-Sturdy-Signature") signature: String,
@RequestBody payload: String
): ResponseEntity<String> {
if (!WebhookVerifier.verifyWebhook(signature, payload, webhookSecret)) {
return ResponseEntity.status(401).body("Invalid signature")
}
// Process webhook
val event = ObjectMapper().readValue(payload, WebhookEvent::class.java)
return ResponseEntity.ok("OK")
}
}
import Foundation
import CryptoKit
func verifyWebhook(signature: String, payload: Data, webhookSecret: String) -> Bool {
guard let secretData = webhookSecret.data(using: .utf8) else {
return false
}
let key = SymmetricKey(data: secretData)
let hmac = HMAC<SHA256>.authenticationCode(for: payload, using: key)
let expectedSignature = Data(hmac).map { String(format: "%02x", $0) }.joined()
return signature == expectedSignature
}
// Vapor usage
func routes(_ app: Application) throws {
app.post("webhooks") { req -> HTTPStatus in
guard let signature = req.headers.first(name: "X-Sturdy-Signature"),
let webhookSecret = Environment.get("WEBHOOK_SECRET"),
let body = req.body.data else {
throw Abort(.badRequest)
}
guard verifyWebhook(signature: signature, payload: body, webhookSecret: webhookSecret) else {
throw Abort(.unauthorized, reason: "Invalid signature")
}
// Process webhook
let event = try req.content.decode(WebhookEvent.self)
return .ok
}
}
defmodule WebhookVerifier do
def verify_webhook(signature, payload, webhook_secret) do
expected_signature =
:crypto.mac(:hmac, :sha256, webhook_secret, payload)
|> Base.encode16(case: :lower)
Plug.Crypto.secure_compare(signature, expected_signature)
end
end
# Phoenix Controller
defmodule MyAppWeb.WebhookController do
use MyAppWeb, :controller
def create(conn, _params) do
signature = get_req_header(conn, "x-sturdy-signature") |> List.first()
webhook_secret = System.get_env("WEBHOOK_SECRET")
{:ok, body, _conn} = read_body(conn)
if WebhookVerifier.verify_webhook(signature, body, webhook_secret) do
# Process webhook
event = Jason.decode!(body)
send_resp(conn, 200, "OK")
else
send_resp(conn, 401, "Invalid signature")
end
end
end
Security Checklist
1
Verify Signatures
Always verify the
X-Sturdy-Signature header on every request2
Use HTTPS
Only accept webhooks on HTTPS endpoints
3
Keep Secrets Safe
Store your webhook secret securely (environment variables, secret manager)
4
Implement Idempotency
Track processed
event_id values to prevent duplicate processing