<?phpdefine('WEBHOOK_SECRET', 'my_webhook_secret');define('WEBHOOK_TIMESTAMP_FRESHNESS_MINUTES', 5);function validate_event($data, $sig, $timestamp){ # Calculate Signature $calc_sig = base64_encode(hash_hmac('sha256', $timestamp . '.' . $data, WEBHOOK_SECRET, true)); # Compare calculated and given signatures $sigValid = hash_equals($sig, $calc_sig); # Compare timestamp with now $diff = abs(floor((strtotime($timestamp) - time()) / 60)); return $sigValid && $diff < WEBHOOK_TIMESTAMP_FRESHNESS_MINUTES;}# Extract the server signature from HTTP header$sig = $_SERVER['Lucca-Signature'] ?? null;# Extract the server timestamp from HTTP header$timestamp = $_SERVER['Lucca-Timestamp'] ?? null;# Extract the raw body$data = file_get_contents('php://input');# Validate$valid = validate_event($data, $sig, $timestamp);error_log('Webhook validation: '.var_export($valid, true));if ($valid) { # Check for duplicates (you may have already received this event) # Do something with the event http_response_code(202);} else { http_response_code(401);}?>
using Microsoft.AspNetCore.Mvc;using Microsoft.Extensions.Logging;using System.IO;using System.Threading.Tasks;using System.Security.Cryptography;using System.Text;using System;namespace YourNamespace.Controllers { [ApiController] [Route("webhooks/[controller]")] public class YourController : ControllerBase { private readonly ILogger<YourController> _logger; private static readonly string WebhookSecret = "your_webhook_secret"; private static readonly int MaxFreshnessInMinutes = 5; private bool ValidateEvent(string data, string signature, string timestamp) { using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(YourController.WebhookSecret)); var calcSig = Convert.ToBase64String(hasher.ComputeHash(Encoding.UTF8.GetBytes(timestamp + "." + data))); TimeSpan diff = DateTime.UtcNow - DateTime.Parse(timestamp); return calcSig == signature && Math.Abs(Math.Floor(diff.TotalMinutes)) < YourController.MaxFreshnessInMinutes; } public YourController(ILogger<YourController> logger) { _logger = logger; } [HttpPost] public async Task<IActionResult> Post() { // Extract the server signature from HTTP header string signature = Request.Headers["Lucca-Signature"]; // Extract the server timestamp from HTTP header string timestamp = Request.Headers["Lucca-Timestamp"]; // Read the request body using (StreamReader reader = new StreamReader(Request.Body)) { string data = await reader.ReadToEndAsync(); bool valid = ValidateEvent(data, signature, timestamp); if (!valid) { _logger.LogInformation("Invalid event:", data); return Unauthorized(); } } // Check for duplicates (you may have already received this event) // Do something with the event return Ok(); } }}
require 'rubygems'require 'base64'require 'openssl'require 'active_support/security_utils'require 'time'WEBHOOK_SECRET = 'my_webhook_secret'WEBHOOK_TIMESTAMP_FRESHNESS_MINUTES = 5helpers do def validate_event(data, sig, timestamp) # Calculate Signature calc_sig = Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', WEBHOOK_SECRET, timestamp + '.' + data)) sig_valid = ActiveSupport::SecurityUtils.secure_compare(calc_sig, sig) # Calculate timestamp diff diff = ((Time.parse(timestamp) - Time.now.utc) / 60).floor().abs() # OK if signatures match and timestamp is +/- 5 minutes from now return sig_valid && diff < WEBHOOK_TIMESTAMP_FRESHNESS_MINUTES endendpost '/' do request.body.rewind # Extract raw body data = request.body.read # Validate valid = validate_event(data, env["Lucca-Signature"], env["Lucca-Timestamp"]) halt 401 unless valid # Check for duplicates (you may have already received this event) # Do something with the event halt 202end
import { createHmac } from 'crypto';function validateSignature( luccaSecret: string | null, luccaSignature: string | null, luccaTimestamp: string | null, rawBody: string,): boolean { if (!luccaSecret || !luccaSignature || !luccaTimestamp) { return false; } const luccaDate = new Date(luccaTimestamp); const currentDate = new Date(); const timeDiff = Math.abs(currentDate.getTime() - luccaDate.getTime()); const timeDiffMinutes = Math.floor(timeDiff / (1000 * 60)); if (timeDiffMinutes > 5) { return false; } const expectedSignature = createHmac('sha256', luccaSecret) .update(`${luccaTimestamp}.${rawBody}`) .digest('base64'); return expectedSignature === luccaSignature;}// Example usage in an Express.js route handlervalidateSignature( process.env.LUCCA_WEBHOOK_SECRET || null, req.headers['lucca-signature'] as string | null, req.headers['lucca-timestamp'] as string | null, rawBody,);
Each event delivery is signed by the server in order for webhook endpoints to be
able to check their authenticity.The signature is a SHA256 hash calculated from the concatenation of both the delivery
timestamp (i.e. the moment the event was sent from our server) and the request payload,
separated by a . (dot) character. Both properties can be found in HTTP headers:
the signature is in the Lucca-Signature HTTP header ;
the timestamp is in the Lucca-Timestamp HTTP header.
If the signature is missing (i.e. no Lucca-Signature header was sent) or does not match,
then return a 401 Unauthorized.
The timestamp sent in the Lucca-Timestamp HTTP header matches the exact time the request
was sent from Lucca’s servers. It is an UTC date-time in ISO 8601 format, e.g.: “2025-01-01T08:34:23Z”.You should check that the timestamp is within +/- 5 minutes of the moment you receive the event, in order
to protect yourself against replay attacks.If the timestamp is too old / too far in the future, or is simply missing, then return a
401 Unauthorized.
You may want to check for duplicate events (and potentially discard them), as you may
receive an event more than once through our retry feature.
<?phpdefine('WEBHOOK_SECRET', 'my_webhook_secret');define('WEBHOOK_TIMESTAMP_FRESHNESS_MINUTES', 5);function validate_event($data, $sig, $timestamp){ # Calculate Signature $calc_sig = base64_encode(hash_hmac('sha256', $timestamp . '.' . $data, WEBHOOK_SECRET, true)); # Compare calculated and given signatures $sigValid = hash_equals($sig, $calc_sig); # Compare timestamp with now $diff = abs(floor((strtotime($timestamp) - time()) / 60)); return $sigValid && $diff < WEBHOOK_TIMESTAMP_FRESHNESS_MINUTES;}# Extract the server signature from HTTP header$sig = $_SERVER['Lucca-Signature'] ?? null;# Extract the server timestamp from HTTP header$timestamp = $_SERVER['Lucca-Timestamp'] ?? null;# Extract the raw body$data = file_get_contents('php://input');# Validate$valid = validate_event($data, $sig, $timestamp);error_log('Webhook validation: '.var_export($valid, true));if ($valid) { # Check for duplicates (you may have already received this event) # Do something with the event http_response_code(202);} else { http_response_code(401);}?>
using Microsoft.AspNetCore.Mvc;using Microsoft.Extensions.Logging;using System.IO;using System.Threading.Tasks;using System.Security.Cryptography;using System.Text;using System;namespace YourNamespace.Controllers { [ApiController] [Route("webhooks/[controller]")] public class YourController : ControllerBase { private readonly ILogger<YourController> _logger; private static readonly string WebhookSecret = "your_webhook_secret"; private static readonly int MaxFreshnessInMinutes = 5; private bool ValidateEvent(string data, string signature, string timestamp) { using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(YourController.WebhookSecret)); var calcSig = Convert.ToBase64String(hasher.ComputeHash(Encoding.UTF8.GetBytes(timestamp + "." + data))); TimeSpan diff = DateTime.UtcNow - DateTime.Parse(timestamp); return calcSig == signature && Math.Abs(Math.Floor(diff.TotalMinutes)) < YourController.MaxFreshnessInMinutes; } public YourController(ILogger<YourController> logger) { _logger = logger; } [HttpPost] public async Task<IActionResult> Post() { // Extract the server signature from HTTP header string signature = Request.Headers["Lucca-Signature"]; // Extract the server timestamp from HTTP header string timestamp = Request.Headers["Lucca-Timestamp"]; // Read the request body using (StreamReader reader = new StreamReader(Request.Body)) { string data = await reader.ReadToEndAsync(); bool valid = ValidateEvent(data, signature, timestamp); if (!valid) { _logger.LogInformation("Invalid event:", data); return Unauthorized(); } } // Check for duplicates (you may have already received this event) // Do something with the event return Ok(); } }}
require 'rubygems'require 'base64'require 'openssl'require 'active_support/security_utils'require 'time'WEBHOOK_SECRET = 'my_webhook_secret'WEBHOOK_TIMESTAMP_FRESHNESS_MINUTES = 5helpers do def validate_event(data, sig, timestamp) # Calculate Signature calc_sig = Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', WEBHOOK_SECRET, timestamp + '.' + data)) sig_valid = ActiveSupport::SecurityUtils.secure_compare(calc_sig, sig) # Calculate timestamp diff diff = ((Time.parse(timestamp) - Time.now.utc) / 60).floor().abs() # OK if signatures match and timestamp is +/- 5 minutes from now return sig_valid && diff < WEBHOOK_TIMESTAMP_FRESHNESS_MINUTES endendpost '/' do request.body.rewind # Extract raw body data = request.body.read # Validate valid = validate_event(data, env["Lucca-Signature"], env["Lucca-Timestamp"]) halt 401 unless valid # Check for duplicates (you may have already received this event) # Do something with the event halt 202end
import { createHmac } from 'crypto';function validateSignature( luccaSecret: string | null, luccaSignature: string | null, luccaTimestamp: string | null, rawBody: string,): boolean { if (!luccaSecret || !luccaSignature || !luccaTimestamp) { return false; } const luccaDate = new Date(luccaTimestamp); const currentDate = new Date(); const timeDiff = Math.abs(currentDate.getTime() - luccaDate.getTime()); const timeDiffMinutes = Math.floor(timeDiff / (1000 * 60)); if (timeDiffMinutes > 5) { return false; } const expectedSignature = createHmac('sha256', luccaSecret) .update(`${luccaTimestamp}.${rawBody}`) .digest('base64'); return expectedSignature === luccaSignature;}// Example usage in an Express.js route handlervalidateSignature( process.env.LUCCA_WEBHOOK_SECRET || null, req.headers['lucca-signature'] as string | null, req.headers['lucca-timestamp'] as string | null, rawBody,);