Verifying webhooks

Deftform sends a webhook to your configured endpoint(s) every time one of your forms is submitted. To make sure a request genuinely came from Deftform and wasn't tampered with in transit, each request is signed with your workspace's webhook secret.

Request format

  • Method: POST

  • Content-Type: application/json

  • Body: JSON describing the submission, for example:

{
"id": "9f1c2d3e-4b5a-6789-0abc-def012345678",
"number": 123,
"number_formatted": "INV-123",
"form_id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809",
"form_name": "Contact form",
"referrer": "https://example.com/contact",
"data": []
}

Headers

Header

Description

X-Deftform-Signature-256

HMAC-SHA256 signature of the raw request body, in the form sha256=<hex>. Use this to verify authenticity.

secret

Your plaintext webhook secret. Provided for backward compatibility only — prefer verifying the signature above.

Signature specification

  1. Algorithm: HMAC-SHA256.

  2. Signing key: Your workspace webhook secret, used directly (no decoding or transformation). Find it under Settings → Connections → Webhook.

  3. Signed payload: The exact raw bytes of the HTTP request body — nothing else is prepended or appended, and no timestamp is included.

  4. Encoding: Lowercase hexadecimal, prefixed with sha256=.

Important: Compute the HMAC over the raw request body before any JSON parsing or re-serialization. Re-encoding the JSON can change bytes (key order, whitespace, unicode escaping) and cause the signature to mismatch.

Verifying the signature

PHP

<?php
$payload = file_get_contents('php://input'); // raw body
$secret = 'YOUR_WEBHOOK_SECRET';
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
$signature = $_SERVER['HTTP_X_DEFTFORM_SIGNATURE_256'] ?? '';
 
if (! hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}

Laravel

<?php
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), config('services.deftform.webhook_secret'));
 
abort_unless(
hash_equals($expected, (string) $request->header('X-Deftform-Signature-256')),
401
);

Node.js (Express)

const crypto = require('crypto');
 
// Capture the RAW body, e.g. app.use(express.raw({ type: 'application/json' }))
app.post('/webhooks/deftform', (req, res) => {
const expected =
'sha256=' + crypto.createHmac('sha256', process.env.DEFTFORM_WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer
.digest('hex');
 
const received = req.get('X-Deftform-Signature-256') || '';
 
const valid =
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
 
if (!valid) return res.status(401).end();
 
const payload = JSON.parse(req.body.toString());
// …process the submission
res.sendStatus(200);
});

Always compare using a constant-time function (hash_equalscrypto.timingSafeEqual) rather than == to avoid timing attacks.

Replay protection

Signatures do not currently include a timestamp, so there is no built-in expiry window. To protect against replayed requests, de-duplicate on the id field in the payload — each form submission has a unique id. Store processed IDs and ignore any you've already handled.

Security recommendations

  • Always serve your webhook endpoint over HTTPS.

  • Treat the webhook secret as a credential — never expose it client-side or commit it to source control.

  • If you believe your secret has leaked, rotate it in Settings → Connections → Webhook and update your receiver.

  • Respond with a 2xx status once you've accepted the request; Deftform retries on failures.


Was this article helpful?