Skip to main content
Every webhook request includes an X-Webhook-Signature header containing an HMAC-SHA256 signature. You should always verify this signature before processing the payload to ensure the request came from SendKit.

How it works

SendKit signs the JSON payload using your webhook’s signing secret:
HMAC-SHA256(JSON payload, signing_secret)
The resulting hex digest is sent in the X-Webhook-Signature header.

Verification examples

import crypto from 'crypto';
import express from 'express';

const app = express();

// Important: use raw body for signature verification
app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf.toString(); }
}));

const verify = (rawBody, signature, secret) => {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
};

app.post('/webhooks/sendkit', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const secret = process.env.SENDKIT_WEBHOOK_SECRET;

  if (!verify(req.rawBody, signature, secret)) {
    return res.status(401).send('Invalid signature');
  }

  // Process the event
  console.log(req.body.type);
  res.status(200).send('OK');
});
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];
$secret = env('SENDKIT_WEBHOOK_SECRET');

$expected = hash_hmac('sha256', $payload, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($payload, true);
// Process the event
import hmac
import hashlib
from flask import Flask, request

app = Flask(__name__)

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

@app.route('/webhooks/sendkit', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Webhook-Signature')
    secret = 'your_signing_secret'

    # Important: use raw body, not parsed JSON
    if not verify(request.get_data(), signature, secret):
        return 'Invalid signature', 401

    event = request.get_json()
    # Process the event
    return 'OK', 200
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
)

func verify(rawBody []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    // Important: use raw body, not re-serialized JSON
    rawBody, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("X-Webhook-Signature")
    secret := "your_signing_secret"

    if !verify(rawBody, signature, secret) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // Process the event using rawBody
    w.WriteHeader(http.StatusOK)
}

Rotating secrets

You can rotate your webhook’s signing secret at any time from the dashboard. After rotation, use the new secret to verify future deliveries. Previous deliveries will still show the old signature in logs.
After rotating a secret, update your application immediately. Requests signed with the old secret will fail verification.