Webhooks
A webhook integration POSTs each new submission to an HTTPS endpoint you control. Every delivery is signed so you can verify it genuinely came from Formiary.
Setup
- Open a form and go to the Integrations tab.
- Add a Webhook integration.
- Enter the HTTPS endpoint URL each submission should be POSTed to.
- Choose which events to subscribe to.
- On creation, Formiary shows a signing secret.
Request headers
Every delivery includes these headers:
Content-Type: application/jsonX-Formiary-Event: submission.created— the event type.X-Formiary-Signature: sha256=<hex>— an HMAC-SHA256 of the raw request body bytes, keyed by your signing secret. The body is canonical JSON (sorted keys, compact separators); always verify against the raw bytes you receive, not a re-serialized object.
Payload envelope
{
"event": "submission.created",
"meta": {
"deliveryId": "…",
"formIntegrationId": "…",
"formId": "…",
"submissionId": null, // null for test events
"timestamp": "2026-06-15T…",
"test": true // present and true only for test events
},
"data": { "submission": { "id": "…", "values": [ … ] } }
}
Verifying the signature
Compute an HMAC-SHA256 of the raw request body using your signing secret and
compare it to the X-Formiary-Signature header. Reject any request whose
computed signature doesn't match.
import crypto from 'node:crypto';
// Mount with a raw body parser so you verify the exact bytes received.
app.post('/webhooks/formiary', express.raw({ type: 'application/json' }), (req, res) => {
const received = req.header('X-Formiary-Signature') ?? '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.FORMIARY_SIGNING_SECRET)
.update(req.body) // raw request bytes, not a re-serialized object
.digest('hex');
const valid = received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// handle event.event / event.data ...
res.sendStatus(200);
});
import hashlib
import hmac
import os
from flask import request, abort
SECRET = os.environ["FORMIARY_SIGNING_SECRET"].encode()
@app.post("/webhooks/formiary")
def formiary_webhook():
raw = request.get_data() # raw request bytes
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-Formiary-Signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
event = request.get_json()
# handle event["event"] / event["data"] ...
return "", 200
func formiaryWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("FORMIARY_SIGNING_SECRET")))
mac.Write(body) // raw request bytes
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Formiary-Signature"))) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// unmarshal body and handle the event ...
w.WriteHeader(http.StatusOK)
}
Responding, retries & dead-lettering
Respond with a 2xx status to acknowledge a delivery. Any other status (or a connection error / timeout) marks the attempt as failed and schedules a retry with backoff.
After repeated failures a delivery is dead-lettered and stops retrying automatically. You can manually retry a failed or dead-lettered delivery from its row in the delivery log. A manual retry preserves the attempt counter, so retrying a dead-lettered delivery grants exactly one more attempt before it re-dead-letters.
meta.deliveryId (or the submission id) to deduplicate on your side.Testing
You can send a test event to your endpoint from the integration. Test events
have meta.test: true and a null submissionId, so your handler can recognize
and ignore them if needed.
Delivery log
Each integration keeps a log of delivery attempts with their status (pending, in flight, success, failed, or dead-lettered) and the response received. Use it to debug failures and to trigger manual retries.