Form integrations
A submission is saved first. Connectors run after that write commits. A connector failure is logged and retried; the respondent still sees the success message, because their submission is already stored.
FormDefinition.destination (see form-definition) names one catalog key, or
is null when the only target is an email notification. "Email the team" is a
flag on the form, notifyOnSubmission, and can run alongside a destination.
Catalog
Each destination is a manifest. Keys are stable and persisted; renaming one is a migration.
interface IntegrationManifest {
key: string; // "webhook", "klaviyo", "googleSheets"
name: string;
initials: string; // two letters on the tile
description: string;
category: "destination" | "analytics" | "notify";
canReceiveSubmissions: boolean;
fields: Array<{
name: string;
label: string;
type: "text" | "secret" | "multiline";
placeholder: string;
help: string;
required: boolean;
}>;
}
The form's destination picker lists entries with canReceiveSubmissions.
Analytics pixels and chat notifications live in the same catalog and stay out
of that picker.
A workable starting set, all ordinary destinations behind one interface:
| Key | Credentials | Effect |
|---|---|---|
webhook | URL, optional Authorization header | POST the submission JSON |
googleSheets | service account, spreadsheet id | append a row |
klaviyo | API key, list id | upsert a profile onto the list |
mailchimp | API key, audience id | subscribe, map merge fields |
hubspot | private app token | create or update a contact |
zapier | catch-hook URL | POST, and let Zapier route it |
A host-specific trigger (a commerce-platform flow, a CMS event) is another manifest and another module. It uses this seam.
Credentials
Credentials belong to a connection the account owns, not copied onto each form.
The form stores the connection id (or the destination key plus the connection).
secret fields are write-only: the UI shows a masked placeholder once set and
never returns the value to the client. multiline is for private keys.
Dispatch
interface SubmissionRecord {
id: string;
formId: string;
locale: string | null;
fields: Record<string, string | string[]>;
createdAt: string;
}
interface Connector {
key: string;
deliver(submission: SubmissionRecord, credentials: Record<string, string>): Promise<void>;
}
One module per key, registered in a map the dispatcher looks up. Adding a connector adds a manifest and a module; the submit handler stays as it is.
Order on POST /submit:
- Validate the body against the published definition.
- Insert the submission row.
- Respond to the embed.
- Deliver to the form's destination and send the notification email. Each call is independent. Record the outcome (pending, delivered, failed, next retry) on the submission.
Field keys in fields are the form's name values. A connector maps those
keys onto the vendor's properties at delivery time (sheet columns, profile
properties, contact fields). Leave the stored submission in the form's own
keys.
Email notification
The notification is the path that needs no third-party account: a message to the addresses on the form, with the submission rendered as labeled rows. It runs through the same post-commit dispatch, so a mail outage does not fail the submit response.
Pair with form-web-component for the request that creates the submission and
form-definition for the destination key and the warning when a form has
nowhere to send.