Your First Automation
Build a real automation from scratch — trigger, action, error handling, and testing. No toy examples. Production patterns from line one.
What You Are Building
A webhook automation that receives form submissions, validates the data, saves it to a database, and sends a confirmation email. This is the most common automation pattern in production — you will use variations of it hundreds of times.
Step 1: The Webhook Trigger
Every automation starts with a trigger. A webhook is a URL that waits for incoming HTTP POST requests. When data arrives, your code runs. Here is a webhook handler in Node.js using Express:
const express = require('express');
const app = express();
app.use(express.json());
// TRIGGER: Webhook receives form data
app.post('/webhook/form-submitted', async (req, res) => {
const payload = req.body;
// Acknowledge receipt IMMEDIATELY (within 3 seconds)
// Process in the background — never block the webhook
res.status(200).json({ received: true });
// Process asynchronously
processFormSubmission(payload).catch(err => {
console.error('Automation failed:', err);
saveToDeadLetterQueue(payload, err);
});
});
Webhook senders (Stripe, Zapier, GitHub) expect a response within 3-5 seconds. If you do not respond in time, they will retry — causing duplicate processing. Always acknowledge first, process second.
Step 2: Validate the Payload
Never trust incoming data. Validate every field before processing. A missing email address should not crash your entire pipeline.
const errors = [];
if (!payload.email) errors.push('Missing email');
if (!payload.name) errors.push('Missing name');
if (payload.email && !payload.email.includes('@')) {
errors.push('Invalid email format');
}
return {
valid: errors.length === 0,
errors
};
}
Step 3: Save to Database
The action — what your automation actually does. This example uses Supabase, but the pattern works with any database:
// 1. Validate
const validation = validatePayload(payload);
if (!validation.valid) {
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
}
// 2. Idempotency check — prevent duplicate processing
const { data: existing } = await supabase
.from('submissions')
.select('id')
.eq('email', payload.email)
.eq('submitted_at', payload.submitted_at)
.single();
if (existing) {
console.log('Duplicate detected, skipping');
return;
}
// 3. Save to database
const { error } = await supabase
.from('submissions')
.insert({
name: payload.name,
email: payload.email,
message: payload.message,
submitted_at: payload.submitted_at
});
if (error) throw error;
// 4. Send confirmation email
await sendConfirmation(payload.email, payload.name);
}
Webhooks can fire twice (network retry, timeout, server restart). Without the duplicate check on line 8-13, you would save the same submission twice and send two confirmation emails. Always check before inserting.
Step 4: Error Handling & Dead Letter Queue
Production automations fail. The database goes down. The email API rate-limits you. Your code needs to handle every failure without losing data:
// Save failed messages for retry later
await supabase.from('dead_letter_queue').insert({
payload: JSON.stringify(payload),
error_message: error.message,
failed_at: new Date().toISOString(),
retry_count: 0,
status: 'pending'
});
}
// Retry failed messages (run on a schedule, e.g. every 10 min)
async function retryFailedMessages() {
const { data: failed } = await supabase
.from('dead_letter_queue')
.select('*')
.eq('status', 'pending')
.lt('retry_count', 3);
for (const msg of failed) {
try {
await processFormSubmission(JSON.parse(msg.payload));
await supabase.from('dead_letter_queue')
.update({ status: 'resolved' })
.eq('id', msg.id);
} catch (err) {
await supabase.from('dead_letter_queue')
.update({ retry_count: msg.retry_count + 1 })
.eq('id', msg.id);
}
}
}
Step 5: Testing Your Automation
Never deploy without testing. Here is how to test each layer of your automation — from unit tests to end-to-end:
test('rejects missing email', () => {
const result = validatePayload({ name: 'Alice' });
expect(result.valid).toBe(false);
expect(result.errors).toContain('Missing email');
});
test('accepts valid payload', () => {
const result = validatePayload({
name: 'Alice',
email: 'alice@example.com'
});
expect(result.valid).toBe(true);
});
curl -X POST http://localhost:3000/webhook/form-submitted \
-H "Content-Type: application/json" \
-d '{"name":"Test User","email":"test@example.com","message":"Hello"}'
Then check: Did the database get a new row? Did the dead letter queue stay empty? Was the email sent?
curl -X POST http://localhost:3000/webhook/form-submitted \
-H "Content-Type: application/json" \
-d '{"name":"No Email User"}'
// Send the same payload twice — does idempotency work?
// Second call should be silently skipped, no duplicate row
The Five Failure Modes
Every automation you build will encounter these failures. Knowing them means you design for them from day one:
The Complete Script in Python
Everything above — webhook, validation, database, email, error handling — combined into a single production-ready Python automation. This is the pattern you will reuse for every webhook automation you build:
from flask import Flask, request, jsonify
from supabase import create_client
import os, smtplib
from email.message import EmailMessage
app = Flask(__name__)
db = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_KEY"])
def validate(payload: dict) -> list:
"""Return list of errors. Empty list = valid."""
errors = []
if not payload.get("email"):
errors.append("Missing email")
elif "@" not in payload["email"]:
errors.append("Invalid email")
if not payload.get("name"):
errors.append("Missing name")
return errors
def send_email(to: str, name: str):
msg = EmailMessage()
msg["Subject"] = "We got your submission!"
msg["To"] = to
msg.set_content(f"Hi {name}, thanks for reaching out.")
with smtplib.SMTP("smtp.example.com", 587) as s:
s.starttls()
s.send_message(msg)
@app.route("/webhook/form", methods=["POST"])
def handle_form():
payload = request.get_json()
# 1. Validate
errors = validate(payload)
if errors:
return jsonify({"errors": errors}), 400
# 2. Idempotency check
existing = db.table("submissions").select("id") \
.eq("email", payload["email"]).execute()
if existing.data:
return jsonify({"status": "duplicate"}), 200
# 3. Save + notify (with error recovery)
try:
db.table("submissions").insert(payload).execute()
send_email(payload["email"], payload["name"])
except Exception as e:
db.table("dead_letter_queue").insert({
"payload": payload, "error": str(e)
}).execute()
return jsonify({"received": True}), 200
Putting It All Together
Every automation you build follows the same three decisions: (1) pick a trigger — what event starts the workflow, (2) pick an action — what the automation does with the data, and (3) connect them with validation, error handling, and idempotency in between. The code above gives you the complete production pattern for the most common case: webhook trigger with database save and email notification.
Production Checklist
Before deploying any automation to production, verify every item: