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:
JSON (flat fields) is sent as application/json:
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:
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.phpandSubmissionContextResolver.php- how a request is classified as a test, and why a client flag cannot influence it.src/Model/Submission/FormSubmissionSettings.phpandFormSubmissionSettingsReader.php- the entity-level settings the dispatcher reads.
src/Controller/Submit/Index.php- where the dispatch happens relative to the flushed response.
Related Topics
- Architecture - where the dispatcher sits in the submission pipeline.
- Create a Custom Form Root - adding root-level data to an outgoing payload.
- Testing a Form - the merchant-side guide to test submissions.
- Sending and Recipients - the settings that drive delivery.