Fan-out: email, S3, and Salesforce Web2Lead

This recipe configures a feedback form with three name, email, and a long text feedback field that fans out to three destinations simultaneously:

  1. Email — plain-text notification via Amazon SES
  2. S3 — raw submission stored as its own object for your records (via http_post to an API Gateway S3 proxy — walked through step by step below, no code required)
  3. Salesforce Web2Lead — lead created with custom field mapping

Prerequisites

  • A deployed Webform Relay stack (task relay:deploy:app completed — see Getting Started)
  • A verified SES sender address set as SES_FROM_ADDRESS
  • A Salesforce org ID and Web2Lead enabled
  • For the S3 copy: nothing in advance. task relay:deploy:submissions creates the bucket, the endpoint in front of it, and its API key for you — see Store every submission in S3

Config

Upload this to S3 at your CONFIG_BUCKET / CONFIG_KEY path. The config is live within cache_ttl — no redeployment needed.

cache_ttl: 30s

forms:
  feedback:
    fields:
      - name: name
        required: true
      - name: email
        required: true
        type: email
      - name: feedback
        required: true
        type: textarea

    outputs:
      # 1. Email notification via SES
      - type: email
        to: yourteam@example.com
        subject: "New feedback submission"
        mapping:
          from_name: name
          from_email: email
          body: feedback

      # 2. Store to S3 via http_post — url and key come from
      #    task relay:deploy:submissions / task relay:submissions:api-key.
      #    An output sends only what `mapping` lists, so every field to be
      #    stored appears below, even where the name is unchanged.
      - type: http_post
        url: https://<api-id>.execute-api.<region>.amazonaws.com/prod/submissions
        headers:
          x-api-key: "<the value printed by task relay:submissions:api-key>"
        mapping:
          name: name
          email: email
          feedback: feedback

      # 3. Salesforce Web2Lead
      - type: salesforce_web2lead
        url: https://webto.salesforce.com/servlet/servlet.WebToLead?encoding=UTF-8
        mapping:
          last_name: name
          contact: email
          qt232DD: feedback
        static:
          oid: "00Dxxxxxxxxxxxxxxx"
          retURL: "https://yoursite.com/thank-you"
          lead_source: Website

Field mapping explained

Email output

Config keySource fieldNotes
from_namenameAppears in the email body as from_name=Jane Doe
from_emailemailAppears in the email body as from_email=jane@example.com
bodyfeedbackThe feedback text, appears as body=...

The email body is plain text with one key=value line per mapped field, sorted alphabetically. If you want a formatted body you will need a custom http_post output to a rendering endpoint.

Salesforce Web2Lead output

Salesforce Web2Lead expects specific field names. Your form uses different names internally — the mapping block handles the rename.

Salesforce fieldSource fieldNotes
last_namenameRequired by Web2Lead
contactemailYour org’s custom field name for email
qt232DDfeedbackCustom field ID for the feedback textarea

The static block injects values that don’t come from the form:

FieldValueNotes
oid00DxxxxxxxxxxxxxxxYour Salesforce org ID — find it in Setup → Company Information
retURLhttps://yoursite.com/thank-youWhere Salesforce redirects after submission
lead_sourceWebsiteOptional; tags the lead’s origin in Salesforce

Replace 00Dxxxxxxxxxxxxxxx with your actual org ID. If retURL is not a real page the submission still succeeds — Salesforce only uses it for browser redirects from an HTML form post.


Store every submission in S3

This is the longest part of the recipe, so it gets its own walkthrough. You do not need to create the bucket, write any code, or open the AWS console: webform-relay ships the whole thing as a CloudFormation template (deploy/s3-relay-template.yaml in the vendored source) and four tasks that drive it.

There is no native s3 output type. Instead, a small API Gateway endpoint sits in front of a private bucket and writes each request body straight to S3 — no Lambda, nothing to maintain:

browser ──POST──▶ webform-relay ──http_post──▶ API Gateway ──PUT──▶ S3 bucket
                       │                        (x-api-key)        (private,
                       ├──▶ SES email                               encrypted)
                       └──▶ Salesforce

What gets created for you

One task relay:deploy:submissions creates a separate CloudFormation stack containing:

ResourceWhat it isNotes
S3 bucketwhere submissions landName auto-generated by CloudFormation. Encrypted at rest (AES256) and all public access blocked — nothing in it is reachable from the internet
REST API with one POST /submissions routethe write endpointRequires an API key; no other route exists
IAM rolelets API Gateway PutObject into that bucket, and nothing elseAPI Gateway can write objects; it cannot read, list, or delete them
API key + usage planthe shared secret for the endpointRetrieved with task relay:submissions:api-key

Because it is a separate stack from your form relay, you can add it, redeploy it, or delete it without touching a working form.

Before you start

  • The app stack is deployed (task relay:deploy:app) and the relay: tasks run from your project root — see Getting Started steps 3–7 if task relay:info doesn’t work yet.
  • .env has your deploy credentials and .env.project has AWS_DEFAULT_REGION and SAM_ARTIFACTS_BUCKET.
  • Optionally, set SUBMISSIONS_STACK_NAME in .env.project to name the stack. Left blank it defaults to <STACK_NAME>-submissions.

Step 1 — Deploy the storage stack

task relay:deploy:submissions

It takes a minute or two and prints the stack outputs when it finishes:

------------------------------------------------------------------
|                         DescribeStacks                          |
+-------------------------+---------------------------------------+
|  SubmissionsBucketName  |  webform-relay-submissions-1a2b3c4d5e  |
|  SubmissionsEndpoint    |  https://abc123.execute-api.us-east-1.amazonaws.com/prod/submissions |
|  ApiKeyId               |  k1l2m3n4o5                            |
+-------------------------+---------------------------------------+

Copy SubmissionsEndpoint — that is the url for the config in step 3. (ApiKeyId is the key’s identifier, not the key itself; step 2 gets the real value.) Re-running the task later is safe: it updates the stack in place and prints the same outputs, so you can always get them back without writing anything down.

Step 2 — Get the API key

task relay:submissions:api-key

This prints one long string. Treat it as a password. Anyone holding it and the endpoint URL can write objects into your bucket — they cannot read what is already there, but they can fill it with junk.

It goes into your webform.yaml in the next step, which means that file now contains a secret: keep it out of version control (webform-relay’s own .gitignore already ignores /webform.yaml for this reason) and remember that the copy that matters lives in your private CONFIG_BUCKET, not on your laptop.

Step 3 — Add the output to your form config

Add a third http_post output to the feedback form in webform.yaml, using the endpoint from step 1 and the key from step 2:

      # 2. Store to S3
      - type: http_post
        url: https://abc123.execute-api.us-east-1.amazonaws.com/prod/submissions
        headers:
          x-api-key: "<the value printed by task relay:submissions:api-key>"
        on_error: continue
        mapping:
          name: name
          email: email
          feedback: feedback

Three things about that block are easy to get wrong:

  • mapping is not optional, and it is not just for renaming. An output sends only what mapping and static put in it. An http_post output with no mapping posts an empty body, and you get an empty object in S3 for every submission. List every field you want stored, even when the name doesn’t change (name: name reads as “store the submitted name field under the key name”).
  • x-api-key is the header name the endpoint expects. Authorization: Bearer ... will not work — API Gateway returns 403 Forbidden and the relay reports a failed output.
  • on_error: continue (optional) means a storage failure is logged and swallowed instead of failing the whole submission. Use it when email is the destination that really matters. Leave it off — the default is fail — when the stored copy is your system of record and you would rather the submitter sees an error than lose it.

If the S3 copy is the one you cannot lose, list this output first: outputs run in the order they appear, so the raw submission is captured before anything downstream has a chance to fail.

Step 4 — Upload the config

task relay:validate:config     # catches typos before they go live
task relay:upload:config

The new output is live within cache_ttl (30s in this recipe). No redeploy.

Step 5 — Send a test submission

curl -X POST \
  https://<api-id>.execute-api.<region>.amazonaws.com/api/v1/submit/feedback \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "name=Test+User&email=test@example.com&feedback=Testing+S3+storage"

{"message": "ok"} means every output succeeded, storage included.

Step 6 — Read what landed

task relay:submissions:list                  # every object: key, size, timestamp
task relay:submissions:read KEY=<object-key> # one submission, URL-decoded
task relay:submissions:dump                  # all of them, oldest first

Each submission is a separate object named after the API Gateway request ID (no file extension), so two submissions can never overwrite each other. The body is the raw form-urlencoded payload exactly as the relay sent it — name=Test+User&email=test%40example.com&.... read and dump decode that back into readable text for you; list does not.

To keep a copy of everything on your own machine:

task relay:submissions:dump > submissions.txt

You can also browse the bucket in the AWS console — S3 → the bucket named in SubmissionsBucketNameObjects — and download objects individually.

What it costs, and how long it keeps things

Storage is billed by the byte and a form submission is a few hundred of them; realistically this is cents per year plus a fraction of a cent per thousand submissions for the API Gateway and S3 requests. Nothing expires on its own — objects stay until you delete them. If you want automatic retention (say, delete after two years), add a lifecycle rule in the S3 console under Management → Lifecycle rules; webform-relay does not manage one for you.

Submissions are personal data. The bucket is private and encrypted at rest by default, and the endpoint can only write — but access to the bucket is still whatever your AWS account grants, so treat the deploy credentials in .env accordingly.

When it doesn’t work

What you seeWhat it meansWhat to do
Relay returns 500; task relay:logs:tail shows the http_post output got 403The x-api-key header is missing, misspelled, or holds the wrong value (ApiKeyId instead of the key itself)Re-run task relay:submissions:api-key and paste that exact string, then re-upload the config
Relay returns 200 but objects in S3 are emptyThe output has no mapping, so nothing was sentAdd a mapping entry per field (step 3)
Relay returns 200, no new objects at allThe url is wrong — it must be the full SubmissionsEndpoint, ending in /prod/submissionsRe-run task relay:deploy:submissions to reprint the outputs
task relay:deploy:submissions fails with iam:CreateRole AccessDenied, leaving the stack in ROLLBACK_FAILEDThe deploy user’s policy scopes IAM to role names matching your STACK_NAME, and it was written for a different one (or predates this stack)task relay:aws:update-deploy-policy, then task relay:deploy:submissions-teardown to clear the failed stack, then deploy again — see Recovering from a failed deploy
task relay:deploy:submissions-teardown fails on the bucketCloudFormation cannot delete a bucket that still has objects in itEmpty it first — see below

Turning it off

task relay:deploy:submissions-teardown

Teardown deliberately does not delete your stored submissions, so it fails while the bucket has anything in it. Empty it by hand first — this destroys every stored submission:

aws s3 rm s3://<SubmissionsBucketName> --recursive

Remember to remove the http_post output from webform.yaml and re-upload, or the next submission fails against an endpoint that no longer exists.


Rolling your own storage endpoint

Skip this section unless you want to own the template yourself or fold storage into an existing stack — everything above is complete without it.

The shipped template is an API Gateway S3 proxy integration: API Gateway PUTs the request body directly to S3 with no Lambda in between. A minimal hand-rolled version:

# Separate SAM template — not part of the relay template
Resources:
  SubmissionsBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: my-form-submissions

  SubmissionsApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      DefinitionBody:
        openapi: "3.0"
        info: { title: Submissions, version: "1" }
        paths:
          /submissions:
            post:
              x-amazon-apigateway-integration:
                type: aws
                httpMethod: PUT
                uri: !Sub "arn:aws:apigateway:${AWS::Region}:s3:path/my-form-submissions/{key}"
                requestParameters:
                  integration.request.path.key: "context.requestId"
                responses:
                  default:
                    statusCode: "200"

Each submission is stored as a separate object keyed by the API Gateway request ID, so they never collide. The object body is the raw application/x-www-form-urlencoded payload exactly as the relay sent it. Note that this minimal version has no API key — add one, as the shipped template does, before pointing anything at it.

If you would rather store decoded JSON than the raw payload, put a small Lambda in front of the bucket instead:

// handler writes URL-encoded body to S3 as a JSON object
func handler(ctx context.Context, req events.APIGatewayProxyRequest) (...) {
    vals, _ := url.ParseQuery(req.Body)
    data := map[string]string{}
    for k, v := range vals { data[k] = v[0] }
    b, _ := json.Marshal(data)
    s3client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: aws.String(os.Getenv("BUCKET")),
        Key:    aws.String(time.Now().UTC().Format(time.RFC3339Nano) + ".json"),
        Body:   bytes.NewReader(b),
    })
    return events.APIGatewayProxyResponse{StatusCode: 200}, nil
}

Generate and embed the HTML form

Ask your deployed endpoint for the form’s HTML — it renders server-side from the config you just uploaded, so it always matches what the relay will accept:

curl https://<api-id>.execute-api.<region>.amazonaws.com/api/v1/form/feedback

Paste the result into your page. The output is a self-contained <form> element. The action attribute points to your deployed endpoint:

<form method="post"
      action="https://<api-id>.execute-api.<region>.amazonaws.com/api/v1/submit/feedback"
      enctype="application/x-www-form-urlencoded">
  <input type="text"     name="name"     required placeholder="Your name">
  <input type="email"    name="email"    required placeholder="your@email.com">
  <textarea              name="feedback" required placeholder="Your feedback"></textarea>
  <button type="submit">Send feedback</button>
</form>

Test the config before going live

Validate the config file before uploading it — this catches typos, unknown keys, and bad mappings without touching the live form:

task relay:validate:config
# ok

Then do a dry-run with curl:

curl -v -X POST \
  https://<api-id>.execute-api.<region>.amazonaws.com/api/v1/submit/feedback \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "name=Test+User&email=test@example.com&feedback=This+is+a+test+submission"

Expected response:

{"message": "ok"}

Check CloudWatch logs for any output-level errors if the response is 500:

task relay:logs:tail

All three outputs are attempted on every submission. A 500 means at least one failed — the logs will show which target and why.


Fan-out behaviour

All three outputs run on every submission. If one fails (for example, SES is throttled, or the S3 proxy returns a 4xx), the relay returns HTTP 500 and logs the error — but the other outputs that succeeded are not rolled back. The submitter sees an error page; the data may have partially arrived at some destinations.

If partial failure is not acceptable for your use case, consider:

  • Adding a retry queue (SQS + Lambda) in front of unreliable targets
  • Making the S3 write the first output so the raw submission is always captured before attempting downstream delivery
  • Setting on_error: continue on the outputs you can afford to lose, so one failing destination no longer turns a good submission into a 500 — see step 3