Webhooks for form submissions
Pipe every submission into your own system — CRM, database, queue, anything with an HTTPS endpoint. We POST signed JSON, retry on failure, and log every delivery.
Endpoint requirements
- A public HTTPS URL that accepts
POSTwith a JSON body. - Respond 2xx within 8 seconds (we time out after 8s; 4s to connect). Do slow work after responding — queue it.
- Any non-2xx response or timeout counts as a failure and triggers a retry.
Setup
- In Static Contact, open your Form and go to the Webhooks tab.
- Click Add webhook, keep the type as Generic (signed JSON), and enter your endpoint URL.
- Leave the signing secret blank to have one generated, or paste your own. Save, then copy the secret into your endpoint's configuration.
Webhooks are available on paid plans; a form can deliver to several destinations at once.
The payload
{
"form": {
"id": "019ea10b-...",
"name": "example.com",
"email": "you@example.com"
},
"submission": {
"id": 123,
"data": { "name": "Jane", "email": "jane@acme.com", "message": "Hi!" },
"received_at": "2026-06-08T12:00:00+00:00"
}
}
submission.data contains exactly the fields your form posted — you control the shape by naming your form fields.
Verifying the signature
Every request carries a signature header so you can prove it came from us and not someone who found your URL:
X-StaticContact-Signature: sha256=<HMAC-SHA256(raw_request_body, your_secret)>
Compute the HMAC over the raw request body (before any JSON parsing) and compare with a constant-time function. PHP:
<?php
$secret = getenv('STATIC_CONTACT_SECRET');
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (! hash_equals($expected, $_SERVER['HTTP_X_STATICCONTACT_SIGNATURE'] ?? '')) {
http_response_code(401);
exit('invalid signature');
}
$payload = json_decode($body, true);
// handle the submission, respond fast
http_response_code(200);
Node (Express):
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.STATIC_CONTACT_SECRET)
.update(req.body)
.digest('hex');
const given = req.headers['x-staticcontact-signature'] ?? '';
const valid = given.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!valid) return res.status(401).end();
const payload = JSON.parse(req.body);
// handle the submission, respond fast
res.status(200).end();
});
Retries & delivery logs
- On failure we retry up to 3 attempts with increasing waits: 1 minute, 5 minutes, 15 minutes.
- Plans with delivery logs show every attempt (status code, response excerpt, attempt number) on the Webhooks tab, with a manual Resend per destination.
- Deliveries are queued — a slow endpoint never delays your email notification or other webhooks.
Troubleshooting
- Signature mismatch — you're verifying a parsed-then-re-encoded body instead of the raw bytes, or the secret differs from the one saved on the webhook. Use the raw request body.
- Saving with a blank secret never breaks verification — blank keeps the existing secret; a new secret is only set when you type one.
- Timeouts — respond first, process after. We allow 8 seconds total.
- Nothing arrives — check the webhook is Enabled and the delivery log for the exact response.