BeginnerContact

Website Contact Form

Validate submissions, store in a table, and send confirmation emails via Resend.

Click to expand
Flow StartAPI POSTData ValidatordataValidatorTRUEFALSESimple OutputError 422Query DatarateLimitDataCoderateCheckWrite DatarateWriteConditionIFELSESimple OutputError 429CodecodeJsWrite DataContact FormSimple OutputSuccess 200CodeemailBodyHTTP RequestResend API

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 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 like O'Brien or 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:

  1. The enum array in the Data Validator schema
  2. The mapping object in the Code node
  3. The subjectOptions array 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