Website Contact Form
Receive contact form submissions via API, validate and store them, then send a confirmation email.
Overview
This workflow exposes an API endpoint that your website's contact form posts to. It validates the input, queries and immediately increments a per-email rate limit counter (3 emails/hour), then checks whether the limit was exceeded. If allowed, it maps the subject slug to a human-readable label, stores the submission, returns a success response, and sends a branded confirmation email via Resend.
If validation fails, the user gets a structured 422 error. If they've exceeded the rate limit, they get a 429.
Prerequisites
- A Resend account with a verified sending domain
- Your Resend API Key (from the Resend Dashboard → API Keys)
- A datasource table called "Contact Form" to store submissions
- A datasource table called "contact_rate_limits" for per-email rate limiting
What You'll Build
A contact form backend that:
- Receives name, email, message (required) and company, subject (optional)
- Validates all fields with length limits, email format, and a name pattern
- Enforces a per-email rate limit of 3 submissions per hour
- Maps subject slugs to display labels
- Stores the submission in a "Contact Form" table
- Returns a success message to the caller
- Sends a styled confirmation email via Resend using properly escaped JSON
Endpoint: POST /api/v1/YOUR_ID/contact-form
Request body:
{
"inputs": {
"name": "Jane Doe",
"email": "jane@example.com",
"company": "Acme Inc",
"subject": "sales",
"message": "I'd like to learn more about your enterprise pricing."
}
}
Success response (200):
Your message has been received. Our team will get back to you within 48 hours.
Rate limit response (429):
{
"error": "Too many messages. Please try again later."
}
Database Tables
Table: Contact Form
| Column | Type | Required | Description |
|---|---|---|---|
name |
text | Yes | Sender's name |
email |
Yes | Sender's email address | |
company |
text | No | Sender's company |
subject |
select | No | Human-readable subject label (mapped from slug) |
message |
longtext | Yes | The message body |
created at |
datetime | No | Timestamp of submission |
Table: contact_rate_limits
Tracks per-email submission frequency. This is the captcha workaround — until a captcha node is available, this table prevents abuse by limiting how often the same email can submit.
| Column | Type | Description |
|---|---|---|
email |
text | The sender's email (unique key for upsert) |
attempt_count |
number | Number of submissions in the current 1-hour window |
window_start |
datetime | When the current window started |
Rate limit: 3 submissions per hour per email. The window resets automatically after 1 hour.
Workflow Nodes
1. Flow Start - API Endpoint
| Setting | Value |
|---|---|
| Trigger Type | API |
| Method | POST |
| Custom Path | contact-form |
| CORS Origins | Your domain(s) |
| Rate Limit | 6/min |
| Timeout | 30s |
POST only — no GET. A contact form should never be callable via browser URL bar or crawlers.
2. Data Validator - Input Validation
Validates the incoming {{inputs}} object against a JSON Schema.
Output variable: dataValidator
| Field | Type | Required | Constraints |
|---|---|---|---|
name |
string | Yes | 1–100 chars, letters/spaces/hyphens/apostrophes only |
email |
string | Yes | Email format, max 320 chars |
message |
string | Yes | 10–5000 chars |
company |
string | No | 0–200 chars |
subject |
string (enum) | No | One of: general, sales, support, partnership, labs, training, feedback |
Additional properties are rejected. This prevents attackers from injecting unexpected fields.
On success: continues to the rate limit query.
On failure: routes to the 422 error output with {{dataValidator.errors}}.
3. Query Data - Rate Limit Lookup
Queries the contact_rate_limits table for the sender's email to check their submission count.
| Setting | Value |
|---|---|
| Datasource | contact_rate_limits |
| Where | email = {{inputs.email}} |
Output variable: rateLimitData
4. Code - Rate Limit Check
Checks whether the email has exceeded 3 submissions in the current 1-hour window.
Output variable: rateCheck
var MAX_PER_HOUR = 3;
var WINDOW_MS = 60 * 60 * 1000;
var now = Date.now();
var row = variables.rateLimitData && variables.rateLimitData[0];
var count = 0;
var windowStart = now;
if (row) {
var lastReset = new Date(row.window_start).getTime();
if (now - lastReset < WINDOW_MS) {
count = parseInt(row.attempt_count) || 0;
}
windowStart = (now - lastReset < WINDOW_MS) ? lastReset : now;
}
var result = {
allowed: count < MAX_PER_HOUR,
newCount: count + 1,
windowStart: (count === 0 || now - windowStart >= WINDOW_MS)
? new Date(now).toISOString()
: new Date(windowStart).toISOString(),
isNewRow: !row
};
result;
This acts as a captcha workaround. The same email can only submit 3 times per hour. After the window expires, the counter resets. When a proper captcha node is available, this can be replaced or combined with it.
5. Write Data - Update Rate Limit Counter
Upserts the rate limit row for this email immediately, incrementing the counter before checking the condition. This prevents a race condition where multiple simultaneous requests all read the same count and slip through.
| Setting | Value |
|---|---|
| Datasource | contact_rate_limits |
| Operation | Upsert |
| Match Column | email |
| Column | Value |
|---|---|
email |
{{inputs.email}} |
attempt_count |
{{rateCheck.newCount}} |
window_start |
{{rateCheck.windowStart}} |
Output variable: rateWrite
The upsert happens before the condition check on purpose. If you check first and write after, concurrent requests can all read the same count and bypass the limit.
6. Condition - Rate Limit OK?
| Setting | Value |
|---|---|
| Field | {{rateCheck.allowed}} |
| Operator | == |
| Value | true |
On true: continues to the subject mapping. On false: routes to the 429 error output.
7. Code - Map Subject Slug to Label
Maps the short subject value to a human-readable label for storage and the confirmation email.
Output variable: codeJs
var mapping = {
"general": "General Inquiry",
"sales": "Sales & Pricing",
"support": "Technical Support",
"partnership": "Partnership",
"labs": "Labs / Engineering",
"training": "Training",
"feedback": "Feedback"
};
mapping[variables.inputs.subject] || "General Inquiry";
8. Write Data - Store Submission
Inserts a row into the "Contact Form" datasource table.
| Column | Value |
|---|---|
name |
{{inputs.name}} |
email |
{{inputs.email}} |
company |
{{inputs.company}} |
subject |
{{codeJs}} |
message |
{{inputs.message}} |
created at |
{{currentTimestamp}} |
Output variable: writeData
9. Simple Output - Success Response
Returns a plain text 200 response to the caller.
| Setting | Value |
|---|---|
| Status | 200 |
| Type | Text |
| Output | Your message has been received. Our team will get back to you within 48 hours. |
10. Code - Build Email Body
Builds the Resend API payload using JSON.stringify() so that special characters in the user's name or message (quotes, newlines, apostrophes) are properly escaped. This prevents the "Request body must be valid JSON" error that occurs when template variables are interpolated directly into raw JSON.
Output variable: emailBody
var html = "<div style='font-family:Inter,Arial,sans-serif;max-width:560px;margin:0 auto;padding:32px;background:#0a0a0a;color:#a1a1aa;border-radius:12px;'>"
+ "<div style='text-align:center;margin-bottom:24px;'>"
+ "<h1 style='color:#ffffff;font-size:22px;margin:0;'>Thanks for reaching out, " + variables.inputs.name + "!</h1>"
+ "</div>"
+ "<p style='font-size:15px;line-height:1.6;margin:16px 0;'>We've received your message regarding <strong style='color:#ffffff;'>" + variables.codeJs + "</strong> and our team will get back to you within 48 hours.</p>"
+ "<hr style='border:none;border-top:1px solid rgba(255,255,255,0.08);margin:24px 0;'/>"
+ "<p style='font-size:13px;color:#71717a;margin:0;'>— Your Team</p></div>";
JSON.stringify({
from: "Your App <noreply@yourdomain.com>",
to: variables.inputs.email,
subject: "We received your message!",
html: html
});
This is the key fix:
JSON.stringify()handles all escaping automatically. Never build JSON by concatenating template variables into a raw string — names likeO'Brienor messages with line breaks will break the JSON.
11. HTTP Request - Send Email (Resend)
Sends the confirmation email using the pre-built JSON body.
| Setting | Value |
|---|---|
| Method | POST |
| URL | https://api.resend.com/emails |
| Auth | Bearer token (your Resend API key) |
Headers:
| Key | Value |
|---|---|
| Content-Type | application/json |
Body: {{emailBody}}
Output variable: email_response
The body is just
{{emailBody}}— the Code node already produced valid JSON. No raw JSON template needed.
Error Outputs
Validation Error (422) — when the Data Validator fails:
| Setting | Value |
|---|---|
| Status | 422 |
| Type | JSON |
| Output | {"error": "Validation failed. Please ensure name, email, and message are provided and valid.", "fields": {{dataValidator.errors}} } |
Rate Limit Error (429) — when the email has exceeded 3/hour:
| Setting | Value |
|---|---|
| Status | 429 |
| Type | JSON |
| Output | {"error": "Too many messages. Please try again later."} |
Frontend Integration
Here's a minimal example of posting to this endpoint from a website:
async function submitContactForm(formData) {
const res = await fetch('https://your-domain.com/api/v1/YOUR_ID/contact-form', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inputs: formData })
});
if (res.ok) {
const message = await res.text();
console.log('Success:', message);
// Disable the form for 60 seconds to prevent rapid resubmission
} else if (res.status === 429) {
console.error('Rate limited — too many submissions.');
} else {
const error = await res.json();
console.error('Validation errors:', error.fields);
}
}
Anti-Spam Measures
This workflow uses a layered approach since a captcha node is not yet available:
| Layer | Where | Description |
|---|---|---|
| Global rate limit | Flow Start | 6 requests/min across all IPs |
| Per-email rate limit | Workflow | 3 submissions/hour per email address (via contact_rate_limits table) |
| Frontend cooldown | Frontend | Disables the form for 60 seconds after a successful submission |
| Honeypot field | Frontend | Hidden field that bots fill but humans don't |
| Minimum submit time | Frontend | Reject submissions faster than 3 seconds after page load |
| Name pattern validation | Both | Only letters, spaces, hyphens, apostrophes |
| Field length limits | Both | Server-side max lengths prevent payload abuse |
The per-email rate limit works for normal usage (sequential requests spaced apart in time). It does not prevent concurrent rapid-fire requests because all simultaneous workflow instances read the same count before any of them write. The global Flow Start rate limit (6/min) is the primary defense against burst abuse. Lower it to 3/min for stricter control.
When a captcha node becomes available, add it between the Data Validator and the rate limit query for an additional layer of protection.
Testing
Using curl
curl -X POST https://your-domain.com/api/v1/YOUR_ID/contact-form \
-H "Content-Type: application/json" \
-d '{
"inputs": {
"name": "Test User",
"email": "test@example.com",
"subject": "general",
"message": "This is a test message from the contact form tutorial."
}
}'
Expected: Your message has been received. Our team will get back to you within 48 hours.
Testing rate limit
Submit 4 times with the same email within an hour. The 4th should return:
{"error": "Too many messages. Please try again later."}
with status 429.
What to verify
| Check | Expected |
|---|---|
| Valid submission | 200 with success message |
| Missing required field | 422 with field errors |
| Invalid email format | 422 with email error |
| Message too short (<10 chars) | 422 with message error |
| 4th submission same email | 429 rate limit error |
| Extra fields in payload | 422 (additionalProperties: false) |
| Confirmation email | Arrives with correct name and subject |
| Name with apostrophe (O'Brien) | Email JSON doesn't break |
| Database row | All columns populated correctly |
| Rate limit table | Row created/updated with correct count |
Subject Options
| Slug | Display Label |
|---|---|
general |
General Inquiry |
sales |
Sales & Pricing |
support |
Technical Support |
partnership |
Partnership |
labs |
Labs / Engineering |
training |
Training |
feedback |
Feedback |
To add a new subject, update three places:
- The
enumarray in the Data Validator schema - The
mappingobject in the Code node - The
subjectOptionsarray in your frontend component
Security Checklist
| Control | Status |
|---|---|
| POST only endpoint | ✅ |
| CORS restricted to your domain(s) | ✅ |
| Global rate limiting (6/min) | ✅ |
| Per-email rate limiting (3/hour) | ✅ |
| Frontend cooldown (60s after submit) | ✅ |
| Schema validation with strict types | ✅ |
| Additional properties rejected | ✅ |
| Field length limits enforced | ✅ |
| Email format validation (server-side) | ✅ |
| Name pattern validation (server-side) | ✅ |
| Email body built with JSON.stringify | ✅ |
| Structured error responses (422, 429) | ✅ |
| Fallback for missing subject | ✅ |
| Confirmation email to user | ✅ |
| No real secrets in tutorial JSON | ✅ |