Skip to content

Webhook Deliveries

A form can mirror every successful submission to an HTTP endpoint. The destination and the body format are entity-level settings, edited in the Submission group of the Form Settings slide-over as Send Submissions To URL and Send Format. This page is the reference for whatever sits at the other end: what arrives, and how to tell a test submission from a customer's.

Delivery is fire-and-forget. WebhookDispatcher runs after the response has been flushed to the submitter, so a slow endpoint never holds up the form, and nothing is retried. A non-2xx response, a refused URL and a transport failure are all logged as warnings to var/log/hyva_cms.log; none of them changes what the submitter sees.

What Arrives at Your Endpoint

The dispatcher POSTs one request per submission, carrying the validator-clean field payload. Envelope keys, the form key and the honeypot field are already stripped, so the body holds exactly the fields declared in the published tree, keyed by Field Name.

Form-encoded (like a browser form) is sent as application/x-www-form-urlencoded:

name=Jane+Smith&email=jane%40example.com

JSON (flat fields) is sent as application/json:

{"name": "Jane Smith", "email": "jane@example.com"}

JSON (with form context) is also application/json, carrying the same fields under fields plus enough context to tell submissions apart:

{
  "form_identifier": "contact_us",
  "store_id": 1,
  "submitted_at": "2026-08-06T10:15:00+00:00",
  "fields": {
    "name": "Jane Smith",
    "email": "jane@example.com"
  }
}

A Multi-Choice Field submits several values, so it arrives as a JSON array in either JSON format and as repeated field[]=... pairs in the form-encoded one.

Only http and https URLs are dispatched. Localhost, loopback and otherwise reserved IP literals are refused, so a receiver has to be reachable by name or public address.

Telling a Test Submission Apart

Submitting from a preview sends for real. The submission is marked as a test on the way out, and the marking is decided on the server from the preview link's signature, so a client cannot set or clear it.

Every test delivery carries an HTTP header, whatever the body format:

X-Hyva-Form-Test: 1

The JSON (with form context) format additionally carries test in the body, between submitted_at and fields:

{
  "form_identifier": "contact_us",
  "store_id": 1,
  "submitted_at": "2026-08-06T10:15:00+00:00",
  "test": true,
  "fields": {
    "name": "Jane Smith",
    "email": "jane@example.com"
  }
}

The key is absent on a live submission rather than false, so an envelope from the storefront stays byte-identical to what an existing receiver integrated against.

The flat formats rely on the header alone

Form-encoded and JSON (flat fields) bodies are exactly the submitted fields and gain nothing on a test submission. There is no body flag to read, and adding one would collide with a merchant's own field named test. Check the header. It is the only marker present in all three formats, so checking it is also the right thing to do for the envelope format.

A receiver that acts on submissions, a CRM, an automation, a mailing list, should drop or quarantine anything carrying the header before merchants start testing. See Testing a form for what a merchant is doing when those deliveries arrive.

Extending the Dispatcher

The webhook dispatcher has two extension seams, and both have to preserve the test marking.

WebhookDispatcher::dispatch() is the whole delivery, and takes the resolved submission context as its last argument:

public function dispatch(
    FormSubmissionSettings $settings,
    string $formIdentifier,
    array $submittedFields,
    SubmissionContext $context
): void

The destination URL and the format come off $settings, built from the form entity by FormSubmissionSettingsReader. Whether the submission is a test comes off $context->isTest(). A plugin that stamps extra data onto an outgoing payload wraps this method; one that suppresses the header or drops the context argument breaks the guarantee that a test is identifiable downstream.

WebhookDispatcher::buildBody() is protected so a subclass can add a format or reshape an existing one:

protected function buildBody(
    string $format,
    string $formIdentifier,
    array $submittedFields,
    bool $isTest = false
): array

It returns [$body, $contentType]. A bespoke format that carries no test field of its own still gets the header, because the header is added by dispatch(), not here.

The mailer takes the same argument

SubmissionMailer::dispatch() also ends in a required SubmissionContext, and uses it to prefix [test] onto the rendered subject. Both dispatch() methods take the context as a required argument on purpose: an unmarked test send is the failure the marking exists to prevent, so a caller that forgets gets a TypeError rather than a quietly unmarked email.

Reference Implementation

In the module-form-builder package:

  • src/Model/Submission/WebhookDispatcher.php - the three body formats, the test header, the URL guard and the concurrency cap.
  • src/Model/Submission/SubmissionContext.php and SubmissionContextResolver.php - how a request is classified as a test, and why a client flag cannot influence it.
  • src/Model/Submission/FormSubmissionSettings.php and FormSubmissionSettingsReader.php
  • the entity-level settings the dispatcher reads.
  • src/Controller/Submit/Index.php - where the dispatch happens relative to the flushed response.