REST API
Webhooks
Get every new response pushed to your server the moment someone submits a form. Each delivery is signed, so you can check it came from Enform.
Subscribe a URL
Create a webhook for one form with your API key. The URL must be public and use https. A form can have up to 25 webhooks.
curl -X POST "https://api.enform.co/api/v1/webhooks" \
-H "Authorization: Bearer $ENFORM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"formId":"c3a1f7e2-1b4d-4e8a-9c2f-6d5e4b3a2f10","url":"https://example.com/enform/webhook"}'const res = await fetch("https://api.enform.co/api/v1/webhooks", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ENFORM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({"formId":"c3a1f7e2-1b4d-4e8a-9c2f-6d5e4b3a2f10","url":"https://example.com/enform/webhook"}),
});
const json = await res.json();import os, requests
res = requests.post(
"https://api.enform.co/api/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['ENFORM_API_KEY']}"},
json={"formId":"c3a1f7e2-1b4d-4e8a-9c2f-6d5e4b3a2f10","url":"https://example.com/enform/webhook"},
)
data = res.json(){
"type": "webhook",
"data": {
"id": "9d4e2b7a-3c1f-4a6e-8b0d-1f2e3a4b5c6d",
"formId": "c3a1f7e2-1b4d-4e8a-9c2f-6d5e4b3a2f10",
"url": "https://example.com/enform/webhook",
"event": "submission.created",
"createdAt": "2026-11-03T10:00:00.000Z",
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}What you receive
Each delivery is a POST with a JSON body. data is the response, in the same shape as Get a response.
Headers
webhook-idstring- The event's ID, the same as id in the body. Use it to skip duplicates.
webhook-timestampstring- When it was sent, in Unix seconds.
webhook-signaturestringv1,followed by the base64 HMAC-SHA256 signature.
{
"id": "0f6a2d8e-4b1c-4e7a-9d3f-8a2b1c0d9e7f",
"type": "submission.created",
"createdAt": "2026-11-03T09:16:41.204Z",
"data": {
"id": "5b0e4c1a-7d2f-4f0e-9a51-2f6c8d0b9e13",
"formId": "c3a1f7e2-1b4d-4e8a-9c2f-6d5e4b3a2f10",
"createdAt": "2026-11-03T09:14:02.511Z",
"completedAt": "2026-11-03T09:16:40.020Z",
"answers": {
"bba1826f-56fc-4e44-a0fa-bac06310da49": "Ada Lovelace",
"4dfa4a9e-85e3-4187-b38e-ae1d4675f99b": "9",
"a2d90fd0-3942-45c8-ade3-773ed5f9733e": "Weekly reports, Slack alerts",
"e7c2a9d4-0b3f-4c1e-9a8d-5f6b7c8d9e01": "ada@example.com"
},
"hidden": {
"email": "ada@example.com"
},
"fields": [
{
"id": "bba1826f-56fc-4e44-a0fa-bac06310da49",
"key": "bba1826f-56fc-4e44-a0fa-bac06310da49",
"label": "Your name",
"type": "short_text",
"value": "Ada Lovelace",
"text": "Ada Lovelace"
},
{
"id": "4dfa4a9e-85e3-4187-b38e-ae1d4675f99b",
"key": "4dfa4a9e-85e3-4187-b38e-ae1d4675f99b",
"label": "How likely are you to recommend us?",
"type": "opinion_scale",
"value": 9,
"text": "9"
},
{
"id": "a2d90fd0-3942-45c8-ade3-773ed5f9733e",
"key": "a2d90fd0-3942-45c8-ade3-773ed5f9733e",
"label": "What should we build next?",
"type": "checkbox",
"value": [
"Weekly reports",
"Slack alerts"
],
"text": "Weekly reports, Slack alerts"
},
{
"id": "e7c2a9d4-0b3f-4c1e-9a8d-5f6b7c8d9e01",
"key": "e7c2a9d4-0b3f-4c1e-9a8d-5f6b7c8d9e01",
"label": "email",
"type": "hidden",
"value": "ada@example.com",
"text": "ada@example.com"
}
]
}
}Check the signature
- Join the
webhook-idheader, thewebhook-timestampheader and the raw body with dots:<id>.<timestamp>.<body>. - Sign that with HMAC-SHA256, using your whole secret as the key,
whsec_included. - Base64 the result and compare it with the part after
v1,inwebhook-signature. - Turn away old timestamps, for example anything more than five minutes off.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const secret = process.env.ENFORM_WEBHOOK_SECRET;
const app = express();
app.post("/enform/webhook", express.raw({ type: "application/json" }), (req, res) => {
const id = req.header("webhook-id");
const timestamp = req.header("webhook-timestamp");
const body = req.body.toString("utf8");
const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64");
const given = (req.header("webhook-signature") ?? "").split(" ").map((part) => part.split(",")[1] ?? "");
const matches = given.some(
(sig) => sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
if (!matches || !fresh) return res.status(400).end();
const event = JSON.parse(body);
console.log(event.data.answers);
res.status(200).end();
});import base64, hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["ENFORM_WEBHOOK_SECRET"]
@app.post("/enform/webhook")
def enform_webhook():
msg_id = request.headers["webhook-id"]
timestamp = request.headers["webhook-timestamp"]
body = request.get_data(as_text=True)
signed = f"{msg_id}.{timestamp}.{body}".encode()
expected = base64.b64encode(hmac.new(SECRET.encode(), signed, hashlib.sha256).digest()).decode()
given = [part.split(",", 1)[-1] for part in request.headers.get("webhook-signature", "").split()]
matches = any(hmac.compare_digest(expected, sig) for sig in given)
fresh = abs(time.time() - int(timestamp)) < 300
if not matches or not fresh:
return "", 400
event = request.get_json()
print(event["data"]["answers"])
return "", 200Answer and retries
2xxAccepted- The delivery is done.
429, 5xx or no answerRetried- Enform tries again with growing gaps, up to 8 attempts over about 20 minutes. It waits 15 seconds for each answer.
410Unsubscribe- Enform deletes the webhook and sends nothing more.
Other 4xxDropped- That delivery isn't retried.
Stop deliveries
- Delete the webhook with DELETE /webhooks/{webhookId}, or answer a delivery with
410. - Revoking the API key that created a webhook removes it. So does the key's creator leaving the workspace owner role, or the workspace's plan losing API access.