CodeB eForms Server by Aloaha

Webhooks Webhooks: every submission straight into your system.

A webhook sends each new submission of a form to an address of your choice, a few seconds after the person clicked Submit: CRM, ticket system, ERP, a low-code flow or your own service. The message is JSON, carries all values and optionally the sealed PDF, and is signed so your system can prove it came from your form server.

Signing follows the open Standard Webhooks specification, so ready-made libraries for many languages can check it. Messages that cannot be delivered are retried for 21 hours.

In short

  • Event: submission.created for every real submission, as JSON, optionally with the sealed PDF.
  • Signature: Standard Webhooks, HMAC-SHA256 over id, timestamp and body (headers webhook-id, webhook-timestamp, webhook-signature).
  • Delivery: 7 attempts over about 21 hours, HTTPS only, private networks refused, failure e-mail at the end.

How it works

  1. Register an address for a form through the REST API (role admin). The answer contains a signing secret that starts with whsec_; it is shown only once.
  2. Someone submits the form in the browser, from the PDF or through the API. The server creates the sealed PDF as usual and queues one message per webhook. The person does not wait for your system.
  3. Your endpoint receives a POST with the values as JSON and three headers: webhook-id, webhook-timestamp and webhook-signature.
  4. Check the signature with the secret, then answer with any 2xx status within 15 seconds. Do longer work after answering.

Webhooks complement the e-mails of the form: the business owner still gets the PDF and XML by e-mail, unless the form is set up without e-mail.

Setting up a webhook

You need an access token of a user with the role admin in the form's group (see getting a bearer token). The address must use https:// and be reachable from the internet; addresses in private networks are refused unless the administrator allows them.

curl -X POST https://forms.example.com/api/v1/forms/contact/webhooks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"url": "https://crm.example.com/hooks/eforms",
       "description": "CRM import",
       "includePdf": true,
       "failureEmails": ["it@example.com"]}'

HTTP/1.1 201 Created
{ "id": "wh_3f2a9c1d0b7e4a6c5d8e", "formId": "contact",
  "url": "https://crm.example.com/hooks/eforms", "events": ["submission.created"],
  "active": true, "includePdf": true, "failureEmails": ["it@example.com"],
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", … }

# send a test message and see what the receiver answered
curl -X POST -H "Authorization: Bearer $TOKEN" \
  https://forms.example.com/api/v1/forms/contact/webhooks/wh_3f2a9c1d0b7e4a6c5d8e/test
{ "delivered": true, "httpStatus": 200, "durationMs": 184 }
  • includePdf adds the submitted PDF as Base64 (up to 10 MB) to every message. Without it the message carries the values only.
  • failureEmails (up to five addresses) are told when a message could not be delivered after all attempts.
  • Store the secret in your system's secret store. Lost it? POST …/webhooks/{id}/rotate-secret issues a new one; the old one stops working at once.

The message

Each message is a JSON object with type, timestamp and data. The event submission.created carries the form ID, the submission ID (the same the API returns for API submissions), the submission reference, the time and the values by field name. Control fields of the web form (bot check, passwords of the encrypted archive) are never included.

POST /hooks/eforms HTTP/1.1
Host: crm.example.com
Content-Type: application/json
User-Agent: CodeB-eForms-Server-Webhooks/1.0
webhook-id: msg_2b8f0c6e9a1d4f7b3c5e8a90
webhook-timestamp: 1791802200
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{ "type": "submission.created",
  "timestamp": "2026-10-12T09:30:00Z",
  "data": {
    "formId": "contact",
    "submissionId": "5d0c2f4e8b1a4c7e9f3b2a1d6e5c4b3a",
    "reference": "contact/118/26",
    "submittedAt": "2026-10-12T09:30:00Z",
    "values": { "FirstName": "Erika", "Surname": "Muster", "Email": "erika@example.com" },
    "pdf": { "fileName": "contact.pdf", "contentType": "application/pdf",
             "size": 48211, "data": "JVBERi0xLjcK…" } } }

The test message has the type webhook.test. The OpenAPI description lists both events under webhooks, with their JSON Schema, so code generators can create the receiving types.

Checking the signature

The signature is v1, followed by the Base64 HMAC-SHA256 of webhook-id + "." + webhook-timestamp + "." + body. The key is the Base64-decoded part of the secret after whsec_. Use the raw request body, before any JSON parsing, and compare in constant time. Reject messages older than five minutes, and keep the webhook-id of processed messages to ignore repeats.

Node.js

const crypto = require("node:crypto");

// rawBody: the request body exactly as received (string), before any JSON parsing
function verifyWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
  if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");
  return (headers["webhook-signature"] || "").split(" ").some(part => {
    const [version, sig] = part.split(",");
    return version === "v1" && sig && sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}

Python

import base64, hashlib, hmac, time

def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
    msg_id, ts = headers.get("webhook-id", ""), headers.get("webhook-timestamp", "0")
    if not msg_id or abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(mac).decode()
    return any(hmac.compare_digest(part[3:], expected)
               for part in headers.get("webhook-signature", "").split() if part.startswith("v1,"))

# or with the reference library:  pip install standardwebhooks
# from standardwebhooks import Webhook;  Webhook(secret).verify(raw_body, headers)

C# (.NET)

using System.Security.Cryptography;
using System.Text;

static bool VerifyWebhook(string secret, string id, string ts, string signatures, string rawBody)
{
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(ts)) > 300) return false;
    var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret.Substring(6) : secret);
    using var hmac = new HMACSHA256(key);
    var expected = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{id}.{ts}.{rawBody}")));
    foreach (var part in signatures.Split(' '))
    {
        var p = part.Split(',', 2);
        if (p.Length == 2 && p[0] == "v1" &&
            CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(p[1]), Encoding.ASCII.GetBytes(expected)))
            return true;
    }
    return false;
}

PHP

function verify_webhook(string $secret, string $id, string $ts, string $signatures, string $rawBody): bool {
    if (abs(time() - (int)$ts) > 300) return false;
    $key = base64_decode(preg_replace('/^whsec_/', '', $secret));
    $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$rawBody", $key, true));
    foreach (explode(' ', $signatures) as $part) {
        [$version, $sig] = array_pad(explode(',', $part, 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $sig)) return true;
    }
    return false;
}
// $rawBody = file_get_contents('php://input');

Delivery and retries

A message counts as delivered when your endpoint answers with a 2xx status within 15 seconds. Otherwise it is sent again with the same webhook-id:

AttemptWhenNote
1At once, a few seconds after the submissionRedirects are not followed.
21 minute later
35 minutes later
430 minutes later
52 hours later
66 hours later
712 hours laterAbout 21 hours after the submission. Then the failure e-mail goes out and the message is kept for 7 days for the administrator.
–Answer 410 GoneNo further attempts for this message.

Managing webhooks

All paths are relative to /api/v1 and need the role admin. PATCH and DELETE also work as POST with the header X-HTTP-Method-Override.

MethodPathWhat it does
GET/forms/{formId}/webhooksThe webhooks of a form, with the result of the last delivery.
POST/forms/{formId}/webhooksCreate a webhook; the answer contains the secret.
GET/forms/{formId}/webhooks/{webhookId}One webhook.
PATCH/forms/{formId}/webhooks/{webhookId}Change address, description, includePdf, failureEmails or switch it off with "active": false (messages still waiting are dropped).
DELETE/forms/{formId}/webhooks/{webhookId}Delete it; waiting messages are dropped.
POST/forms/{formId}/webhooks/{webhookId}/testSend webhook.test now and show the answer.
POST/forms/{formId}/webhooks/{webhookId}/rotate-secretNew secret.
GET/usageMessages waiting and failed messages of the last 7 days (admins).

Security and privacy

  • HTTPS only, with certificate check. Addresses that point to the form server itself or to private networks (10.x, 192.168.x, 172.16–31.x, link-local, unique local IPv6) are refused, also when a host name changes its address later. An administrator can allow internal receivers (setting Webhooks → Allow addresses in private networks).
  • Only admins of a form's group can add webhooks, because a webhook receives every value of every submission.
  • Secrets are stored encrypted with the Windows data protection of the server (DPAPI). After moving the server to another machine, rotate them.
  • Data on the server: a message stays on the server only until it is delivered. Messages that fail all attempts are deleted after 7 days (adjustable). Apart from that, the server keeps no copy of submissions by default.
  • Each message is an ordinary HTTPS request from your form server, so your firewall can allow its address. For a hosted server, ask us for the address.

Questions and answers

What is a form webhook?

An HTTPS request the form server sends to your system each time a form is submitted. It carries the submitted values as JSON (and optionally the PDF), so your CRM, ERP or workflow receives the data without anyone copying it from an e-mail.

How do I know a webhook really comes from my form server?

Every message is signed with HMAC-SHA256 and a secret only you and the form server know, following the Standard Webhooks specification. Check the webhook-signature header as shown on this page or with a Standard Webhooks library, and reject messages older than five minutes.

What happens if my system is down?

The message is retried after 1 minute, 5 minutes, 30 minutes, 2, 6 and 12 hours, with the same webhook-id. If all seven attempts fail, the failure e-mail addresses of the webhook are told and the message is kept for 7 days.

Can I receive the PDF too?

Yes. With "includePdf": true each message carries the submitted, sealed PDF as Base64, up to 10 MB.

Do submissions through the API trigger webhooks?

Yes. Every real submission does: from the browser, from the PDF in Adobe Reader and through the API. Filling a PDF with /fill or Save in the browser does not.

Can I use webhooks with Zapier, Make or Power Automate?

Yes. Use the catch-hook or HTTP trigger URL of the flow as the webhook address. If the tool cannot check signatures, at least use a long, secret URL and check the webhook-timestamp.

How many webhooks can a form have?

Up to 10 by default; the administrator can change the limit.

Connect your forms

Ask for test credentials and connect a form of our online server to your own system.

Last updated: