Skip to main content

Signature

Validate signature in order to ensure authenticity.

Timestamp

Validate the timestamp in order to protect against replay attacks.

Deduplication

Check for duplicates.
You should refuse any event that does not have both the Lucca-Signature and the Lucca-Timestamp HTTP headers and return a 401 Unauthorized.

Lucca-Signature

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.
<content_to_hash> = <timestamp> '.' <body>

Lucca-Timestamp

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.

Handling Duplicates

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.
<?php
define('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 = 5

helpers 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
    end
end

post '/' 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 202
end
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 handler
validateSignature(
    process.env.LUCCA_WEBHOOK_SECRET || null,
    req.headers['lucca-signature'] as string | null,
    req.headers['lucca-timestamp'] as string | null,
    rawBody,
);
const crypto = require('crypto');
function validateSignature(luccaSecret, luccaSignature, luccaTimestamp, rawBody) {
    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 = crypto
        .createHmac('sha256', luccaSecret)
        .update(`${luccaTimestamp}.${rawBody}`)
        .digest('base64');
    return expectedSignature === luccaSignature;
}
// Example usage in an Express.js route handler
validateSignature(
    process.env.LUCCA_WEBHOOK_SECRET,
    req.headers['lucca-signature'],
    req.headers['lucca-timestamp'],
    rawBody
);