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:
- Email — plain-text notification via Amazon SES
- S3 — raw submission stored as its own object for your records (via
http_postto an API Gateway S3 proxy — walked through step by step below, no code required) - Salesforce Web2Lead — lead created with custom field mapping
Prerequisites
- A deployed Webform Relay stack (
task relay:deploy:appcompleted — 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:submissionscreates 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: WebsiteField mapping explained
Email output
| Config key | Source field | Notes |
|---|---|---|
from_name | name | Appears in the email body as from_name=Jane Doe |
from_email | email | Appears in the email body as from_email=jane@example.com |
body | feedback | The 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 field | Source field | Notes |
|---|---|---|
last_name | name | Required by Web2Lead |
contact | email | Your org’s custom field name for email |
qt232DD | feedback | Custom field ID for the feedback textarea |
The static block injects values that don’t come from the form:
| Field | Value | Notes |
|---|---|---|
oid | 00Dxxxxxxxxxxxxxxx | Your Salesforce org ID — find it in Setup → Company Information |
retURL | https://yoursite.com/thank-you | Where Salesforce redirects after submission |
lead_source | Website | Optional; 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)
└──▶ SalesforceWhat gets created for you
One task relay:deploy:submissions creates a separate CloudFormation stack
containing:
| Resource | What it is | Notes |
|---|---|---|
| S3 bucket | where submissions land | Name 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 route | the write endpoint | Requires an API key; no other route exists |
| IAM role | lets API Gateway PutObject into that bucket, and nothing else | API Gateway can write objects; it cannot read, list, or delete them |
| API key + usage plan | the shared secret for the endpoint | Retrieved 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 therelay:tasks run from your project root — see Getting Started steps 3–7 iftask relay:infodoesn’t work yet. .envhas your deploy credentials and.env.projecthasAWS_DEFAULT_REGIONandSAM_ARTIFACTS_BUCKET.- Optionally, set
SUBMISSIONS_STACK_NAMEin.env.projectto name the stack. Left blank it defaults to<STACK_NAME>-submissions.
Step 1 — Deploy the storage stack
task relay:deploy:submissionsIt 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-keyThis 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: feedbackThree things about that block are easy to get wrong:
mappingis not optional, and it is not just for renaming. An output sends only whatmappingandstaticput in it. Anhttp_postoutput with nomappingposts 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: namereads as “store the submittednamefield under the keyname”).x-api-keyis the header name the endpoint expects.Authorization: Bearer ...will not work — API Gateway returns403 Forbiddenand 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 isfail— 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:configThe 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 firstEach 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.txtYou can also browse the bucket in the AWS console — S3 → the bucket named in
SubmissionsBucketName → Objects — 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 see | What it means | What to do |
|---|---|---|
Relay returns 500; task relay:logs:tail shows the http_post output got 403 | The 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 empty | The output has no mapping, so nothing was sent | Add a mapping entry per field (step 3) |
Relay returns 200, no new objects at all | The url is wrong — it must be the full SubmissionsEndpoint, ending in /prod/submissions | Re-run task relay:deploy:submissions to reprint the outputs |
task relay:deploy:submissions fails with iam:CreateRole AccessDenied, leaving the stack in ROLLBACK_FAILED | The 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 bucket | CloudFormation cannot delete a bucket that still has objects in it | Empty it first — see below |
Turning it off
task relay:deploy:submissions-teardownTeardown 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> --recursiveRemember 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/feedbackPaste 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
# okThen 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:tailAll 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: continueon the outputs you can afford to lose, so one failing destination no longer turns a good submission into a500— see step 3