Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. E-commerce Order Automation: Stripe + Invoice + Shipping Workflow

Iurii Automates: E-commerce Order Automation: Stripe + Invoice + Shipping Workflow

How I eliminated 160+ hours/month of manual order processing at a 35-country EU store

February 22, 2026· 10 min read

E-commerce order automation that cut 160+ hours/month of manual work to zero: Stripe webhook to VAT invoice and tracking in under 2 minutes. Full pipeline with production code.

Stack

Next.jsTypeScriptNode.js

Libraries

BullMQ

Databases

Redis

Services

StripeZohoAirtablePostNordNetvisorMailgun

Topics

E-commerceAutomationWebhooksPayments
E-commerce Order Automation: Stripe + Invoice + Shipping Workflow

On this page

  • The Problem with Manual Order Processing
  • The Full Pipeline
  • Step 1: The Webhook Handler
  • Step 2: The BullMQ Worker
  • Step 3: Zoho CRM Integration
  • Step 4: PostNord Shipment Creation
  • Step 5: The Confirmation Email
  • Handling Failures in the Pipeline
  • Gotchas Nobody Warned Me About
  • Results

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 work on Pikkuna, the platform already operated across 30 languages and 32 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.

Related service

Automation & Workflows

Full post-purchase pipeline automation — from Stripe webhook to shipping label, invoice, and confirmation email — without a human in the loop.

More about this service →

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. For a deeper look at the webhook architecture itself, see Stripe Webhooks Done Right.

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 <[email protected]>",
    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 32 countries without any special routing logic — the customer locale flows through from Stripe payment metadata to the Mailgun template variable automatically. Getting that locale to behave consistently across the storefront, invoices, and emails is its own architecture problem, which I break down in handling i18n routing for 30 languages in Next.js.


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 e-commerce automation and API integration projects and longer-term engagements.

Iurii Rogulia

Working on something like this?

E-commerce

Building a multi-market e-commerce stack with Stripe, order automation, and EU fulfilment is exactly what I ship for EU brands.

More about this service

Relevant client work

View all projects
Pikkuna — E-commerce for Vinyl Curtains & PVC Products
Pikkuna — E-commerce for Vinyl Curtains & PVC Products
October 12, 2024
Pikkuna — E-commerce for Vinyl Curtains & PVC Products

International e-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe → Zoho CRM → Airtable → Mailgun →

vatnode — EU VAT Validation API
vatnode — EU VAT Validation API
January 19, 2026
vatnode — EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

PostNord Shipping Automation
PostNord Shipping Automation
November 26, 2025
PostNord Shipping Automation

Automated PostNord label generation for 20–50 daily shipments, replacing 5–7 minutes of manual portal work per order.

What clients say

“

Iurii built our entire B2B platform from scratch in a remarkably short time — a fully multilingual webshop covering 32 European markets in 28 languages, with automated VAT calculation, VIES validation, Stripe payments across 6 methods including SEPA and bank transfers, server-generated PDF invoices, and Notion as our CRM. Everything was integrated and working end-to-end, not just a prototype. As someone responsible for both sales and operations, I especially valued that he understood the business side — compliance requirements, B2B buyer expectations, different payment preferences across markets. He asked the right questions upfront, kept things transparent throughout, and delivered without dragging it out. Highly recommend.

Mark Tamm 🇪🇪

Account Manager, pi-pi.ee

Services

StripeNotion

Topics

E-commerceB2Bi18nPaymentsPDFTax/VAT
“

I'd built most of our MVP with Cursor and it looked finished — it compiled, the tests were green, the demo worked. It just wouldn't survive real users. Iurii took it over and found what I couldn't see: authentication handled four different ways, tests that only asserted what the code already did, and a dependency list half of which was unused. He didn't rewrite it from scratch — he told me honestly what was salvageable, ripped out the dead code, and got it to something a real team could build on. Two weeks and it went from 'looks done' to actually shippable.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

We wanted to add an AI feature that turns messy user notes into structured records, but our first attempt returned unpredictable JSON that broke the app half the time. Iurii rebuilt it using structured outputs against the OpenAI API with proper validation, so the data is always shaped the way our database expects. He also added a fallback path for when the model is unsure instead of letting it guess. It's been running in production for a month with no manual cleanup.

Bram de Vries 🇳🇱

Product Lead

Stack

Next.jsTypeScript

Services

OpenAI

Topics

AILLMStructured Outputs

Related articles

PostNord API and Zoho Desk Automation: Production Gotchas
April 12, 2026· 10 min
PostNord API and Zoho Desk Automation: Production Gotchas

PostNord API and Zoho Desk automation for EU e-commerce: the undocumented gotchas — 200 OK empty arrays, EU data center URLs, token caching — with production

Stack

TypeScriptNode.js

Libraries

BullMQ

Services

StripeZohoPostNord

Topics

E-commerceAutomationShippingCRM
Stripe Webhooks: Idempotency, Retries, and Queue Setup
January 2, 2026· 11 min
Stripe Webhooks: Idempotency, Retries, and Queue Setup

Stripe webhook production architecture: idempotency keys in PostgreSQL and Redis, BullMQ queue, signature verification in Next.js App Router — with full

Stack

Next.jsTypeScriptNode.js

Libraries

BullMQDrizzle ORMioredis

Databases

PostgreSQLRedis

Services

Stripe

Topics

SaaSWebhooksArchitecturePayments
Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026· 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS
Subscription Billing: The Edge Cases Stripe Docs Skip
July 22, 2026· 17 min
Subscription Billing: The Edge Cases Stripe Docs Skip

Subscription billing edge cases Stripe glosses over: proration, dunning, the state machine, cancellation timing, refunds, VAT, and webhook ordering.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQL

Services

Stripe

Topics

SaaSPaymentsE-commerceBilling
Stripe to Mollie Migration: What Actually Breaks
June 26, 2026· 21 min
Stripe to Mollie Migration: What Actually Breaks

Stripe to Mollie migration in production: mandates instead of Subscriptions, thin webhooks, no proration, no portal, no Tax. What you rebuild yourself.

Stack

Next.jsTypeScriptNode.js

Libraries

Drizzle

Databases

PostgreSQL

Services

StripeMollie

Topics

BillingPaymentsSubscriptionsMigrationVAT