DEV Community

Cover image for E-commerce Order Automation: Stripe + Invoice + Shipping Workflow
Iurii Rogulia
Iurii Rogulia

Posted on • Originally published at iurii.rogulia.fi

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

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 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.

slug="automation-workflows"
text="Full post-purchase pipeline automation — from Stripe webhook to shipping label, invoice, and confirmation email — without a human in the loop."
/>

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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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}`
    );
  }
});
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)