How We Built a Zero-Touch Product Delivery Pipeline: From Stripe Webhook to PDF in Your Inbox
The Pain Point
You have a productized service. A customer pays. Now what?
If you're like most small teams, the answer is: someone gets an email, someone remembers to run the thing, someone exports the report, someone sends it to the customer. Maybe that takes an hour. Maybe it takes a day. Maybe it falls through the cracks entirely when you're busy with other work.
We ran into this exact problem at Ledd Consulting. We sell automated security audits — a customer provides a server URL, pays $199 or $499 through Stripe, and expects a branded PDF report in their inbox. The audit itself is deterministic. The PDF generation is automated. There's no reason a human should be in the loop at all.
But building a pipeline where money comes in one end and a finished product comes out the other — reliably, at 2 AM on a Sunday, with proper failure handling — is harder than it sounds.
Why Common Solutions Fall Short
The "Just Use Zapier" Approach
Most teams reach for a no-code integration. Stripe → Zapier → some PDF API → email. The problem is threefold: you lose control of failure handling, you can't run custom analysis logic between payment and delivery, and you're paying per-zap for something that should be a few hundred lines of code.
The "Queue It and Process Later" Approach
Others throw the webhook event onto a job queue — SQS, Bull, Celery — and process it asynchronously. That's architecturally sound for high-volume systems. But if you're running 25 services on a single VPS and processing maybe 5–10 purchases per week, adding a queue broker is ceremony that buys you nothing except another service to monitor.
The "Webhook Fires, Human Fulfills" Approach
The most common pattern we see in small consultancies: the Stripe webhook sends a Slack notification, and a person does the rest. This works until it doesn't — and when it doesn't, the customer who just paid $499 is sitting there refreshing their inbox wondering if they got scammed.
Our Approach
We built a synchronous-acknowledgment, asynchronous-fulfillment pipeline. The Stripe webhook returns 200 immediately (Stripe requires this within 20 seconds), then kicks off the full audit → PDF → email chain in the background using setImmediate(). If any step in the chain fails, the system sends an internal alert with enough context for manual recovery — but in practice, it hasn't needed manual intervention yet.
The architecture has three components:
- Contact form service (port 8080) — handles checkout session creation and Stripe webhooks
- Audit engine (port 5000) — runs the actual security analysis and renders PDFs
- Email delivery layer — sends the report with verified delivery confirmation
All three run on the same host. No queue. No broker. No external orchestration.
Implementation
Step 1: Creating the Checkout Session
When a customer clicks "Buy," we create a Stripe Checkout session with all the metadata we'll need for fulfillment embedded directly in the session:
async function createAuditCheckout(req, res, body) {
const clientIP = getClientIP(req);
if (isRateLimited(clientIP)) {
sendJSON(res, 429, { error: 'Too many requests. Try again later.' });
return;
}
const { target_url, target_name, customer_email, contact_name, tier } = body;
const normalizedTier = normalizeTier(tier);
const tierConfig = getTierConfig(normalizedTier);
if (!customer_email || !validateEmail(customer_email.trim())) {
sendJSON(res, 400, { error: 'A valid email is required.' });
return;
}
if (!target_url || typeof target_url !== 'string') {
sendJSON(res, 400, { error: 'Server URL is required.' });
return;
}
try {
new URL(target_url.trim());
} catch {
sendJSON(res, 400, { error: 'Invalid server URL.' });
return;
}
const safeName = (target_name || '').trim().substring(0, 100) || 'Server';
const stripe = getStripe();
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
customer_email: customer_email.trim().toLowerCase(),
line_items: [{
price_data: {
currency: 'usd',
product_data: {
name: tierConfig.name,
description: `${tierConfig.description} for ${safeName}`,
},
unit_amount: tierConfig.priceCents,
},
quantity: 1,
}],
metadata: {
product: 'security-audit',
target_url: target_url.trim().substring(0, 500),
target_name: safeName,
customer_email: customer_email.trim().toLowerCase().substring(0, 200),
contact_name: sanitizeOptional(contact_name, 200),
tier: tierConfig.id,
},
success_url: `${checkoutBase}/thank-you?checkout=success`,
cancel_url: `${checkoutBase}/security-audit`,
});
sendJSON(res, 200, { session_id: session.id, checkout_url: session.url });
}
The key design decision here: everything the fulfillment pipeline needs lives in metadata. The target URL, customer email, tier, and contact name are all embedded in the Stripe session. When the webhook fires, we don't need to look anything up — the event carries its own context.
This matters more than it seems. Every external lookup you add to your fulfillment path is another failure point. If your webhook handler has to query a database to find what the customer ordered, and that database is down, your paid customer gets nothing.
Step 2: The Webhook Handler
Here's the core of the pipeline — the webhook handler that receives Stripe's checkout.session.completed event:
async function handleStripeWebhook(req, res) {
let rawBody;
try {
rawBody = await collectRawBody(req);
} catch {
sendJSON(res, 400, { error: 'Bad request body.' });
return;
}
const sig = req.headers['stripe-signature'];
if (!sig || !STRIPE_WEBHOOK_SECRET) {
sendJSON(res, 400, { error: 'Missing signature or webhook secret.' });
return;
}
let event;
try {
const stripe = getStripe();
event = stripe.webhooks.constructEvent(rawBody, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error(`Stripe webhook signature failed: ${err.message}`);
sendJSON(res, 400, { error: 'Invalid signature.' });
return;
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const meta = session.metadata || {};
if (meta.product === 'security-audit' || meta.product === 'compliance-audit') {
// Acknowledge Stripe immediately
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: true }));
// Fulfill asynchronously
setImmediate(() => {
fulfillAudit(meta, session.id);
});
return;
}
}
sendJSON(res, 200, { received: true });
}
Three things to notice:
- Signature verification first. We use
constructEventwith the raw body — not parsed JSON. Stripe's signature verification is sensitive to body parsing; if your framework parses the body before you verify the signature, it will fail silently. This is the most common Stripe webhook bug we see in code reviews. - Immediate response, deferred work. We call
res.end()before starting fulfillment. Stripe will retry webhooks that don't return 200 within 20 seconds, and our audit can take up to 120 seconds. If we waited, Stripe would fire the webhook again, and we'd fulfill the order twice. setImmediate()oversetTimeout(0). Both defer execution, butsetImmediateruns after I/O callbacks in the current event loop iteration, which means the HTTP response is guaranteed to flush before fulfillment starts. This isn't academic — we've seensetTimeout(0)race with response flushing under load.
Step 3: The Fulfillment Pipeline
This is where the actual work happens — calling the audit engine, generating the PDF, and emailing it:
async function fulfillAudit(meta, sessionId) {
const { target_url, target_name, customer_email } = meta;
const lead = recordCheckoutLead(meta, sessionId);
const tierConfig = getTierConfig(normalizeTier(meta.tier));
console.log(`Fulfilling audit: ${target_url} (${target_name}) -> ${customer_email}`);
if (!process.env.API_KEY) {
sendEmailNotification({
...lead,
offering: 'Audit FULFILLMENT FAILED',
source: 'audit-fulfillment',
message: `ALERT: Fulfillment failed for paid customer.\n\n` +
`Customer: ${customer_email}\nTarget: ${target_url}\n` +
`Session: ${sessionId}\nError: API key not configured.\n\n` +
`Manual fulfillment required.`,
});
return;
}
let pdfBuffer;
try {
const auditUrl = `http://127.0.0.1:5000/api/audit?key=${encodeURIComponent(process.env.API_KEY)}&format=pdf`;
const auditResp = await fetch(auditUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_url, target_name: target_name || 'Server' }),
signal: AbortSignal.timeout(120000),
});
if (!auditResp.ok) {
const errText = await auditResp.text();
throw new Error(`Audit engine returned ${auditResp.status}: ${errText.substring(0, 300)}`);
}
pdfBuffer = Buffer.from(await auditResp.arrayBuffer());
console.log(`Audit PDF generated: ${pdfBuffer.length} bytes`);
} catch (err) {
console.error(`Audit scan failed: ${err.message}`);
sendEmailNotification({
...lead,
offering: 'Audit FULFILLMENT FAILED',
source: 'audit-fulfillment',
message: `ALERT: Fulfillment failed for paid customer.\n\n` +
`Customer: ${customer_email}\nTarget: ${target_url}\n` +
`Session: ${sessionId}\nError: ${err.message}\n\n` +
`Manual fulfillment required.`,
});
return;
}
const safeName = ((target_name || 'server')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')) || 'server';
const pdfPath = `/tmp/audit-${safeName}-${Date.now()}.pdf`;
try {
fs.writeFileSync(pdfPath, pdfBuffer);
const subject = `Your ${tierConfig.name} Report - ${target_name || target_url}`;
const bodyText = [
`Your ${tierConfig.name} is complete.`,
'',
`Target: ${target_url}`,
`Server: ${target_name || 'N/A'}`,
'',
'Your branded PDF report is attached. It includes a compliance matrix,',
'concrete findings, severity ratings, and remediation guidance.',
'',
'If you have questions or want a deeper review,',
'reply to this email.',
'',
'-- Ledd Consulting',
].join('\n');
sendTemplatedEmail({
to: customer_email,
subject,
bodyText,
attachmentPaths: [pdfPath],
});
console.log(`Audit report emailed to ${customer_email}`);
} catch (err) {
console.error(`Failed to email audit report: ${err.message}`);
sendEmailNotification({
...lead,
offering: 'Audit EMAIL FAILED',
source: 'audit-fulfillment',
message: `PDF generated but email delivery failed.\n\n` +
`Customer: ${customer_email}\nPDF saved: ${pdfPath}\n` +
`Error: ${err.message}`,
});
}
}
The pattern here is fail loudly at every stage. Each catch block doesn't just log — it fires an internal notification with the customer email, session ID, and error message. If the audit engine is down, we know within seconds. If the PDF generates but email fails, we know the PDF path so we can send it manually.
This isn't theoretical. We built this after a real incident where a fulfillment script silently swallowed an error, and a customer waited 48 hours for a report we thought had been delivered.
Step 4: Tiered Pricing Without Tiered Complexity
We run multiple audit products at different price points through the same pipeline:
const AUDIT_PRICE_CENTS = 19900; // $199 - Automated Audit
const COMPLIANCE_AUDIT_PRICE_CENTS = 49900; // $499 - Compliance Audit
const REDTEAM_AUDIT_PRICE_CENTS = 240000; // $2,400 - Red-Team Audit
The tier field in the Stripe metadata controls which audit engine endpoint gets called and which PDF template is rendered. Adding a new product tier is a config change — define the price, name the endpoint, and the pipeline handles the rest.
Step 5: Input Validation That Earns Trust
Before any checkout session is created, every submission passes through a classification filter:
function classifySubmission(name, email, message, company) {
const emailLower = email.toLowerCase();
const domain = emailLower.split('@')[1] || '';
const localPart = emailLower.split('@')[0] || '';
// Check for injection attacks in any field
const allText = `${name} ${email} ${message} ${company || ''}`;
for (const pattern of INJECTION_PATTERNS) {
if (pattern.test(allText)) {
return { verdict: 'attack', reason: `injection pattern: ${pattern}` };
}
}
// Disposable/test email domains
if (DISPOSABLE_DOMAINS.has(domain)) {
return { verdict: 'test', reason: `disposable domain: ${domain}` };
}
// Blocked local parts
if (BLOCKED_LOCAL_PARTS.has(localPart)) {
return { verdict: 'test', reason: `blocked local part: ${localPart}` };
}
// Name validation
const cleanName = name.trim();
if (cleanName.length < 2 || /^(.)\1+$/.test(cleanName) || /^\d+$/.test(cleanName)) {
return { verdict: 'spam', reason: `suspicious name: "${cleanName}"` };
}
return { verdict: 'legitimate', reason: null };
}
This catches disposable email domains (mailinator, guerrillamail, yopmail), injection attempts in form fields, and bot-like submissions — all before we ever create a Stripe session. We're not just protecting revenue; we're protecting the audit engine from scanning attacker-controlled URLs.
Results
We've been running this pipeline in production since early 2026. The numbers:
- Median fulfillment time: 47 seconds from Stripe webhook to email in inbox. Most of that is the audit engine scanning the target.
- Manual interventions needed: Zero in the last 30 days. The alert pathway exists and has fired during development, but hasn't been needed in production.
- Webhook reliability: Stripe reports 100% first-attempt success rate on our endpoint. We've never triggered a retry.
- Cost per fulfillment: Effectively zero marginal cost. The audit engine, PDF renderer, and email system all run on the same VPS that hosts our other 25 services. No per-invocation charges, no third-party API fees for the delivery pipeline itself.
- Lines of code: The entire fulfillment pipeline — checkout creation, webhook handling, audit orchestration, PDF delivery, failure alerting — is under 400 lines of JavaScript in a single file.
Adapting This for Your System
The pattern generalizes to any "customer pays, system delivers" workflow:
- Embed fulfillment context in Stripe metadata. Don't rely on database lookups in your webhook handler. The metadata travels with the event.
- Acknowledge immediately, fulfill asynchronously. Use
setImmediate()or equivalent in your runtime. Never block the webhook response on fulfillment work. - Alert on every failure branch, not just the happy path. Each
catchblock should produce an actionable notification with enough context for manual recovery: customer identity, what they paid for, what failed, and where any partial artifacts are saved. - Keep services on the same host when volume permits. We call the audit engine over
127.0.0.1with a 120-second timeout. No network latency, no DNS resolution, no TLS handshake. At our scale, the simplicity is worth more than the architectural purity of separate deployments. - Validate inputs before creating checkout sessions. Don't let attackers use your Stripe integration as a probe. Filter disposable emails, injection patterns, and bot submissions at the form level.
If you're processing fewer than 1,000 orders per day, you almost certainly don't need a message queue between your webhook and your fulfillment logic. A single Node.js process with setImmediate and proper error handling will get you further than most teams expect.
Conclusion
The gap between "we have a product" and "customers can buy it without us touching anything" is smaller than most teams think — but only if you resist the urge to over-engineer. We didn't need a queue, a serverless function, or a third-party fulfillment platform. We needed a Stripe webhook handler that responds fast, a fulfillment function that fails loudly, and an audit engine that was already running.
The entire pipeline ships in a single file alongside the contact form it serves. It handles multiple product tiers, validates inputs against injection and spam, and has delivered every paid report without manual intervention. That's not a boast — it's a design constraint. When you're a small team, zero-touch fulfillment isn't a luxury. It's the only way the business works.
Need help building AI agent systems or designing multi-agent architectures? Ledd Consulting specializes in autonomous workflow design and agent orchestration for enterprise teams.