Verify webhook events
servis.ai offers two ways to verify the authenticity of a webhook so you can be sure the request came from servis.ai (or match your app’s existing header scheme):
Verify with servis.ai’s headers (Default)
Verify with your own headers (Custom)
Verify with servis.ai’s headers (Default)
Set up in the webhook form
Secure Webhook = Yes
Secure Type = Default
Secret = your shared secret (keep it private)
What servis.ai sends (every POST)
Header
x-fa-request-timestamp: current Unix time in secondsHeader
x-fa-signature:sha256=+ HMAC-SHA256 ofv0:{timestamp}:{raw_json_body}using your SecretMethod:
POSTBody: the exact JSON that was signed (no reformatting)
Using HMAC with a shared secret provides message authentication and integrity.
Example request headers (plain text)
{
"host": "example.com",
"user-agent": "servis.ai/1.0",
"content-type": "application/json; charset=utf-8",
"content-length": "42",
"x-fa-request-timestamp": "1739923528",
"x-fa-signature": "sha256=a05d830fa017433b...0341"
}
Example request body
{"name":"John Doe"}
Create your signature string to compare with x-fa-signature
Receive the webhook request.
Construct the message as:
v0:{x-fa-request-timestamp}:{raw_request_body}
Example with values
v0:1739923528:{"name":"John Doe"}
Hash the message using HMAC-SHA256 with your Secret; output hex.
Create the signature by prepending
sha256=to the hex digest:
sha256={HEX_DIGEST}
Compare your computed signature to the
x-fa-signatureheader. If they match (and the timestamp is recent), the request came from servis.ai.
Replay protection
Reject requests wherex-fa-request-timestampis older/newer than ~5 minutes from your server’s clock. Timestamps + HMAC significantly reduce replay risk.
Node.js verification example (server side)
import crypto from 'crypto';
// Express-style example that preserves the raw body:
import express from 'express';
const app = express();
// Capture raw body (important: must be the exact bytes that were signed)
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); }
}));
const SECRET = process.env.SERVIS_AI_WEBHOOK_SECRET; // same as in the form
app.post('/webhook', (req, res) => {
const ts = req.header('x-fa-request-timestamp');
const sig = req.header('x-fa-signature');
// Basic presence/format checks
if (!ts || !sig || !sig.startsWith('sha256=')) {
return res.status(400).send('Missing or malformed security headers');
}
// Replay protection (~5 minutes window)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(ts)) > 300) {
return res.status(408).send('Stale timestamp');
}
// Build the signed string using the *raw* request body
const payload = req.rawBody ?? JSON.stringify(req.body);
const message = `v0:${ts}:${payload}`;
// Compute expected signature
const digest = crypto.createHmac('sha256', SECRET)
.update(message, 'utf8')
.digest('hex');
const expected = `sha256=${digest}`;
// Constant-time comparison
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(sig, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('Signature mismatch');
}
// ✅ Verified
res.status(200).send('OK');
});
crypto.timingSafeEqual helps avoid timing attacks when comparing secrets.
Sender example (Default)
// DEFAULT option: example sender
import crypto from 'crypto';
import fetch from 'node-fetch';
const WEBHOOK_URL = 'https://example.com/webhook/abc123';
const SECRET = 'sk_demo_12345abc67890'; // same value set in the form
function sign(secret, ts, bodyStr) {
const base = `v0:${ts}:${bodyStr}`;
const hex = crypto.createHmac('sha256', secret).update(base).digest('hex');
return `sha256=${hex}`;
}
async function sendWebhook(data) {
const ts = Math.floor(Date.now() / 1000).toString();
const bodyStr = JSON.stringify(data);
const res = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-fa-request-timestamp': ts,
'x-fa-signature': sign(SECRET, ts, bodyStr),
},
body: bodyStr
});
console.log('Status:', res.status);
console.log('Body:', await res.text());
}
sendWebhook({ name: 'John Doe' }).catch(console.error);
Verify with your own headers (Custom)
If your application already uses specific header names or builds the signature differently, choose Custom. In this mode, verification is performed by a short function you paste into the webhook’s Custom Code box. You can keep x-fa-request-timestamp and x-fa-signature or change them—just make sure the code reads the same names your system sends.
Set up in the webhook form
Secure Webhook = Yes
Secure Type = Custom
Secret = the same value your system uses
Server side example (Custom)
(function(request, secret, context){
const crypto = context.libs.crypto;
// Read the headers your system sends:
// If your app uses different names, change these two lines accordingly.
const ts = request.header('x-fa-request-timestamp');
const sig = request.header('x-fa-signature');
// Required format and presence check
if (!ts || !sig || !sig.startsWith('sha256=')) {
return false;
}
// Reject old or future requests beyond ±5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(ts)) > 300) {
return false;
}
// Use the raw body if available to avoid formatting differences
const payload = request.rawBody ?? JSON.stringify(request.body);
const signed = `v0:${ts}:${payload}`;
// Recompute expected signature with the same Secret
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(signed, 'utf8')
.digest('hex');
// Constant-time comparison to avoid timing attacks
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(sig, 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}(request, secret, context));
HMAC-SHA256 is the recommended MAC for this scheme; compare using constant time and validate timestamps to mitigate replays.
Sender example (Custom)
// CUSTOM option: example sender
import crypto from 'crypto';
import fetch from 'node-fetch';
const WEBHOOK_URL = 'https://example.com/webhook/abc123';
const SECRET = 'sk_demo_12345abc67890'; // same value you set in the form
function sign(secret, ts, bodyStr) {
const base = `v0:${ts}:${bodyStr}`;
const hex = crypto.createHmac('sha256', secret).update(base).digest('hex');
return `sha256=${hex}`;
}
async function sendWebhook(data) {
const ts = Math.floor(Date.now() / 1000).toString();
const bodyStr = JSON.stringify(data);
const res = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-fa-request-timestamp': ts, // match the names your Custom code reads
'x-fa-signature': sign(SECRET, ts, bodyStr),
},
body: bodyStr
});
console.log('Status:', res.status);
console.log('Body:', await res.text());
}
sendWebhook({ name: 'John Doe' }).catch(console.error);
Secret: what it is and how to set it up
What it is
A private string known only by you and the webhook. It’s used to create and verify the signature so you can be sure the request really came from your system/servis.ai. (This is standard HMAC usage.)
How to set it
In the webhook form, enter a strong Secret (for example:
sk_demo_12345abc67890).Use the same exact string in your sending code.
If you rotate it later, update it in both places at the same time.
Troubleshooting
Verification failed → Secret mismatch, body changed after signing, missing
sha256=prefix, or timestamp too old/new.Missing required headers (Default) →
x-fa-request-timestamporx-fa-signaturewas not sent.Tip → Always compute the signature over the exact raw JSON string you send; even whitespace changes the digest.
Tip → Keep a ~5-minute acceptance window to prevent replay attacks.
That’s it—pick Default for the quickest secure setup, or Custom if you need to match your own headers or signature format.