All docs

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"}'
Response 201
{
  "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-signaturestring
v1, followed by the base64 HMAC-SHA256 signature.
Body
{
  "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

  1. Join the webhook-id header, the webhook-timestamp header and the raw body with dots: <id>.<timestamp>.<body>.
  2. Sign that with HMAC-SHA256, using your whole secret as the key, whsec_ included.
  3. Base64 the result and compare it with the part after v1, in webhook-signature.
  4. 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();
});

Answer 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.

What do you want to ask?

Start free

Free, no card needed.