Transports
Transports
A transport is a destination the background worker delivers a submission to. Every form carries its own transport configuration in form_configs.features.transport, an object keyed by backend.
All keys are optional. A backend only runs when its key is present and enabled is not false:
| Key | Backend | State |
|---|---|---|
email | Email through Sender.io, Brevo or SMTP | Supported |
google_sheet | Google Sheets (appends one row) | Supported — needs server credentials |
twenty | Twenty CRM (Person, optional Company and Note) | Supported — needs the project API key |
telegram | Telegram | Not implemented (stub) |
whatsapp | Not implemented (stub) |
Those five keys are the complete list the CLI accepts. Any other key is rejected.
Setting the configuration
formic form set-transport stores the transport object of one form:
formic form set-transport contact-form '{"email":{"to":"ops@example.com","enabled":true}}'
The command prints the stored features.transport object back:
✅ Transport configuration set for form 'contact-form'
Effective features.transport:
{
"email": {
"to": "ops@example.com",
"enabled": true
}
}
Behaviour to keep in mind:
- The fragment replaces
features.transportas a whole. It does not merge with what was there before, so always send the complete object. Other entries underfeaturesare preserved. - The fragment is validated before it is stored: malformed JSON, a non-object value, an unknown backend key, or a field of the wrong type aborts the command and leaves the stored configuration untouched.
enableddefaults totruein every backend ({"twenty":{}}means run Twenty).- A form that can be delivered to only one thing is one where the other keys are simply absent.
A form with email and Twenty CRM:
{
"email": {
"to": "ops@example.com",
"enabled": true
},
"twenty": {
"enabled": true,
"email_field": "email",
"name_field": "name",
"phone_field": "phone",
"company_name_field": "company",
"note_body_field": "message"
}
}
| Setting | Type | Required | Description |
|---|---|---|---|
provider | string | No | Preferred provider: sender, brevo or smtp. Empty or unknown keeps the server order |
to | string | Yes | Recipient address — exactly one |
enabled | boolean | No | Default true |
{
"email": {
"provider": "brevo",
"to": "ops@example.com",
"enabled": true
}
}
The message is built by the server
There is no template configuration and no per-form subject today:
- The subject is always
New submission for: <form name>. - The body is the HTML rendering of the submission fields, plus the submission ID and the reception timestamp.
- The sender name and address come from the server environment, never from the form.
Provider selection
The server builds a provider chain from its environment, in this order:
- Sender.io —
SENDER_API_KEY(SENDER_FROM_EMAIL,SENDER_FROM_NAME) - Brevo —
BREVO_API_KEY(BREVO_FROM_EMAIL,BREVO_FROM_NAME) - SMTP —
SMTP_HOST(SMTP_PORT,SMTP_USER,SMTP_PASSWORD,SMTP_FROM_EMAIL,SMTP_FROM_NAME,SMTP_TLS)
provider moves the named provider to the front of that chain and keeps the rest as fallback: if the preferred provider fails, the next configured provider is tried. An empty provider (the default) sends through the first provider that succeeds, which is what formic form show prints as Email Provider. Start-up fails when no provider at all is configured unless EMAIL_DRY_RUN=true.
to is a single address and is passed to the provider as a single recipient — a comma-separated list is not expanded. To notify several mailboxes, use a distribution list or add another backend.
Google Sheets
Appends one row per submission to an existing spreadsheet.
| Setting | Type | Required | Description |
|---|---|---|---|
spreadsheet_id | string | Yes | Google Sheets document ID |
sheet_name | string | Yes | Tab name the row is appended to (no default — an empty value makes the API call fail) |
enabled | boolean | No | Default true |
{
"google_sheet": {
"spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
"sheet_name": "Leads",
"enabled": true
}
}
Credentials are server-side: FORMIC_GOOGLE_SERVICE_ACCOUNT holds the service account JSON. The spreadsheet must be shared with that account’s address. When the variable is missing, the backend is not built at all: the server logs an error and the submission is delivered to the remaining backends only.
The tab must already exist and its first row must hold the field names — the row is appended under the header row.
Twenty CRM
Creates (or reuses) a Person from the submission, optionally with a Company and a Note.
| Setting | Type | Required | Description |
|---|---|---|---|
enabled | boolean | No | Default true |
name_field | string | No | Submission field holding the full name (default name), split into first and last name |
email_field | string | No | Submission field holding the email (default email). It is the identity and de-duplication key: a submission without it fails permanently |
phone_field | string | No | Submission field holding the phone number |
job_title_field | string | No | Submission field holding the job title |
company_name_field | string | No | Submission field holding the company name (B2B). When present, a Company is created or linked and set on the Person |
note_body_field | string | No | Submission field holding the note body (the message). When present, a Note is created and linked to the Person and Company |
note_title_field | string | No | Submission field holding the note title. Defaults to Nuevo lead: <form slug> |
{
"twenty": {
"enabled": true,
"email_field": "email",
"name_field": "name",
"phone_field": "phone",
"job_title_field": "position",
"company_name_field": "company",
"note_body_field": "message",
"note_title_field": "subject"
}
}
Access is per project: the form’s project must have a Twenty API key. The GraphQL endpoint is shared and comes from FORMIC_TWENTY_URL (default https://crm.audelabs.com/graphql). Without a project key the backend is not built.
Telegram and WhatsApp — not implemented
Both backends are accepted by the configuration validator but their transports are stubs: sending returns not implemented yet and formic status reports their health check as not implemented.
{
"telegram": {
"chat_id": "-1001234567890",
"enabled": true
}
}
| Setting | Type | Required | Description |
|---|---|---|---|
telegram.chat_id | string | Yes | Chat the message would be sent to — accepted and stored, never read |
whatsapp.phone_number | string | Yes | Number the message would be sent to — accepted and stored, never read |
Nothing reads those values yet — in particular there is no bot token in the form configuration; a credential like that belongs to the server environment, not to a per-form setting.
Until the implementations land, do not enable these keys on a live form: the backend is built and counted as a delivery target, and every submission fails against it. When it is the form’s only backend the submission is retried up to the limit and ends failed; when the form has other backends that succeed, the submission can still be reported sent without that failure showing up in error_message — the partial-success accounting below does not count Telegram or WhatsApp. Leave them out of features.transport.
Forms without features.transport
A form never touched by set-transport keeps the pre-CLI behaviour: when features.transport.email is absent and the form has a destination email (formic form create <slug> <email> <turnstile-key>), the server synthesises the email backend from the form’s destination_email and email_provider columns. That is why formic form show still prints Destination Email. Setting a transport object overrides it.
Delivery semantics
- The worker polls the queue (5 s interval) and processes submissions in batches.
- Status flow:
pending→processing→sentorfailed. - Every backend that was built is attempted for every submission. A submission where all of them succeed is
sent. - Partial delivery is reported as
sentwith anerror_messagenaming the last failure (for examplePartial success, last error: ...); check that field when a submission looks successful but a backend failed. - Failures are classified from the error itself: a permanent one (4xx, validation, missing field) marks the submission
failedimmediately; a transient one (5xx, network) returns it to the queue, up to the retry limit (3 by default). - The backend health checks behind
formic statusare live calls (authenticated Twenty read, provider API check, Sheets/SMTP), not just a “configured” flag.
Not implemented yet: project defaults and a summary step
OpenSpec change 2026-09-13-transport-inheritance-and-summary specifies the next step for this page and is not implemented in the current release. When it lands:
- transport configuration may be set as a project default and inherited by the project’s forms, resolved with the precedence form → project → legacy columns, per backend and whole-object (no field-by-field merging), with provenance (
form,project:<name>,legacy) visible from the CLI; - backends gain a role (
delivery,summary) so a pipeline can end with a narrated, signed summary that reports what happened in the previous steps; - the accounting of a submission follows the backends actually built, and the partial-success message is reworked.
The tracking issue is Audelabs/Formic#1. This page will be updated when the change ships.
Configured settings on this page are written against apps/formic-server/src/transport/mod.rs and the validation in formic form set-transport; last checked against commit 424dc98.