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 values

Field types

TypeHTML elementValidation
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

TypeProtocolRequired fields
http_postapplication/x-www-form-urlencoded POSTurl
emailAmazon SESto
salesforce_web2leadURL-encoded POST to Salesforce Web2Leadurl
smtpSMTP (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:

ValueBehavior 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.
continueThe 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/collect

Notes:

  • on_error is a per-output delivery policy. It is unrelated to field-level required and 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’s on_error.
  • An unknown/unregistered output type is a configuration error, not a delivery failure, and always surfaces (500) even under on_error: continue.
  • Only fail and continue are 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: email

email

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: message

salesforce_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: Website

smtp

Sends a plain-text email directly over SMTP — no AWS dependency. TLS behaviour is port-driven:

PortTLS
465Implicit TLS (SMTPS) — TLS from the first byte
587 (default)STARTTLS — upgrades if the server advertises it
Any otherSTARTTLS 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: message

Security: 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.com

Render 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_SECRET environment variable (the FormTokenSecret SAM parameter). It is never read from the S3 config. A form that enables min_fill_seconds while the secret is empty fails closed — the endpoints return 500 rather than accepting an unverified submission.
  • form_ttl must exceed min_fill_seconds; token_field must 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:

ConditionResponse
valid token, min_fill_seconds ≤ elapsed ≤ form_ttlproceeds normally
elapsed < min_fill_seconds, or a missing / forged / bad-signature tokensilent 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: [ ... ]
KeyApplies toDescription
providerallrecaptcha_v2, recaptcha_v3, hcaptcha, or turnstile
secretallThe provider’s server-side secret key (templatable — resolve from an env var rather than committing it)
min_scorerecaptcha_v3Minimum score to accept (0.0–1.0); submissions below it are rejected
fieldallOverrides 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 typeSerialization
http_post, salesforce_web2leadapplication/x-www-form-urlencoded body with the repeated key sent multiple times: interest=design&interest=engineering
email (SES), smtpkey=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:

ReferenceValue
.fields.<name>The field’s submitted value(s), joined with , for a multi-value field; an absent field renders empty
.form.idThe form name
.submitted_atUTC RFC3339 timestamp of the submission

Functions (this is the complete whitelist — nothing else is available):

FunctionUsagePurpose
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: email

HTTP 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

StatusMeaning
200Submission accepted; all fail-policy outputs relayed (any on_error: continue output that failed is logged, not surfaced)
400Missing form name, wrong Content-Type, or malformed body
404Form name not found in config
422Validation error (missing required field, invalid email, etc.)
500Config 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.

Outcomehtmx 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 failurethe 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-relay

Or run without building:

task generate:form FORM=contact   # prints form HTML to stdout

Environment Variables

VariableRequiredDefaultDescription
CONFIG_BUCKETIf not using CONFIG_INLINE/CONFIG_FILES3 bucket containing the config YAML
CONFIG_KEYNowebform.yamlS3 key for the config file
CONFIG_INLINENoRaw YAML config string; takes precedence over S3. Intended for local development — pass via SAM --env-vars.
CONFIG_FILENoPath to a local YAML config file; takes precedence over S3. Useful when running the binary directly without SAM.
CACHE_TTLNo30sConfig cache TTL (Go duration string)
SES_FROM_ADDRESSFor email outputsVerified SES sender address (used as the From header for all SES emails)
FORM_TOKEN_SECRETFor forms using spam.min_fill_secondsHMAC 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_INLINECONFIG_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.

Parameterdotenv keyDefaultDescription
ConfigBucketCONFIG_BUCKETS3 bucket for the config file
ConfigKeyCONFIG_KEYwebform.yamlS3 key for the config file
ConfigInline""Raw YAML config (for local dev with --env-vars; leave empty in production)
CacheTTLCACHE_TTL30sConfig cache TTL
SesFromAddressSES_FROM_ADDRESS""Verified SES sender address
AllowedOriginsALLOWED_ORIGINS"*"CORS origin allowlist (comma-separated). Controls who may read the response from a browser; see Tuning CORS and throttling
ThrottleRateLimitTHROTTLE_RATE_LIMIT10Steady-state requests/second cap for the whole HTTP API stage
ThrottleBurstLimitTHROTTLE_BURST_LIMIT20Burst size allowed above ThrottleRateLimit
FormTokenSecretFORM_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 submission
    • GET /api/v1/form/{form} — server-render the form (with a fresh min-fill token when enabled); used by htmx
    • GET /api/v1/token/{form} — return just the min-fill token as a hidden-input fragment
  • IAMS3ReadPolicy on ConfigBucket; SESCrudPolicy on SesFromAddress

Go Package Reference

Auto-generated from source with gomarkdoc:

Regenerate with task generate:apidocs.