Skip to content

Webhooks

A webhook sends a POST to your own URL for every submission. Use it to push leads into a CRM, a Slack channel, a spreadsheet, or any system that can accept an HTTP request.

Webhooks live in the form’s config as an array. Each one needs an https URL, an active flag, and a secret:

"webhooks": [
{
"url": "https://hooks.example.com/octaforms",
"active": true,
"secret": "a-long-random-secret-at-least-32-chars"
}
]

A form can have several. The secret must be at least 32 characters, because it signs the payload and a short secret is guessable. The editor blocks saving an active webhook with a weak or missing secret.

Each webhook receives a JSON body:

{
"id": 123,
"form_id": 45,
"created": "2026-07-08T10:15:00Z",
"fields": { "name": "Jan", "email": "jan@example.com" },
"meta": { "url": "https://example.com/contact" }
}

File fields appear as metadata only, never the file itself:

"cv": [{ "name": "resume.pdf", "mime": "application/pdf", "size": 84213 }]

The bytes are left out on purpose. Base64-encoding a file would bloat the signed payload, and a receiving machine has no WordPress session to fetch a file with. The actual file goes to you as an email attachment and is available in the admin.

Every webhook request carries a signature header:

X-Octa-Forms-Signature: sha256=<hmac_sha256(secret, body)>

Verify it against the raw request body, using the webhook’s secret. If it does not match, reject the request. This is what stops anyone else from posting fake submissions to your endpoint.

Delivery is at-least-once. Under retries, the same webhook can arrive more than once. Your endpoint must deduplicate using the payload’s id, which is stable for a given submission. Treat id as an idempotency key: if you have already processed it, acknowledge and move on.

When you fire a webhook from the admin test panel, the payload carries "test": true and a placeholder id like test-<uuid>, so your endpoint can tell a test apart from a real submission.

Outgoing webhook URLs are re-checked for safety before every send, but the gap between resolving the hostname and connecting is not pinned to the resolved address. This is documented for completeness. For normal endpoints you control, it does not affect you.