DocsReference

Verifying signatures

Every webhook request includes an X-LetsPost-Signature header. Verify it before trusting the payload — it proves the request came from LetsPost and wasn't tampered with in transit.

How it works

LetsPost computes an HMAC-SHA256 digest of the raw request body using the endpoint's secret (returned once when you register the endpoint). The header value is:

X-LetsPost-Signature: sha256=<hex-encoded-digest>

To verify, compute the same HMAC on your side and compare using a timing-safe comparison function. A regular string equality check (===) is vulnerable to timing attacks.

Important: always use the raw, unparsed request body bytes for the HMAC. If you parse JSON first, whitespace differences will break the digest.

Node.js

Using the built-in crypto module with Express:

import express from 'express';
import crypto from 'node:crypto';

const app = express();

// express.raw keeps the body as a Buffer — required for HMAC to match.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.header('X-LetsPost-Signature') ?? '';
  const presented = header.replace(/^sha256=/, '');
  const expected = crypto
    .createHmac('sha256', process.env.LETSPOST_WEBHOOK_SECRET)
    .update(req.body)            // Buffer — not parsed JSON
    .digest('hex');

  // Buffers must be the same length before timingSafeEqual, or it throws.
  const presentedBuf = Buffer.from(presented, 'hex');
  const expectedBuf  = Buffer.from(expected,  'hex');

  if (
    presentedBuf.length !== expectedBuf.length ||
    !crypto.timingSafeEqual(presentedBuf, expectedBuf)
  ) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString());
  console.log('Received event:', event.type);
  res.sendStatus(200);
});

Python

Using the standard hmac module with FastAPI:

import hmac
import hashlib
import os
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()

@app.post("/webhook")
async def webhook(
    request: Request,
    x_letspost_signature: str = Header(None),
):
    body = await request.body()   # raw bytes — don't await .json() first
    presented = (x_letspost_signature or "").removeprefix("sha256=")
    expected = hmac.new(
        os.environ["LETSPOST_WEBHOOK_SECRET"].encode(),
        body,
        hashlib.sha256,
    ).hexdigest()

    # hmac.compare_digest is timing-safe
    if not hmac.compare_digest(presented, expected):
        raise HTTPException(status_code=401, detail="Invalid signature")

    import json
    event = json.loads(body)
    print("Received event:", event["type"])
    return {"ok": True}

PHP

Using hash_hmac and hash_equals:

<?php

$rawBody = file_get_contents('php://input');  // raw — not $_POST
$header  = $_SERVER['HTTP_X_LETSPOST_SIGNATURE'] ?? '';
$presented = ltrim(str_replace('sha256=', '', $header));

$expected = hash_hmac('sha256', $rawBody, getenv('LETSPOST_WEBHOOK_SECRET'));

// hash_equals is timing-safe
if (!hash_equals($expected, $presented)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true);
error_log('Received event: ' . $event['type']);

http_response_code(200);
echo json_encode(['ok' => true]);

Ruby

Using OpenSSL::HMAC and a timing-safe comparison:

require 'openssl'

# Sinatra example — adapt to Rails or any other framework
post '/webhook' do
  raw_body  = request.body.read
  header    = request.env['HTTP_X_LETSPOST_SIGNATURE'] || ''
  presented = header.sub(/^sha256=/, '')

  expected = OpenSSL::HMAC.hexdigest(
    OpenSSL::Digest.new('sha256'),
    ENV['LETSPOST_WEBHOOK_SECRET'],
    raw_body
  )

  # Rack::Utils.secure_compare or ActiveSupport::SecurityUtils.secure_compare
  # are both timing-safe alternatives to ==
  unless Rack::Utils.secure_compare(expected, presented)
    halt 401, 'Invalid signature'
  end

  event = JSON.parse(raw_body)
  logger.info "Received event: #{event['type']}"
  status 200
end

Testing locally

Use ngrok or a similar tunnel to expose a local port, then register the tunnel URL as your webhook endpoint. The LetsPost dashboard also lets you replay any past event so you can iterate without triggering real activity.

Was this page helpful?

Something unclear? Email us — we read every message.