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:

KeyBackendState
emailEmail through Sender.io, Brevo or SMTPSupported
google_sheetGoogle Sheets (appends one row)Supported — needs server credentials
twentyTwenty CRM (Person, optional Company and Note)Supported — needs the project API key
telegramTelegramNot implemented (stub)
whatsappWhatsAppNot 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.transport as a whole. It does not merge with what was there before, so always send the complete object. Other entries under features are 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.
  • enabled defaults to true in 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"
  }
}

Email

SettingTypeRequiredDescription
providerstringNoPreferred provider: sender, brevo or smtp. Empty or unknown keeps the server order
tostringYesRecipient address — exactly one
enabledbooleanNoDefault 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:

  1. Sender.io — SENDER_API_KEY (SENDER_FROM_EMAIL, SENDER_FROM_NAME)
  2. Brevo — BREVO_API_KEY (BREVO_FROM_EMAIL, BREVO_FROM_NAME)
  3. 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.

SettingTypeRequiredDescription
spreadsheet_idstringYesGoogle Sheets document ID
sheet_namestringYesTab name the row is appended to (no default — an empty value makes the API call fail)
enabledbooleanNoDefault 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.

SettingTypeRequiredDescription
enabledbooleanNoDefault true
name_fieldstringNoSubmission field holding the full name (default name), split into first and last name
email_fieldstringNoSubmission field holding the email (default email). It is the identity and de-duplication key: a submission without it fails permanently
phone_fieldstringNoSubmission field holding the phone number
job_title_fieldstringNoSubmission field holding the job title
company_name_fieldstringNoSubmission field holding the company name (B2B). When present, a Company is created or linked and set on the Person
note_body_fieldstringNoSubmission field holding the note body (the message). When present, a Note is created and linked to the Person and Company
note_title_fieldstringNoSubmission 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
  }
}
SettingTypeRequiredDescription
telegram.chat_idstringYesChat the message would be sent to — accepted and stored, never read
whatsapp.phone_numberstringYesNumber 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 → sent or failed.
  • Every backend that was built is attempted for every submission. A submission where all of them succeed is sent.
  • Partial delivery is reported as sent with an error_message naming the last failure (for example Partial 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 failed immediately; a transient one (5xx, network) returns it to the queue, up to the retry limit (3 by default).
  • The backend health checks behind formic status are 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.