Before I automated order processing at Pikkuna, this is what happened every time someone paid:
A manager received a Stripe email notification. They opened Zoho CRM in one tab, copy-pasted the customer name and address. They opened Airtable in another tab to log the production order. Then PostNord in a third tab to generate the shipping label. Then Netvisor — Finnish accounting software — in a fourth tab to create the invoice. Then back to email to send the confirmation with the tracking number.
Fifteen to thirty minutes per order. Four browser tabs. And every time a field was mis-typed, the wrong address went on the label or the invoice had the wrong amount.
After automation: 0 manual steps. 2 minutes from Stripe payment confirmation to the customer having a tracking number and a VAT invoice in their inbox. Zero human error.
This is the architecture I built, and the code that runs it.
The Problem with Manual Order Processing
The obvious cost is time. At 20 orders per day, 20 minutes each — that's nearly 7 hours of manager time, every day, doing nothing but data entry.
But the hidden cost is worse: errors. A wrong postal code means a returned shipment. A wrong VAT number on an invoice means an accounting problem the customer's finance team will flag two months later. A missed order means an angry email.
When I started on Pikkuna, the platform already operated across 30 languages and 35 countries. Manual processing didn't scale. The solution wasn't to hire more people to do the same thing — it was to make the computer do it.
The Full Pipeline
Here is the complete automation pipeline, from payment confirmation to customer email:
Stripe (payment_intent.succeeded)
└─► Next.js webhook handler
└─► BullMQ queue (deduplication + retry)
└─► Order processor worker
├─► 1. Zoho CRM — create contact + deal
├─► 2. Airtable — log production order
├─► 3. PostNord API — create shipment + label
├─► 4. Netvisor — create VAT invoice
└─► 5. Mailgun — send confirmation with tracking
Each step runs sequentially. If any step fails, the worker retries with exponential backoff and alerts via Telegram. The whole pipeline completes in under 2 minutes on a normal connection.
Step 1: The Webhook Handler
The entry point is a Next.js API route. The most important thing here is reading the raw request body for signature verification — Next.js App Router does not expose it automatically.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { orderQueue } from "@/lib/queue";
import { redis } from "@/lib/redis";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request): Promise<Response> {
// Raw body is required for signature verification
const rawBody = await request.arrayBuffer();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing stripe-signature header", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(Buffer.from(rawBody), signature, WEBHOOK_SECRET);
} catch (err) {
return new Response("Webhook signature verification failed", { status: 400 });
}
// Idempotency check: Redis stores processed event IDs for 24 hours.
// Stripe retries webhooks for up to 72 hours, so without this
// a single payment can create multiple orders.
const dedupKey = `stripe:event:${event.id}`;
const alreadyProcessed = await redis.set(dedupKey, "1", "EX", 86400, "NX");
if (alreadyProcessed === null) {
// Event already in queue or processed — respond 200 to stop Stripe retrying
return new Response("Already queued", { status: 200 });
}
if (event.type === "payment_intent.succeeded") {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await orderQueue.add(
"process-order",
{ paymentIntentId: paymentIntent.id, eventId: event.id },
{
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { count: 100 },
removeOnFail: false, // Keep failed jobs for inspection
}
);
}
// Always return 200 quickly — Stripe expects a fast response.
// The actual work happens in the BullMQ worker, not here.
return new Response("Queued", { status: 200 });
}
The key design decision: the webhook handler does almost nothing. It verifies the signature, checks for duplicates, and puts the job in a queue. If the Zoho API is slow or PostNord times out, that's the worker's problem — not the webhook endpoint's.
Step 2: The BullMQ Worker
The worker runs as a separate long-lived process. It pulls jobs off the queue and runs the pipeline steps in order.
// workers/order-processor.ts
import { Worker, Job } from "bullmq";
import { redis } from "@/lib/redis";
import { fetchOrderDetails } from "@/lib/stripe";
import { createZohoDeal } from "@/lib/zoho";
import { logAirtableOrder } from "@/lib/airtable";
import { createPostNordShipment } from "@/lib/postnord";
import { createNetvisorInvoice } from "@/lib/netvisor";
import { sendConfirmationEmail } from "@/lib/mailgun";
import { notifyTelegram } from "@/lib/telegram";
interface OrderJobData {
paymentIntentId: string;
eventId: string;
}
const worker = new Worker<OrderJobData>(
"orders",
async (job: Job<OrderJobData>) => {
const { paymentIntentId } = job.data;
// Fetch full order details from Stripe (customer, line items, shipping)
const order = await fetchOrderDetails(paymentIntentId);
// Each step returns data needed by subsequent steps.
// Failures throw — BullMQ handles retry with backoff.
const { dealId } = await createZohoDeal(order);
await logAirtableOrder(order, { dealId });
const { trackingNumber, labelUrl } = await createPostNordShipment(order);
const { invoiceNumber } = await createNetvisorInvoice(order, { trackingNumber });
await sendConfirmationEmail(order, { trackingNumber, labelUrl, invoiceNumber });
return { dealId, trackingNumber, invoiceNumber };
},
{ connection: redis, concurrency: 3 }
);
worker.on("failed", async (job, err) => {
if (!job) return;
// Alert on final failure (all retries exhausted)
if (job.attemptsMade >= (job.opts.attempts ?? 1)) {
await notifyTelegram(
`Order pipeline failed after ${job.attemptsMade} attempts\n` +
`Payment: ${job.data.paymentIntentId}\n` +
`Error: ${err.message}`
);
}
});
Step 3: Zoho CRM Integration
Zoho's API requires creating a contact and a deal separately. I batch this into one logical operation:
// lib/zoho.ts
interface ZohoOrderResult {
dealId: string;
contactId: string;
}
export async function createZohoDeal(order: Order): Promise<ZohoOrderResult> {
const token = await getZohoAccessToken(); // Handles OAuth token refresh
// Upsert the contact (search by email, create if not found)
const searchResponse = await fetch(
`https://www.zohoapis.eu/crm/v3/Contacts/search?criteria=(Email:equals:${encodeURIComponent(order.customerEmail)})`,
{ headers: { Authorization: `Zoho-oauthtoken ${token}` } }
);
let contactId: string;
if (searchResponse.ok) {
const existing = await searchResponse.json();
contactId = existing.data?.[0]?.id ?? (await createContact(order, token));
} else {
contactId = await createContact(order, token);
}
// Create the deal linked to the contact
const dealResponse = await fetch("https://www.zohoapis.eu/crm/v3/Deals", {
method: "POST",
headers: {
Authorization: `Zoho-oauthtoken ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
data: [
{
Deal_Name: `Order ${order.id} — ${order.customerName}`,
Stage: "Closed Won",
Amount: order.totalAmount / 100, // Stripe stores amounts in cents
Contact_Name: { id: contactId },
Description: order.lineItems.map((i) => `${i.name} × ${i.quantity}`).join("\n"),
Shipping_Address: order.shippingAddress,
},
],
}),
});
const deal = await dealResponse.json();
const dealId = deal.data[0].details.id;
return { dealId, contactId };
}
One gotcha: Zoho's EU data center uses zohoapis.eu, not zohoapis.com. Using the wrong domain produces auth errors that look like token problems.
Step 4: PostNord Shipment Creation
PostNord's API returns a base64-encoded PDF label along with the tracking number:
// lib/postnord.ts
interface ShipmentResult {
trackingNumber: string;
labelUrl: string; // S3 URL after uploading the label PDF
}
export async function createPostNordShipment(order: Order): Promise<ShipmentResult> {
const response = await fetch("https://api2.postnord.com/rest/shipment/v5/shipment", {
method: "POST",
headers: {
"x-api-key": process.env.POSTNORD_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
shipmentServiceCode: "19", // PostNord MyPack Home
sender: {
name: "Pikkuna Oy",
address1: process.env.SENDER_ADDRESS!,
city: process.env.SENDER_CITY!,
countryCode: "FI",
},
receiver: {
name: order.customerName,
address1: order.shippingAddress.line1,
city: order.shippingAddress.city,
postCode: order.shippingAddress.postalCode,
countryCode: order.shippingAddress.country,
email: order.customerEmail,
},
parcels: [{ weight: calculateTotalWeight(order.lineItems) }],
}),
});
const data = await response.json();
const shipment = data.CompositeShipmentData[0];
const trackingNumber = shipment.parcels[0].parcelNumber;
// Decode and upload the PDF label to S3 for permanent storage
const labelPdf = Buffer.from(shipment.pdfs[0].pdf, "base64");
const labelUrl = await uploadToS3(labelPdf, `labels/${trackingNumber}.pdf`);
return { trackingNumber, labelUrl };
}
Step 5: The Confirmation Email
The final step sends a transactional email via Mailgun. Templates are stored in Mailgun — this keeps HTML out of application code and lets non-developers edit copy:
// lib/mailgun.ts
import FormData from "form-data";
import Mailgun from "mailgun.js";
export async function sendConfirmationEmail(
order: Order,
{ trackingNumber, invoiceNumber }: { trackingNumber: string; invoiceNumber: string }
): Promise<void> {
const mg = new Mailgun(FormData).client({ key: process.env.MAILGUN_API_KEY! });
await mg.messages.create(process.env.MAILGUN_DOMAIN!, {
from: "Pikkuna Orders <orders@pikkuna.fi>",
to: order.customerEmail,
subject: `Your order is confirmed — tracking ${trackingNumber}`,
template: "order-confirmation",
"h:X-Mailgun-Variables": JSON.stringify({
customer_name: order.customerName.split(" ")[0],
tracking_number: trackingNumber,
tracking_url: `https://tracking.postnord.com/en/?id=${trackingNumber}`,
invoice_number: invoiceNumber,
order_items: order.lineItems,
locale: order.locale, // Customer's language — template is multilingual
}),
});
}
Handling Failures in the Pipeline
The question I get most often: what happens when one step fails?
Steps 1 and 2 (Zoho CRM and Airtable) are logging steps. If they fail, the customer is unaffected. BullMQ retries them, and if all retries are exhausted, Telegram gets an alert.
Steps 3 and 4 (PostNord and Netvisor) are more critical. If PostNord fails, there's no tracking number and no confirmation email. The worker retries with exponential backoff: 2s, 4s, 8s, 16s, 32s — 5 attempts total. PostNord has occasional outages; backoff handles the short ones automatically. If all 5 fail, a developer manually re-queues the job from the BullMQ dashboard.
One deliberate design choice: no rollbacks. If a Zoho deal is created but PostNord fails, I don't delete the Zoho deal. Partial state in the CRM is better than losing the data entirely. The Airtable row has a status field that tracks which pipeline steps completed — it serves as the source of truth.
Gotchas Nobody Warned Me About
Stripe retries webhooks for 72 hours. Your idempotency check must survive longer than that. A 24-hour Redis TTL is usually fine, but Redis can restart. For production I also store processed event IDs in the database as a permanent record, and use Redis as a fast first-check layer only.
Zoho rate limits the token endpoint at ~100 req/min. During flash sales, token refresh calls can hit this ceiling. Cache the access token and refresh only when expiry is imminent — not on every API call.
PostNord returns 200 OK for some error conditions. {"httpStatusCode": 200, "CompositeShipmentData": []} — an empty array with a success status — appears when a service code is unavailable for the destination country. Always check that CompositeShipmentData[0] exists and treat an empty array as a hard error.
request.arrayBuffer(), not request.json(). In Next.js App Router, parsing the body first corrupts the raw bytes that Stripe's signature verification needs. This trips up everyone migrating a Pages Router webhook to App Router.
Results
After deploying this pipeline at Pikkuna:
- Processing time: 15–30 minutes manually → under 2 minutes automated
- Human error rate: Occasional wrong addresses and missing fields → zero
- Manager hours recovered: ~160–200 per month at typical order volume
- Pipeline reliability: 99.4% of orders complete with no human intervention. The remaining 0.6% are third-party API outages that resolve on retry within minutes.
The system handles 30 languages and 35 countries without any special routing logic — the customer locale flows through from Stripe payment metadata to the Mailgun template variable automatically.
If your team still manually processes orders, the question isn't whether to automate — it's which system to build for your specific stack. The tools I used (Zoho, PostNord, Netvisor, Mailgun) are specific to this project. Your business might use Salesforce, DHL, QuickBooks, and Klaviyo. The architecture is the same; the integrations are different.
I've built this kind of pipeline for Pikkuna and pi-pi.ee across 28 languages and 32 EU markets. If you need a senior developer who can own this end-to-end — get in touch. I'm available for freelance projects and longer-term engagements.
