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]
Back to projects

Iurii Automates: PostNord Shipping Automation

November 26, 2025

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

Stack

Next.jsTypeScript

Libraries

pdf-lib

Services

PostNordAirtableMailgun

Topics

ShippingLabel GenerationOrder Automation

Key Results

  • Cut 5–7 minutes of manual shipping work per order down to zero
  • Freed staff from up to 5 hours a day of copy-paste portal work
  • Fewer mistakes — shipment errors down to a few isolated cases per month
  • 20–50 shipments a day booked, labelled and tracked automatically
PostNord Shipping Automation

The Business Problem

Before this integration, every PostNord shipment at Pikkuna was created by hand. A staff member would open the order in Airtable, copy the recipient's name, address, and phone number into the PostNord web portal, generate a label, download it, upload it back to the record, and then manually email the tracking number to the customer. Five to seven minutes per shipment. With 20–50 shipments going out each day, that was up to five hours of pure mechanical work — the kind that produces typos, missed tracking emails, and staff who have better things to do.

The requirements were straightforward:

  • Trigger from Airtable automation when an order is marked ready at the factory
  • Pull customer data directly from the Airtable record — no separate CRM lookup
  • Book a PostNord MyPack Home shipment and generate a thermal label (100×150mm)
  • Store the label as an attachment on the Airtable record and save the tracking number
  • Return the label as base64 in the response so the caller can print immediately
  • Handle multi-package orders without any extra manual steps

The Solution

I replaced the entire manual routine with a single automated step. When the factory marks an order ready in Airtable, the system now takes over: it reads the customer's details, books the PostNord shipment, generates the shipping label, saves the tracking number, and stores the label back on the order — all without anyone opening the shipping portal.

Multi-package orders are handled the same way, with their labels combined into one file ready to print. If saving the label to Airtable ever fails, the shipment still goes through and the label is returned so warehouse staff can print immediately — a storage hiccup never blocks a shipment. The result is that the five-to-seven-minute manual routine per order disappears, along with the typos and forgotten tracking emails that came with it.

Results

MetricValue
Manual work per shipment5–7 min portal work → single API call
Daily shipments automated20–50
Shipment errorsReduced to a few isolated cases per month
Multi-package supportMerged thermal PDF per order
Label formatPDF 100×150mm (PostNord LABEL)
TriggerGET /api/postnord/create-shipment?id={orderId}
Data sourceAirtable (no CRM lookup)
Tracking storageAirtable field + base64 in API response

Under the Hood

For the technically curious, here is how the integration is built. It lives in a single Next.js route handler: a GET request to /api/postnord/create-shipment?id={orderId}. Airtable automation calls this endpoint when the factory marks an order ready. The handler reads the order fields, books the shipment through the PostNord REST API v3, and stores everything back — label and tracking number — before returning a response.

There is no SDK for PostNord, so the integration uses native fetch throughout.

Environment Switching Between Sandbox and Production

PostNord provides separate sandbox and production environments with different base URLs and API keys. The handler selects the correct credentials at startup based on a single environment variable:

const POSTNORD_MODE = process.env.POSTNORD_MODE || "sandbox";
const IS_PRODUCTION = POSTNORD_MODE === "production";
 
const POSTNORD_API_KEY = IS_PRODUCTION
  ? process.env.POSTNORD_PRODUCTION_API_KEY
  : process.env.POSTNORD_SANDBOX_API_KEY;
 
const POSTNORD_BASE_URL = IS_PRODUCTION
  ? process.env.POSTNORD_PRODUCTION_BASE_URL // https://api2.postnord.com
  : process.env.POSTNORD_SANDBOX_BASE_URL; // https://atapi2.postnord.com
 
const POSTNORD_APPLICATION_ID = IS_PRODUCTION
  ? process.env.POSTNORD_PRODUCTION_APPLICATION_ID || "2477"
  : process.env.POSTNORD_SANDBOX_APPLICATION_ID || "1438";

The API key is passed as a query parameter on every request — PostNord's convention, not an Authorization header. Switching from sandbox to production during development required only a single env change; nothing in the request logic changes.

Address Normalization and Edge Cases

Airtable lookup fields return arrays rather than strings. Country codes are not always ISO 3166-1 alpha-2 — the UK arrives as UK rather than GB, which PostNord rejects. Phone numbers come in mixed formats. All of this needs to be corrected before the PostNord request goes out:

// Airtable lookup fields return arrays
let code = Array.isArray(countryCode) ? countryCode[0] : countryCode;
 
// UK → GB (PostNord requires ISO 3166-1 alpha-2)
if (code === "UK") code = "GB";
 
// Phone normalization
let formatted = phone.replace(/[\s\-]+/g, "");
if (!formatted.startsWith("+")) formatted = "+" + formatted;
 
// Weight and package count default to prevent PostNord rejection
const weight = Number(fields.weight) || 1;
const numberOfPackages = Number(fields.numberOfPackages) || 1;
 
// loadingDate must always be in the future (PostNord requirement)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const loadingDate = tomorrow.toISOString();

There was also a legacy data problem. Before November 2025, orders in Airtable stored the delivery address as a single concatenated string — "Street, PostalCode City, Country". When structured fields were introduced, old records were not back-filled. The handler detects the missing fields and falls back to parsing the legacy string:

// Fallback: handle orders created before separate address fields existed
if ((!fields.delivery_street || !fields.delivery_city) && fields.deliveryAddress) {
  const addressParts = (fields.deliveryAddress as string).split(",");
  // Parse "Street, PostalCode City, Country" legacy format
}

Booking the Shipment

Every shipment uses service code 17 (MyPack Home — door-to-door). There are no pickup points and no per-country product variations. The consignor is always Suomen Pehmeä Ikkuna Oy in Savitaipale, Finland. The consignee fields come from the normalized Airtable data:

const postnordRequest = {
  messageDate: new Date().toISOString(),
  messageFunction: "Instruction",
  messageId: `ORDER_${orderNumber}_${Date.now()}`,
  application: {
    applicationId: parseInt(POSTNORD_APPLICATION_ID, 10),
    name: "Pikkuna",
    version: "1.0",
  },
  updateIndicator: "Original",
  shipment: [
    {
      service: { basicServiceCode: "17" }, // MyPack Home
      numberOfPackages: { value: numberOfPackages },
      totalGrossWeight: { value: weight, unit: "KGM" },
      dateAndTimes: { loadingDate },
      parties: {
        consignor: {
          issuerCode: "Z14",
          partyIdentification: { partyId: "20839844", partyIdType: "160" },
          party: {
            nameIdentification: { name: "Suomen Pehmeä Ikkuna Oy" },
            address: {
              streets: ["Teollisuustie 10"],
              postalCode: "54800",
              city: "Savitaipale",
              countryCode: "FI",
            },
          },
        },
        consignee: {
          party: {
            nameIdentification: { name: recipientName },
            address: {
              streets: [deliveryStreet],
              postalCode: deliveryPostalCode,
              city: deliveryCity,
              countryCode: recipientCountryCode,
            },
            contact: {
              contactName: recipientName,
              emailAddress: recipientEmail,
              smsNo: recipientPhone,
            },
          },
        },
      },
      goodsItem: Array.from({ length: numberOfPackages }, () => ({
        packageTypeCode: "PC",
        items: [{ grossWeight: { value: weight / numberOfPackages, unit: "KGM" } }],
      })),
    },
  ],
};

PostNord returns tracking numbers and URLs nested inside idInformation — an array of objects, each containing its own ids and urls arrays. Multi-package shipments produce multiple tracking numbers. The handler collects them all:

const idInformation = bookingResponse?.idInformation || [];
 
const trackingNumbers: string[] = [];
const trackingUrls: string[] = [];
 
for (const idInfo of idInformation) {
  if (idInfo?.ids && Array.isArray(idInfo.ids)) {
    for (const id of idInfo.ids) {
      if (id?.value) trackingNumbers.push(id.value);
    }
  }
  if (idInfo?.urls && Array.isArray(idInfo.urls)) {
    for (const url of idInfo.urls) {
      if (url?.url) trackingUrls.push(url.url);
    }
  }
}
 
const trackingNumber = trackingNumbers.join(", ");

Multi-Package PDF Merging

PostNord returns one label per package. For multi-package shipments, the labels need to be merged into a single PDF before storage. This was added in late November 2025 when the first multi-package orders came through:

const mergePDFs = async (base64PDFs: string[]): Promise<string> => {
  if (base64PDFs.length === 1) return base64PDFs[0];
 
  const mergedPdf = await PDFDocument.create();
  for (const base64Pdf of base64PDFs) {
    const pdfBytes = Buffer.from(base64Pdf, "base64");
    const pdf = await PDFDocument.load(pdfBytes);
    const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
    copiedPages.forEach((page) => mergedPdf.addPage(page));
  }
 
  const mergedPdfBytes = await mergedPdf.save();
  return Buffer.from(mergedPdfBytes).toString("base64");
};

The label format also changed during development. The initial implementation requested paperSize=A4. When the labels arrived, the thermal printers at the warehouse rejected them — thermal printers expect paperSize=LABEL (100×150mm). One query parameter change, but it only becomes obvious after you have a physical printer in front of you.

Storing the Label in Airtable

Airtable splits its API surface across two subdomains. Metadata updates — writing the tracking number to a field — go to api.airtable.com. Uploading file attachments goes to a separate endpoint at content.airtable.com. Both calls are required, and using the wrong subdomain for either produces an opaque error:

// 1. Clear the previous label attachment (field name typo is in production)
await fetch(`https://api.airtable.com/v0/${BASE_ID}/${TABLE_ID}/${orderId}`, {
  method: "PATCH",
  body: JSON.stringify({ fields: { shippmentLabel: [] } }),
});
 
// 2. Upload the merged PDF to the content subdomain
const uploadResponse = await fetch(
  `https://content.airtable.com/v0/${BASE_ID}/${orderId}/shippmentLabel/uploadAttachment`,
  {
    method: "POST",
    body: JSON.stringify({
      file: mergedLabelData,
      contentType: "application/pdf",
      filename: `postnord_label_${orderNumber}.pdf`,
    }),
  }
);

If the Airtable upload fails, the handler logs the error but still returns a successful response with the label as base64. The calling system — whether Airtable automation or the admin UI — can print the label immediately without waiting on Airtable's content API. This avoids blocking a shipment over a storage failure that has no bearing on whether PostNord accepted the booking.

Evolution

The integration went through four distinct changes driven by real operational problems:

DateChange
2025-11-24Added separate delivery_street, delivery_city, delivery_postalCode Airtable fields
2025-11-25Added legacy address fallback parser — old orders only had a concatenated string
2025-11-26Added pdf-lib, multi-package PDF merging; switched paperSize=A4 → paperSize=LABEL after thermal printers rejected A4 labels
2026-02-02Dynamic application ID via env vars for sandbox/production parity

None of these were speculative design decisions. Each one was a concrete failure in the first days of operation that required a concrete fix.

Iurii RoguliaAvailable

Need something similar?

I build custom solutions — from APIs to full products. Let's talk about your project.

View all projects

Related 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 →

Stack

Next.jsReactTypeScript

Libraries

next-intlZodpdf-libPuppeteerStripe.js

Databases

Redis

Services

StripeZohoMailgunPostNordNetvisorVercelVercel BlobGoogle AnalyticsMeta CAPIAirtableUpstash

Topics

E-commercePaymentsShippingPDFPWACTOArchitectureSEOSchema.orgi18nPerformance
HTPBE? — Has This PDF Been Edited?
HTPBE? — Has This PDF Been Edited?
September 25, 2024
HTPBE? — Has This PDF Been Edited?

SaaS platform for PDF authenticity verification with a public REST API.

Stack

Next.jsReactTypeScript

Libraries

Drizzle ORMZodNextAuth.jspdf-lib

Databases

PostgreSQL

Services

MollieResendGoogle Analytics

Topics

SaaSPDFAPIAuthSecuritySEOSchema.org

Related posts

E-commerce Order Automation: Stripe + Invoice + Shipping Workflow
February 22, 2026· 10 min
E-commerce Order Automation: Stripe + Invoice + Shipping Workflow

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.

Stack

Next.jsTypeScriptNode.js

Libraries

BullMQ

Databases

Redis

Services

StripeZohoAirtablePostNordNetvisorMailgun

Topics

E-commerceAutomationWebhooksPayments
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