Boarding Form Webhook Payloads
Payload reference for the five form.* lifecycle events, from creation through completion or expiration.
Boarding form webhooks track a merchant's progress through onboarding in near real time — when a form is created, when the merchant first saves progress, every time they save, when they submit, and when an unfinished form lapses.
Together they replace polling the Forms API to discover state changes, and they make drop-off visible: you can see which merchants opened a form and stopped.
| Event | Fires when | Times per form |
|---|---|---|
form.created | A boarding form is created. | Once |
form.started | The merchant completes a section of the form for the first time. | Once |
form.updated | Any change is saved to the form. | Once per save |
form.completed | The form is submitted and its completion recorded. | Once |
form.expired | An unfinished form passes its expiration time. | Once |
For creating and configuring the forms themselves, see Boarding Forms.
Lifecycle
flowchart TD
A["Form created"] -->|form.created| B["Status: ACTIVE"]
B --> C{"Merchant saves<br/>first section?"}
C -->|"Yes — first save only"| D["form.started<br/>+ form.updated"]
C -->|"Never — deadline passes"| E["form.expired<br/>Status: EXPIRED"]
D --> F{"More saves?"}
F -->|"Each save"| G["form.updated"]
G --> F
F -->|"Submitted"| H["form.completed<br/>+ form.updated<br/>Status: COMPLETED"]
F -->|"Abandoned past deadline"| E
form.updatedfires alongside the other events, not instead of themEvery save emits
form.updated, and that includes the save that first records progress and the save that completes the form. So the first save delivers bothform.startedandform.updated, and submission delivers bothform.completedandform.updated.If you subscribe to
form.updatedtogether with the others, expect two notifications for those moments. Branch oneventTyperather than assuming one notification per state change.
Payload
All five events share one envelope. data carries identifiers, status, and timestamps — plus the contact block for the person filling out the form, when one has been captured.
{
"id": "what_test_6knw5fhtys9egrxtd6bt0txjhj",
"webhookId": "wh_test_57vn6accfv9hrb5kqehv3k47tx",
"eventType": "form.completed",
"data": {
"formId": "form_test_3r6j69gwvm81gapafn6c0dtmg2",
"merchantId": "mid_test_wsqgngrn99gptndb1h2s70dzj",
"status": "COMPLETED",
"lastSectionCompleted": "banking",
"completionDate": "2026-07-18T09:41:55Z",
"modifiedOn": "2026-07-18T09:41:55Z",
"personCompleting": {
"firstName": "Jane",
"lastName": "Roe",
"email": "[email protected]",
"phone": "+15125550143",
"title": "CFO",
"agreedOn": "2026-07-18T09:41:52Z",
"ipAddress": "203.0.113.42"
}
}
}
Form contents are never sentThe payload carries identifiers, status values, and timestamps only — never the business, owner, banking, or document data captured by the form, and never the form template. To read the submitted data, fetch the form or the merchant through the API once you receive the notification.
Fields by event
Fields with no value are omitted rather than sent as null, so the exact key set depends on the event.
| Field | created | started | updated | completed | expired |
|---|---|---|---|---|---|
formId | ✅ | ✅ | ✅ | ✅ | ✅ |
merchantId | ✅ | ✅ | ✅ | ✅ | ✅ |
status | ✅ | ✅ | ✅ | ✅ | ✅ |
createdOn | ✅ | ||||
expiresOn | ✅ | ✅ | |||
lastSectionCompleted | ✅ | ✅ | ✅ | ||
completionDate | ✅ | ||||
modifiedOn | ✅ | ✅ | ✅ | ||
personCompleting | ◻️ | ◻️ | ◻️ | ◻️ | ◻️ |
✅ always present ◻️ present only once captured
| Field | Description |
|---|---|
formId | The boarding form's ID. Use this to fetch the form through the API. |
merchantId | The merchant the form belongs to, prefixed mid_. |
status | The form's lifecycle status: ACTIVE, COMPLETED, or EXPIRED. |
createdOn | When the form was created, ISO 8601 UTC. |
expiresOn | The form's expiration deadline, ISO 8601 UTC. |
lastSectionCompleted | The most recent section the merchant finished. Use it to gauge progress. |
completionDate | When the form was submitted, ISO 8601 UTC. |
modifiedOn | When this save occurred, ISO 8601 UTC. Use it to discard stale notifications. |
personCompleting | The contact filling out the form. See below. |
personCompleting
personCompletingPresent once the person completing the form has identified themselves. Every field within it is optional, and the block is omitted entirely if no values have been captured yet — so a form.created notification usually has no personCompleting, while a form.completed normally does.
| Field | Description |
|---|---|
firstName, lastName | The person's name. |
email | Their email address. |
phone | Their phone number, in E.164 format. |
title | Their role at the business, such as CFO. |
agreedOn | When they accepted the platform agreement, ISO 8601 UTC. Recorded by Preczn, never supplied by the client. |
ipAddress | The IP address the agreement was accepted from. Recorded by Preczn, never supplied by the client. |
agreedOn and ipAddress are stamped server-side, which is what makes them usable as evidence of acceptance. See Welcome Section for how the agreement is presented.
Event semantics
form.started means first saved progress
form.started means first saved progressform.started fires when the merchant completes a section of the form for the first time — not when they open the link or view the first page. A merchant who opens a form and closes it without saving anything will never produce form.started.
This makes it a meaningful engagement signal rather than a page-view counter: a form with form.created but no form.started was never genuinely begun, which is a different re-engagement problem from one abandoned halfway through.
form.expired is an hourly sweep
form.expired is an hourly sweepExpiration is detected by a job that runs hourly, so form.expired arrives within about an hour of the deadline in expiresOn rather than exactly at it. Every other form event is emitted as it happens.
The sweep only considers forms whose status is ACTIVE. Two consequences: a form completed before its deadline is not eligible and will never emit form.expired even after expiresOn passes, and a form carrying no status value at all is never swept.
expiresOnis fixed when the form is createdThe deadline is calculated at creation from the form's expiration window and then frozen. Editing the expiration setting on a form template afterwards does not move the deadline on forms that already exist — those keep the
expiresOnthey were created with.
Delivery guarantees
form.created, form.started, form.completed, and form.expired are each designed to fire exactly once per form. form.updated fires once per save and is intentionally not once-only.
As with every Preczn webhook, this is not a substitute for idempotency in your handler — a retry can redeliver any notification. Deduplicate on the top-level id, key your updates off formId, and compare modifiedOn against your stored state. See Receiving and Verifying Webhooks.
FAQ
Which events should I subscribe to?
It depends on what you are building.
For activating merchants automatically, form.completed alone is usually enough — it is the signal that the data is in and you can trigger your next step.
For re-engagement, add form.created and form.expired. A form created but never started, or one that expired, is a merchant worth emailing.
For a live progress indicator, add form.started and form.updated and read lastSectionCompleted. Be aware form.updated is the chattiest event, since it fires on every save.
Why did I get two notifications when the merchant submitted the form?
Because submission is also a save. It emits form.completed and form.updated, and both are delivered if you subscribe to both. The same applies to the first save, which emits form.started and form.updated.
This is deliberate — form.updated means "the form changed", and those moments are changes. Branch on eventType and ignore the form.updated when you have already handled the more specific event.
How many form.updated notifications should I expect?
One per save, which during active form-filling can be many — merchants save as they work through sections. There is no batching or debouncing.
If that volume is more than you need, subscribe to form.started and form.completed instead and skip form.updated entirely. Only subscribe to it if you genuinely need to react to intermediate saves.
Can I get the data the merchant entered from the webhook?
No — payloads deliberately exclude all form contents. Use the formId or merchantId to fetch the submitted data through the API once you receive the notification. This keeps sensitive onboarding data out of webhook traffic and delivery history, where it would otherwise sit in the clear.
Does form.completed mean the merchant can start processing?
No. It means the form was submitted, not that the processor has approved the merchant. Approval is a separate, asynchronous step by the downstream processor.
Watch connections[].status on merchant.updated for the connection reaching a ready state — see Merchant Webhook Payloads and Merchant Lifecycle.
Do I get an event when a merchant opens the form link?
No. The earliest engagement signal is form.started, which requires the merchant to save a section. There is no page-view or link-open event.
What happens if a merchant completes a form after it expired?
They cannot — an expired form is no longer fillable, and the merchant is directed to the form's expiration URL. Issue a new form to let them continue.
Note that the reverse race is handled: because the sweep only looks at forms still ACTIVE, a form completed just before its deadline will not subsequently be marked expired.
Do forms scoped to multiple connections emit one set of events or several?
One set. The events describe the form, not the connections attached to it, so a form covering several processor connections still emits a single form.created, form.completed, and so on. See Multi-Connection Boarding Forms.
Updated about 1 hour ago
