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. PostNord API and Zoho Desk Automation: Production Gotchas

Iurii Automates: PostNord API and Zoho Desk Automation: Production Gotchas

The PostNord and Zoho Desk quirks that will silently break your EU order fulfilment pipeline — and how to fix them

April 12, 2026· 10 min read

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

Stack

TypeScriptNode.js

Libraries

BullMQ

Services

StripeZohoPostNord

Topics

E-commerceAutomationShippingCRM
PostNord API and Zoho Desk Automation: Production Gotchas

On this page

  • PostNord: The API That Returns 200 for Errors
  • Building the Shipment Payload
  • Storing the Label PDF
  • Zoho CRM: The EU Data Center Trap
  • Zoho Desk: Auto-Creating Logistics Tickets
  • Zoho Desk Contact Lookup
  • OAuth Token Management
  • Full Pipeline in the Worker
  • Production Gotchas Summary

I've written before about the overall automation pipeline at pikkuna.fi — the high-level flow from Stripe webhook to customer confirmation in under 2 minutes. This article is the deep cut: specifically PostNord and Zoho Desk, the parts that required the most trial and error to get right in production.

If you're integrating with PostNord's API or building automated Zoho Desk ticket creation, you'll hit the same walls. This is everything I learned.

PostNord: The API That Returns 200 for Errors

Related service

Automation & Workflows

Integrating PostNord, Zoho CRM, and Zoho Desk into a single fulfilment pipeline? I've built and debugged this in production — service codes, EU data centers, token caching, and all.

More about this service →

PostNord's API looks normal on the surface — REST endpoints, JSON, API key authentication. The first surprise comes when you make a successful-looking request and get back this:

{
  "httpStatusCode": 200,
  "CompositeShipmentData": []
}

HTTP 200. Empty shipment array. No error field. No message.

This is PostNord's way of telling you that the service code you requested is unavailable for the destination country. If you're checking response.ok and moving on, you just silently failed to create a shipping label. The order will proceed through your pipeline — Zoho CRM updated, accounting updated, confirmation email sent — with no tracking number and no label.

The correct implementation: always check that CompositeShipmentData is non-empty and treat an empty array as a hard error that should retry:

// lib/shipping/postnord.ts
interface PostNordShipmentResponse {
  httpStatusCode: number;
  CompositeShipmentData: PostNordShipmentData[];
}
 
interface PostNordShipmentData {
  parcels: Array<{
    parcelNumber: string;
    pdfs: Array<{
      pdf: string; // base64-encoded PDF
      fileName: string;
    }>;
  }>;
}
 
export async function createPostNordShipment(
  order: Order
): Promise<{ trackingNumber: string; labelPdf: Buffer }> {
  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",
      Accept: "application/json",
    },
    body: JSON.stringify(buildShipmentPayload(order)),
  });
 
  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`PostNord API error ${response.status}: ${errorText}`);
  }
 
  const data = (await response.json()) as PostNordShipmentResponse;
 
  // This is the critical check — HTTP 200 does NOT mean success
  if (!data.CompositeShipmentData || data.CompositeShipmentData.length === 0) {
    throw new Error(
      `PostNord returned 200 but no shipment data. ` +
        `Service code may be unavailable for country: ${order.shippingAddress.country}`
    );
  }
 
  const shipmentData = data.CompositeShipmentData[0];
  const parcel = shipmentData.parcels[0];
 
  if (!parcel) {
    throw new Error("PostNord returned shipment data with no parcels");
  }
 
  const trackingNumber = parcel.parcelNumber;
 
  // Label is base64-encoded PDF
  const labelPdf = Buffer.from(parcel.pdfs[0].pdf, "base64");
 
  return { trackingNumber, labelPdf };
}

Building the Shipment Payload

PostNord's payload structure is verbose. Here's the production version with all the fields that matter:

// lib/shipping/postnord.ts
function buildShipmentPayload(order: Order): PostNordShipmentRequest {
  return {
    shipmentServiceCode: getServiceCode(order.shippingAddress.country),
    shipmentDate: new Date().toISOString().split("T")[0], // YYYY-MM-DD
    sender: {
      quickId: process.env.POSTNORD_SENDER_QUICK_ID!, // Your registered sender ID
      name: process.env.POSTNORD_SENDER_NAME!,
      address: {
        street1: process.env.POSTNORD_SENDER_STREET!,
        city: process.env.POSTNORD_SENDER_CITY!,
        stateOrProvinceCode: process.env.POSTNORD_SENDER_STATE!,
        postalCode: process.env.POSTNORD_SENDER_POSTAL!,
        countryCode: "FI",
      },
    },
    receiver: {
      name: order.customerName,
      address: {
        street1: order.shippingAddress.line1,
        street2: order.shippingAddress.line2 || undefined,
        city: order.shippingAddress.city,
        postalCode: order.shippingAddress.postalCode,
        countryCode: order.shippingAddress.country,
      },
      contact: {
        email: order.customerEmail,
        // Mobile required for MyPack Home — PostNord sends delivery notifications
        mobile: order.customerPhone || undefined,
      },
    },
    parcels: [
      {
        weight: calculateTotalWeight(order.lineItems),
        comment: `Order ${order.id}`, // Appears on the label
      },
    ],
    orderReference: order.id, // Your internal reference — returned in webhook callbacks
    printMedia: [
      {
        printMediaType: "PDF",
        mediaSize: "A4",
      },
    ],
  };
}
 
// Service code selection by destination country
function getServiceCode(countryCode: string): string {
  // Finland domestic
  if (countryCode === "FI") return "19"; // MyPack Home
 
  // Nordic countries
  if (["SE", "NO", "DK"].includes(countryCode)) return "PD2"; // Parcel Connect
 
  // EU
  return "PD2"; // Parcel Connect covers most EU destinations
 
  // If you ship to specific countries that need different services,
  // add them here — this is where 200+empty often originates
}

The service code issue: PostNord has different service codes for different shipping products, and not every product ships to every country. MyPack Home (code 19) is Finland domestic only. Using it for a German delivery returns 200 + empty array. Map your destination countries to the correct service codes explicitly, and add the destination country to your error message so debugging is fast.

Storing the Label PDF

The label PDF needs to be stored somewhere accessible:

  • The operations team needs to print it
  • It should be attached to the Zoho Desk ticket (more on that below)
  • It should survive if the VPS restarts

I upload to Vercel Blob with the tracking number as the filename:

// lib/shipping/store-label.ts
import { put } from "@vercel/blob";
 
export async function storeShippingLabel(
  labelPdf: Buffer,
  trackingNumber: string
): Promise<string> {
  const blob = await put(`shipping-labels/${trackingNumber}.pdf`, labelPdf, {
    access: "public",
    contentType: "application/pdf",
  });
 
  return blob.url;
}

The URL is what I pass downstream — to Zoho CRM (stored on the Deal), to Zoho Desk (attached to the ticket), and to the customer email.

Zoho CRM: The EU Data Center Trap

Before the Zoho Desk integration, a quick note about Zoho CRM that trips up many EU developers: Zoho has separate data centers for EU and non-EU customers. If your Zoho account is in the EU data center (which it should be if you're an EU business), your API base URL is zohoapis.eu, not zohoapis.com.

// WRONG — will produce auth errors that look like token problems
const BASE_URL = "https://www.zohoapis.com/crm/v3";
 
// CORRECT for EU data center accounts
const BASE_URL = "https://www.zohoapis.eu/crm/v3";

The error message you get with the wrong data center is something like INVALID_TOKEN — which leads you to debug your OAuth flow for an hour before you realize it's a URL issue.

Zoho Desk: Auto-Creating Logistics Tickets

After a PostNord shipment is created, I automatically create a Zoho Desk ticket with:

  • The customer details
  • The order number
  • The tracking number
  • The shipping label PDF attached (as a URL or file)

This gives the operations team a single view: all logistics issues appear as Desk tickets, linked to the CRM deal, with the label ready to print.

// lib/crm/zoho-desk.ts
interface ZohoDeskTicketResult {
  ticketId: string;
  ticketNumber: string;
}
 
export async function createShipmentTicket(
  order: Order,
  trackingNumber: string,
  labelUrl: string,
  dealId: string
): Promise<ZohoDeskTicketResult> {
  const token = await getZohoAccessToken(); // Shared OAuth token cache with CRM
 
  // Create the ticket
  const ticketResponse = await fetch("https://desk.zoho.eu/api/v1/tickets", {
    method: "POST",
    headers: {
      Authorization: `Zoho-oauthtoken ${token}`,
      "Content-Type": "application/json",
      orgId: process.env.ZOHO_DESK_ORG_ID!,
    },
    body: JSON.stringify({
      subject: `Shipment — Order ${order.id} — ${order.customerName}`,
      description: buildTicketDescription(order, trackingNumber, labelUrl),
      status: "Open",
      priority: "Medium",
      channel: "Automated",
      contactId: await findOrCreateDeskContact(order, token),
      // CRM Deal ID — links ticket to the deal in CRM
      cf: {
        cf_crm_deal_id: dealId,
        cf_tracking_number: trackingNumber,
        cf_label_url: labelUrl,
      },
      departmentId: process.env.ZOHO_DESK_LOGISTICS_DEPT_ID!,
    }),
  });
 
  if (!ticketResponse.ok) {
    const error = await ticketResponse.text();
    throw new Error(`Zoho Desk ticket creation failed: ${error}`);
  }
 
  const ticket = await ticketResponse.json();
  return {
    ticketId: ticket.id,
    ticketNumber: ticket.ticketNumber,
  };
}
 
function buildTicketDescription(order: Order, trackingNumber: string, labelUrl: string): string {
  return `
<b>Order:</b> ${order.id}<br>
<b>Customer:</b> ${order.customerName}<br>
<b>Email:</b> ${order.customerEmail}<br>
<b>Shipping address:</b> ${order.shippingAddress.line1}, ${order.shippingAddress.city}, ${order.shippingAddress.country}<br>
<br>
<b>Tracking number:</b> ${trackingNumber}<br>
<b>PostNord tracking:</b> <a href="https://tracking.postnord.com/en/?id=${trackingNumber}">Track parcel</a><br>
<b>Shipping label:</b> <a href="${labelUrl}">Download PDF</a><br>
<br>
<b>Items:</b><br>
${order.lineItems.map((i) => `• ${i.name} × ${i.quantity}`).join("<br>")}
  `.trim();
}

Zoho Desk Contact Lookup

Desk contacts are separate from CRM contacts. When creating a ticket, you need a Desk contact ID — not a CRM contact ID. This is a common source of confusion.

// lib/crm/zoho-desk.ts
async function findOrCreateDeskContact(order: Order, token: string): Promise<string> {
  // Search for existing Desk contact by email
  const searchResponse = await fetch(
    `https://desk.zoho.eu/api/v1/contacts/search?email=${encodeURIComponent(order.customerEmail)}`,
    {
      headers: {
        Authorization: `Zoho-oauthtoken ${token}`,
        orgId: process.env.ZOHO_DESK_ORG_ID!,
      },
    }
  );
 
  const searchData = await searchResponse.json();
  const existingContact = searchData.data?.[0];
 
  if (existingContact) {
    return existingContact.id;
  }
 
  // Create new Desk contact
  const createResponse = await fetch("https://desk.zoho.eu/api/v1/contacts", {
    method: "POST",
    headers: {
      Authorization: `Zoho-oauthtoken ${token}`,
      "Content-Type": "application/json",
      orgId: process.env.ZOHO_DESK_ORG_ID!,
    },
    body: JSON.stringify({
      lastName: order.customerName.split(" ").slice(-1)[0] || order.customerName,
      firstName: order.customerName.split(" ").slice(0, -1).join(" ") || "",
      email: order.customerEmail,
      phone: order.customerPhone || undefined,
    }),
  });
 
  if (!createResponse.ok) {
    throw new Error(`Failed to create Desk contact: ${await createResponse.text()}`);
  }
 
  const newContact = await createResponse.json();
  return newContact.id;
}

OAuth Token Management

Both Zoho CRM and Zoho Desk use the same OAuth token (if you requested both scopes during initial authorization). Tokens expire after one hour — the most common production failure mode is calling Zoho with an expired token and getting an INVALID_TOKEN error.

The fix: cache the token with an expiry, refresh before expiry:

// lib/crm/zoho-auth.ts
interface CachedToken {
  accessToken: string;
  expiresAt: number; // Unix timestamp in ms
}
 
let tokenCache: CachedToken | null = null;
 
export async function getZohoAccessToken(): Promise<string> {
  // Refresh if within 5 minutes of expiry
  if (tokenCache && tokenCache.expiresAt - Date.now() > 5 * 60 * 1000) {
    return tokenCache.accessToken;
  }
 
  const response = await fetch("https://accounts.zoho.eu/oauth/v2/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      refresh_token: process.env.ZOHO_REFRESH_TOKEN!,
      client_id: process.env.ZOHO_CLIENT_ID!,
      client_secret: process.env.ZOHO_CLIENT_SECRET!,
      grant_type: "refresh_token",
    }),
  });
 
  if (!response.ok) {
    throw new Error(`Zoho token refresh failed: ${await response.text()}`);
  }
 
  const data = await response.json();
 
  // Token is valid for 3600 seconds (1 hour)
  tokenCache = {
    accessToken: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };
 
  return tokenCache.accessToken;
}

Note: accounts.zoho.eu for EU data center accounts, not accounts.zoho.com. Same trap as the API URL.

Zoho's token refresh endpoint is rate-limited at approximately 100 requests per minute. During a flash sale with high order volume, if every order job independently refreshes the token, you'll hit this limit. The module-level tokenCache solves this for a single process. If you run multiple worker processes, you'd want to store the token in Redis instead.

Full Pipeline in the Worker

Putting it together — the complete order fulfilment step in the BullMQ worker:

// workers/fulfillment.worker.ts
import { Worker, Job } from "bullmq";
import { redis } from "@/lib/redis";
import { fetchOrderFromStripe } from "@/lib/payments/stripe";
import { createPostNordShipment } from "@/lib/shipping/postnord";
import { storeShippingLabel } from "@/lib/shipping/store-label";
import { createZohoDeal } from "@/lib/crm/zoho-crm";
import { createShipmentTicket } from "@/lib/crm/zoho-desk";
import { sendOrderConfirmationWithInvoice } from "@/lib/email/send-order-confirmation";
import { notifyTelegram } from "@/lib/monitoring/telegram";
 
interface FulfillmentJobData {
  paymentIntentId: string;
  eventId: string;
}
 
const worker = new Worker<FulfillmentJobData>(
  "fulfillment",
  async (job: Job<FulfillmentJobData>) => {
    const order = await fetchOrderFromStripe(job.data.paymentIntentId);
 
    // 1. CRM — non-blocking, runs first so deal ID is available
    const { dealId } = await createZohoDeal(order);
 
    // 2. Shipping label — the critical step
    const { trackingNumber, labelPdf } = await createPostNordShipment(order);
    const labelUrl = await storeShippingLabel(labelPdf, trackingNumber);
 
    // 3. Desk ticket — logistics team visibility
    await createShipmentTicket(order, trackingNumber, labelUrl, dealId);
 
    // 4. Customer email with invoice and tracking
    await sendOrderConfirmationWithInvoice({
      ...mapOrderToEmailData(order),
      trackingUrl: `https://tracking.postnord.com/en/?id=${trackingNumber}`,
    });
 
    return { dealId, trackingNumber, labelUrl };
  },
  {
    connection: redis,
    concurrency: 3,
  }
);
 
worker.on("failed", async (job, err) => {
  if (!job) return;
  const isLastAttempt = job.attemptsMade >= (job.opts.attempts ?? 1);
  if (isLastAttempt) {
    await notifyTelegram(
      `Fulfillment pipeline failed permanently\n` +
        `PaymentIntent: ${job.data.paymentIntentId}\n` +
        `Error: ${err.message}\n` +
        `Attempts: ${job.attemptsMade}`
    );
  }
});

Production Gotchas Summary

ProblemSymptomFix
PostNord 200 + empty arraySilently missing tracking numbersAlways check CompositeShipmentData.length > 0
Wrong Zoho data center URLINVALID_TOKEN error on valid tokensUse zohoapis.eu and accounts.zoho.eu for EU accounts
Token refresh on every requestRate limit errors during flash salesCache token in module scope or Redis, refresh proactively
Desk vs CRM contact IDsTicket creation fails with INVALID_CONTACTDesk has separate contact storage — search/create in Desk specifically
No phone in PostNord payloadMyPack Home delivery notifications don't sendInclude receiver.contact.mobile when available
Service code for wrong countryPostNord 200 + empty arrayMap destination countries to correct service codes explicitly

If you're building post-purchase automation for an EU e-commerce platform — shipping labels, CRM updates, logistics tickets — and you're hitting these exact issues, I've been through them in production at Pikkuna. Get in touch for automation workflows and API integration projects.

I'm available for freelance projects and long-term engagements.


Related posts: How I Fully Automated E-commerce Order Processing — the full pipeline overview. Stripe Webhooks Done Right — the webhook architecture that drives this pipeline.

Related project: Pikkuna E-commerce Platform — the system this pipeline runs in production.

Iurii Rogulia

Working on something like this?

Automation & Workflows

Building post-purchase fulfilment automation with PostNord, Zoho, and BullMQ? I've been through these exact issues in production on EU e-commerce platforms.

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.

pi-pi.ee — B2B E-commerce for Waterless Urinal Systems
pi-pi.ee — B2B E-commerce for Waterless Urinal Systems
January 17, 2026
pi-pi.ee — B2B E-commerce for Waterless Urinal Systems

i18n B2B e-commerce platform for waterless urinal products across 32 European countries with automated VAT handling, PDF invoicing, and CRM integration.

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
“

We had three CRM options on the table and kept going in circles for weeks. Sent Iurii our list of needs, he came back the next day with a clear pick and a short explanation why the other two wouldn't work for us. Done.

Giorgi Beridze 🇬🇪

Topics

E-commerceArchitectureCRM
“

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

Related articles

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
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
BullMQ vs pg-boss vs Cron: Node.js Background Jobs Compared
May 18, 2026· 15 min
BullMQ vs pg-boss vs Cron: Node.js Background Jobs Compared

BullMQ vs pg-boss vs node-cron for Node.js background jobs. Trade-offs between Redis and Postgres queues, retries, deduplication, and production monitoring.

Stack

Node.jsTypeScript

Libraries

BullMQpg-boss

Databases

PostgreSQLRedis

Topics

ArchitectureSaaSAutomation
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