Reference
Complete reference documentation for Webform Relay configuration, the HTTP API, the CLI, and the Go source packages.
Config Schema
Config is a YAML file stored in S3. The Lambda reads CONFIG_BUCKET and
CONFIG_KEY from environment variables, caches the parsed config for CACHE_TTL
(default 30s), and re-fetches when the TTL expires.
cache_ttl: 30s # how long to cache config in memory (parsed as Go duration)
forms:
<form-name>:
fields:
- name: <string> # field key in the submitted form body
required: true|false # default false
type: text|email|textarea|hidden # default "text"
outputs:
# HTTP POST to any endpoint
- type: http_post
url: https://...
headers:
Authorization: "Bearer token"
# Amazon SES email (requires SES_FROM_ADDRESS env var)
- type: email
to: recipient@example.com
subject: "Subject line"
# Salesforce Web2Lead
- type: salesforce_web2lead
url: https://webto.salesforce.com/servlet/servlet.WebToLead?encoding=UTF-8
# SMTP email (no AWS dependency)
- type: smtp
host: smtp.example.com
port: "587" # optional; default 587; 465 = implicit TLS
username: user@example.com
password: "secret"
to: recipient@example.com
subject: "Subject line"
# all output types support:
on_error: fail|continue # default fail; see below
mapping:
<output-key>: <input-field-name> # rename/select input fields
static:
<output-key>: <literal-value> # inject fixed valuesField types
| Type | HTML element | Validation |
|---|---|---|
text | <input type="text"> | None beyond required/length |
email | <input type="email"> | Must contain exactly one @ with chars on each side |
textarea | <textarea> | None beyond required/length |
hidden | <input type="hidden"> | None; value comes from the form body like any other field |
All field values are capped at 10 000 bytes.
The parser also recognizes a richer field object — more types
(url, tel, number, date, select, radios, checkboxes, checkbox),
common presentational properties, and type-specific properties — plus strict
parsing, structural validation, constraint enforcement, and accessible HTML
rendering of that object. See the Field Model reference for
the full vocabulary and rules, including Rendered HTML
for how each type and property renders.
Output types
| Type | Protocol | Required fields |
|---|---|---|
http_post | application/x-www-form-urlencoded POST | url |
email | Amazon SES | to |
salesforce_web2lead | URL-encoded POST to Salesforce Web2Lead | url |
smtp | SMTP (plain, STARTTLS, or SMTPS) | host, to |
Multiple outputs per form are attempted on every submission (fan-out).
Successful outputs are never rolled back. Whether a failed output fails the
whole submission depends on that output’s on_error policy (below).
on_error — partial-failure policy
Every output type accepts an optional on_error key controlling what happens
when that output’s delivery fails:
| Value | Behavior on delivery failure |
|---|---|
fail (default) | The submission fails: the endpoint returns HTTP 500. This is the historical, all-or-nothing behavior — omitting on_error keeps it. |
continue | The failure is logged and swallowed; other outputs still run and, if none of the fail-policy outputs failed, the endpoint returns 200. Use for best-effort, non-critical destinations (an analytics webhook, a secondary CRM) that must not block the primary output. |
outputs:
- type: email # critical: default on_error: fail
to: team@example.com
- type: http_post # best-effort: a failure here won't 500
on_error: continue
url: https://analytics.example.com/collectNotes:
on_erroris a per-output delivery policy. It is unrelated to field-levelrequiredand to input validation — a missing required field or a wrong-typed value is a user error and always returns 422, never 500, regardless of any output’son_error.- An unknown/unregistered output type is a configuration error, not a
delivery failure, and always surfaces (500) even under
on_error: continue. - Only
failandcontinueare accepted; any other value is rejected at config-parse time with an error naming the form and output.
http_post
Posts the mapped data to an arbitrary HTTP endpoint as
application/x-www-form-urlencoded. Any response with a 4xx or 5xx status
is treated as an error. Custom request headers can be injected via the
headers map (useful for API keys or Authorization tokens). Content-Type
is always set to application/x-www-form-urlencoded and cannot be overridden.
outputs:
- type: http_post
url: https://hooks.example.com/form
headers:
Authorization: "Bearer <token>"
mapping:
name: name
email: emailemail
Sends a plain-text email via Amazon SES. The body is one key=value line per
mapped field, sorted alphabetically. Requires the SES_FROM_ADDRESS
environment variable to be set to a verified SES sender address.
outputs:
- type: email
to: team@example.com
subject: "New submission"
mapping:
from_name: name
from_email: email
body: messagesalesforce_web2lead
Posts to the Salesforce Web2Lead endpoint as URL-encoded form data. Functionally
identical to http_post but uses the salesforce_web2lead type string for
clarity and future extensibility. Requires url pointing to the Web2Lead
servlet. Use mapping to rename your form fields to Salesforce field names and
static to inject fields that are not part of the submitted form (org ID,
redirect URL, lead source).
outputs:
- type: salesforce_web2lead
url: https://webto.salesforce.com/servlet/servlet.WebToLead?encoding=UTF-8
mapping:
last_name: name
email: email
static:
oid: "00Dxxxxxxxxxxxxxxx"
retURL: "https://yoursite.com/thank-you"
lead_source: Websitesmtp
Sends a plain-text email directly over SMTP — no AWS dependency. TLS behaviour is port-driven:
| Port | TLS |
|---|---|
465 | Implicit TLS (SMTPS) — TLS from the first byte |
587 (default) | STARTTLS — upgrades if the server advertises it |
| Any other | STARTTLS if advertised; plain-text otherwise |
Authentication uses SMTP PLAIN when username is set. The envelope sender
is webform-relay@<host>. The email body is one key=value line per mapped
field, sorted alphabetically.
outputs:
- type: smtp
host: smtp.example.com
port: "587" # optional; default 587
username: relay@example.com
password: "s3cr3t" # store in a secret; see Security note below
to: inbox@example.com
subject: "New form submission"
mapping:
from_name: name
from_email: email
body: messageSecurity: username and password are stored in the config YAML in S3.
Restrict access to the config bucket via IAM, enable S3 server-side encryption,
and rotate credentials regularly. Do not commit credentials to source control.
Spam prevention
Each form may declare an optional spam: block. Today it supports a single
honeypot trap:
forms:
contact:
spam:
honeypot: website # a decoy field name; real users leave it empty
fields:
- name: email
type: email
required: true
- name: message
type: textarea
outputs:
- type: email
to: team@example.comRender the honeypot field on your page as a visually hidden, non-tabindex
input that a human never sees or fills (e.g. <input name="website" autocomplete="off" tabindex="-1" style="position:absolute;left:-9999px">). Bots
that fill every field will populate it.
Behavior on submit:
- If the honeypot field arrives with any non-empty value, the submission is
silently dropped: the endpoint returns the same
200 {"message":"ok"}as a real success (so a bot can’t tell it was caught) and nothing is relayed. - The honeypot field is always stripped from the submission before validation, mapping, and relay, so it never reaches an output on the normal path either.
- The honeypot name must not also be a declared
fields:entry — that collision is rejected at config-parse time (it would strip a real field).
The honeypot is a cheap first filter; combine it with captcha: and the
min-fill token (below) for stronger bot resistance.
Min-fill signed token
A stateless anti-automation check that rejects submissions completing faster than
a human plausibly could. When a form loads, the server issues an HMAC-signed,
form-bound timestamp; on submit it checks that at least min_fill_seconds have
elapsed. The signature makes the timestamp impossible for a client to forge or
backdate, and nothing is stored server-side.
forms:
contact:
spam:
min_fill_seconds: 3 # >0 enables the check; reject faster submissions
form_ttl: 2h # token max age (default 2h); Go duration string
token_field: _ts # hidden field carrying the token (default "_ts")
fields: [ ... ]
outputs: [ ... ]- Signing secret: set the
FORM_TOKEN_SECRETenvironment variable (theFormTokenSecretSAM parameter). It is never read from the S3 config. A form that enablesmin_fill_secondswhile the secret is empty fails closed — the endpoints return500rather than accepting an unverified submission. form_ttlmust exceedmin_fill_seconds;token_fieldmust not collide with a declared field. Both are enforced at config-parse time.
Issuing the token (htmx). The relay mints a fresh token per form load through two GET endpoints — use whichever fits:
<!-- Endpoint A: the relay renders the whole form, token already injected -->
<div hx-get="https://<relay-host>/api/v1/form/contact"
hx-trigger="load" hx-swap="innerHTML"></div>
<!-- Endpoint B: your page owns the form; fetch just the token input -->
<form hx-post="https://<relay-host>/api/v1/submit/contact" hx-swap="outerHTML">
<input hx-get="https://<relay-host>/api/v1/token/contact"
hx-trigger="load" hx-swap="outerHTML">
<!-- your fields … -->
</form>On submit, the token field is stripped (it never reaches validation, mapping, or an output), then:
| Condition | Response |
|---|---|
valid token, min_fill_seconds ≤ elapsed ≤ form_ttl | proceeds normally |
elapsed < min_fill_seconds, or a missing / forged / bad-signature token | silent 200 {"message":"ok"}, nothing relayed (opaque to bots) |
elapsed > form_ttl (a stale page) | 422 {"error":"form expired, please reload"} |
The token is not single-use — with no server state it can be replayed until
it expires, so form_ttl bounds the window and rate limiting caps volume within
it. It raises automation cost; it is not proof of a human. Layer it with the
honeypot and captcha:.
Captcha
A form may require a captcha token, verified server-side against the provider’s
API before the submission is relayed. Configure it in a captcha: block:
forms:
contact:
captcha:
provider: recaptcha_v3 # recaptcha_v2 | recaptcha_v3 | hcaptcha | turnstile
secret: "{{ env \"RECAPTCHA_SECRET\" }}"
min_score: 0.5 # recaptcha_v3 only: reject below this score (0.0–1.0)
field: g-recaptcha-response # optional; overrides the provider's default token field
fields: [ ... ]
outputs: [ ... ]| Key | Applies to | Description |
|---|---|---|
provider | all | recaptcha_v2, recaptcha_v3, hcaptcha, or turnstile |
secret | all | The provider’s server-side secret key (templatable — resolve from an env var rather than committing it) |
min_score | recaptcha_v3 | Minimum score to accept (0.0–1.0); submissions below it are rejected |
field | all | Overrides the submitted field name carrying the token (defaults per provider) |
The captcha token field is verified and then stripped from the submission
before validation, mapping, and relay. A failed or missing token returns 400.
Mapping and static values
mapping renames input fields: output-key: input-key copies input[input-key]
to output[output-key]. static always injects literal values (and overwrites
any mapping for the same key). Present-but-empty field values are omitted from
both mapping and static.
Multi-value fields
A field submitted more than once (checkboxes, multi-select) keeps every
value all the way through to each output; see
Multi-value fields in the Field Model
reference for the validation rules. Each output type serializes those values
differently:
| Output type | Serialization |
|---|---|
http_post, salesforce_web2lead | application/x-www-form-urlencoded body with the repeated key sent multiple times: interest=design&interest=engineering |
email (SES), smtp | key=value body lines, keys sorted; a multi-value field emits one line per value (e.g. interest=design then interest=engineering) |
Templating
Output values may contain {{ ... }} templates, rendered server-side at
submission time with Go’s standard-library text/template, a locked-down
scope, and a small function whitelist — no arbitrary code, no filesystem, no
network.
Templated positions: an output’s subject, to, each static value,
each headers value, and username/password. url is never templated
(kept literal) and mapping values are not templated — those are input
field-name references, not template strings. Every templated string is
parsed and checked at config load time; a template syntax error or a call to
a function outside the whitelist makes the config fail to load, not fail on
the next submission.
Scope available inside a template:
| Reference | Value |
|---|---|
.fields.<name> | The field’s submitted value(s), joined with , for a multi-value field; an absent field renders empty |
.form.id | The form name |
.submitted_at | UTC RFC3339 timestamp of the submission |
Functions (this is the complete whitelist — nothing else is available):
| Function | Usage | Purpose |
|---|---|---|
trim | {{ trim .fields.name }} | Trim leading/trailing whitespace |
lower | {{ lower .fields.email }} | Lowercase |
upper | {{ upper .fields.code }} | Uppercase |
default | {{ default "n/a" .fields.company }} | Fall back to a literal when the value is empty |
env | {{ env "CRM_TOKEN" }} | Look up a process/Lambda environment variable by name |
values | {{ values "interests" }} | Raw []string of a multi-value field’s submitted values |
join | {{ join "|" (values "interests") }} | Join a list (e.g. from values) with a separator |
Secrets: {{ env "NAME" }} is the canonical way to get a secret (API
token, credential) into an output value — set it as an environment variable
on the Lambda/process and reference it by name in the config. The
environment cannot be enumerated or dumped from a template: only a named
lookup via env "NAME" is possible, an unknown name renders empty rather
than erroring, and {{ .env }} always renders empty because the environment
is never placed into template scope as a value.
No HTML escaping: template output is substituted literally, with no HTML/JS escaping, because these outputs are email and webhook sinks, not HTML pages. (The public-facing form HTML generated for the CLI/handler is escaped separately and is unaffected by this.)
outputs:
- type: email
to: sales@example.com
subject: "New submission from {{ .fields.name }}"
mapping:
from_name: name
from_email: email
- type: http_post
url: https://hooks.example.com/form
headers:
X-CRM-Token: "{{ env \"CRM_TOKEN\" }}"
mapping:
name: name
email: emailHTTP API
POST /api/v1/submit/{form}
Request
- Content-Type:
application/x-www-form-urlencoded - Body: URL-encoded form fields
- Max body size: 64 KB
Responses
| Status | Meaning |
|---|---|
| 200 | Submission accepted; all fail-policy outputs relayed (any on_error: continue output that failed is logged, not surfaced) |
| 400 | Missing form name, wrong Content-Type, or malformed body |
| 404 | Form name not found in config |
| 422 | Validation error (missing required field, invalid email, etc.) |
| 500 | Config load failure, an unknown output type, or a fail-policy output failing to deliver |
By default all responses are application/json with {"message":"..."} or
{"error":"..."}. Requests from htmx get HTML fragments instead — see below.
Form-rendering endpoints (htmx)
Two GET endpoints server-render a form so it can be embedded and re-rendered
over htmx (they also mint a fresh min-fill token when the form uses one):
GET /api/v1/form/{form}— returns the whole form as an HTML fragment. Reads the current field values from the query string, so it repopulates and re-evaluates conditional visibility on each re-render.GET /api/v1/token/{form}— returns just the min-fill token as an<input type="hidden">fragment, for a page that owns its own form markup.
Both return text/html, 404 for an unknown form, and 500 if the form
enables min-fill but no FORM_TOKEN_SECRET is configured. See the
reveal-on-correct-entry how-to for an
end-to-end example.
Full OpenAPI spec: api-reference.html — or view
openapi.yaml.
htmx integration
When a submit request carries the HX-Request: true header (htmx sends it on
every request), the endpoint returns an HTML fragment to swap into the page
instead of JSON. Any other client keeps getting the JSON above, byte-for-byte —
existing consumers are unaffected.
| Outcome | htmx fragment |
|---|---|
| success (and honeypot / min-fill silent drops) | <div class="form-confirmation">…</div> — the form’s confirmation message (default Thank you.). Drops return the identical fragment, so a bot can’t tell it was caught |
| validation failure | the re-rendered form — the submitter’s values refilled, each failing field’s message inline (<p class="field-error">, wired via aria-invalid/aria-describedby), and a fresh min-fill token if the form uses one. Swap it back over the form (hx-swap="outerHTML") |
| min-fill expired / other errors | <ul class="form-errors"><li>…</li>…</ul> — the message(s), HTML-escaped |
The confirmation message is an optional per-form key:
forms:
contact:
confirmation: "Thanks — we'll be in touch."
fields: [ ... ]
outputs: [ ... ]Status codes are real — validation and min-fill-expired are 422, unknown
form is 404, etc. htmx only swaps 2xx by default, so to display error
fragments load the response-targets
extension and point hx-target-4* / hx-target-5* at an error container. A
typical form:
<form hx-post="https://<relay-host>/api/v1/submit/contact"
hx-target="#result" hx-target-error="#errors"
hx-ext="response-targets">
<!-- fields … -->
<div id="errors"></div>
</form>
<div id="result"></div>CLI
webform-relay [--config FILE] <command>
Commands:
form html <name> Print the HTML <form> element for the named form
form list List all form names in the config, one per line, sorted
config validate Parse the config file and print "ok" or the error
Flags:
--config FILE Path to config YAML (default: webform.yaml)
Also reads WEBFORM_RELAY_CONFIG env var.Build the CLI:
task build:cli # output: bin/webform-relayOr run without building:
task generate:form FORM=contact # prints form HTML to stdoutEnvironment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
CONFIG_BUCKET | If not using CONFIG_INLINE/CONFIG_FILE | — | S3 bucket containing the config YAML |
CONFIG_KEY | No | webform.yaml | S3 key for the config file |
CONFIG_INLINE | No | — | Raw YAML config string; takes precedence over S3. Intended for local development — pass via SAM --env-vars. |
CONFIG_FILE | No | — | Path to a local YAML config file; takes precedence over S3. Useful when running the binary directly without SAM. |
CACHE_TTL | No | 30s | Config cache TTL (Go duration string) |
SES_FROM_ADDRESS | For email outputs | — | Verified SES sender address (used as the From header for all SES emails) |
FORM_TOKEN_SECRET | For forms using spam.min_fill_seconds | — | HMAC signing secret for the min-fill anti-spam token. A form that enables min-fill without this fails closed (500). Kept out of the S3 config; set via the SAM parameter below |
Config source priority: CONFIG_INLINE → CONFIG_FILE → S3 (CONFIG_BUCKET / CONFIG_KEY).
SAM Template Parameters
Every parameter below is supplied by task deploy:app from the matching dotenv
key, so a deploy never prompts. FORM_TOKEN_SECRET is a secret and lives in the
gitignored .env (see .env.example); everything else lives in the committed
.env.project. See Getting Started.
| Parameter | dotenv key | Default | Description |
|---|---|---|---|
ConfigBucket | CONFIG_BUCKET | — | S3 bucket for the config file |
ConfigKey | CONFIG_KEY | webform.yaml | S3 key for the config file |
ConfigInline | — | "" | Raw YAML config (for local dev with --env-vars; leave empty in production) |
CacheTTL | CACHE_TTL | 30s | Config cache TTL |
SesFromAddress | SES_FROM_ADDRESS | "" | Verified SES sender address |
AllowedOrigins | ALLOWED_ORIGINS | "*" | CORS origin allowlist (comma-separated). Controls who may read the response from a browser; see Tuning CORS and throttling |
ThrottleRateLimit | THROTTLE_RATE_LIMIT | 10 | Steady-state requests/second cap for the whole HTTP API stage |
ThrottleBurstLimit | THROTTLE_BURST_LIMIT | 20 | Burst size allowed above ThrottleRateLimit |
FormTokenSecret | FORM_TOKEN_SECRET | "" | HMAC secret for the min-fill token (NoEcho). Required only if a form sets spam.min_fill_seconds. Omitted from the deploy when blank, so an existing stack keeps its current secret. Lives in .env, not .env.project |
Lambda Resources
- SubmitFunction — Go container image, x86_64, 128 MB, 25 s timeout
- Routes (all via the HTTP API) —
POST /api/v1/submit/{form}— accept a submissionGET /api/v1/form/{form}— server-render the form (with a fresh min-fill token when enabled); used by htmxGET /api/v1/token/{form}— return just the min-fill token as a hidden-input fragment
- IAM —
S3ReadPolicyonConfigBucket;SESCrudPolicyonSesFromAddress
Go Package Reference
Auto-generated from source with gomarkdoc:
Regenerate with task generate:apidocs.