Webhooks Overview
Signature Verification
Verify the authenticity of webhook deliveries using HMAC-SHA256.
Why verify signatures?
Anyone who knows your webhook endpoint URL can send fake requests to it. Signature verification ensures that every webhook delivery genuinely comes from SkyLight Chat and has not been tampered with in transit.
How it works
- When you create a webhook, SkyLight Chat generates a unique secret (a 64-character random string)
- For every delivery, SkyLight Chat computes an HMAC-SHA256 digest of the raw JSON payload using your secret
- The digest is sent in the
X-Skylight-Signatureheader assha256=<hex_digest> - Your server computes the same digest and compares — if they match, the request is authentic
Request headers
Every webhook delivery includes:
Content-Type: application/json
X-Skylight-Event: contact.created
X-Skylight-Delivery: a1b2c3d4-e5f6-7890-abcd-ef1234567890
X-Skylight-Signature: sha256=abc123def456...
X-Skylight-Timestamp: 1741093200
| Header | Description |
|---|---|
X-Skylight-Event | The event name |
X-Skylight-Delivery | UUID for this delivery attempt |
X-Skylight-Signature | sha256=<hmac_hex> of the raw request body |
X-Skylight-Timestamp | Unix timestamp of the delivery |
Verification examples
import crypto from 'crypto'
function verifySignature(rawBody, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody) // rawBody must be the raw bytes, NOT parsed JSON
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}
// Express example
app.post('/webhooks/skylightchat', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-skylight-signature']
const secret = process.env.SKYLIGHTCHAT_WEBHOOK_SECRET
if (!verifySignature(req.body, signature, secret)) {
return res.status(401).send('Unauthorized')
}
const payload = JSON.parse(req.body)
// process payload...
res.status(200).send('OK')
})
<?php
function verifyWebhookSignature(string $rawBody, string $signature, string $secret): bool
{
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signature);
}
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SKYLIGHT_SIGNATURE'] ?? '';
$secret = getenv('SKYLIGHTCHAT_WEBHOOK_SECRET');
if (!verifyWebhookSignature($rawBody, $signature, $secret)) {
http_response_code(401);
exit('Unauthorized');
}
$payload = json_decode($rawBody, true);
// process $payload...
http_response_code(200);
echo 'OK';
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['SKYLIGHTCHAT_WEBHOOK_SECRET']
@app.route('/webhooks/skylightchat', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Skylight-Signature', '')
raw_body = request.get_data()
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
payload = request.get_json()
# process payload...
return 'OK', 200
require 'openssl'
require 'sinatra'
post '/webhooks/skylightchat' do
secret = ENV['SKYLIGHTCHAT_WEBHOOK_SECRET']
signature = request.env['HTTP_X_SKYLIGHT_SIGNATURE']
raw_body = request.body.read
expected = 'sha256=' + OpenSSL::HMAC.hexdigest('sha256', secret, raw_body)
unless Rack::Utils.secure_compare(expected, signature)
halt 401, 'Unauthorized'
end
payload = JSON.parse(raw_body)
# process payload...
status 200
end
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
)
func verifySignature(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Skylight-Signature")
secret := os.Getenv("SKYLIGHTCHAT_WEBHOOK_SECRET")
if !verifySignature(body, sig, secret) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "OK")
}
Important: use raw bytes
Always compute the HMAC over the raw request body bytes, not over a re-serialized JSON object. JSON serialization may change whitespace or key ordering, causing signature mismatches.
In Express.js, use express.raw() instead of express.json() for the webhook route. In other frameworks, read the raw body before parsing.
Replay protection
To guard against replay attacks, validate the X-Skylight-Timestamp header:
const timestamp = parseInt(req.headers['x-skylight-timestamp'], 10)
const now = Math.floor(Date.now() / 1000)
const fiveMinutes = 5 * 60
if (Math.abs(now - timestamp) > fiveMinutes) {
return res.status(400).send('Stale event — possible replay attack')
}
Reject any delivery where the timestamp is more than 5 minutes in the past.
Rotating your secret
If your secret is ever compromised, regenerate it immediately:
- Dashboard: Go to webhook settings → Regenerate Secret
- API:
POST /api/v1/webhooks/{id}/regenerate-secret
Update your environment variable and deploy before the old secret stops working.
