Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in Shopify: E-Invoicing with a Node.js Custom App and One API Call

Many German B2B merchants sell on Shopify. They must send and receive e-invoices now. This guide shows how a small Node.js custom app turns every paid Shopify order into a ZUGFeRD invoice with one REST API. The app reads the order with the Admin GraphQL API and links the PDF to the order in a metafield. It also covers email delivery by the API, XRechnung for public buyers, B2B companies, and checking supplier invoices.

This guide adds ZUGFeRD and XRechnung to Shopify. You build one small custom app in Node.js. It listens to the orders/paid webhook, reads the order with the Admin GraphQL API and calls one REST API. The ZUGFeRD PDF is linked to the order in a metafield. It works for normal Shopify stores and for Shopify Plus stores with B2B companies. The email section shows how the API sends the invoice to the customer. The import section shows how to check the supplier invoices you receive. All code uses Node.js 20 with built-in fetch, FormData and Blob. No npm package is needed. The Node.js guide explains the API calls outside Shopify. This guide does not repeat it.


Why Shopify needs ZUGFeRD now

German merchants on Shopify must send and receive structured e-invoices. Since January 2025, every German business must be able to receive e-invoices. From January 2027, businesses with more than EUR 800,000 turnover in the previous year must issue them. From January 2028, all businesses must. This applies to invoices between businesses. A Shopify store that sells to companies needs ZUGFeRD for those orders. The Germany mandate page lists the dates and formats.

A printed order PDF is not enough. An invoice that Shopify prints from a template is a plain PDF. People can read it, but software cannot. The mandate asks for structured data. ZUGFeRD adds this data as XML inside the PDF. The customer still opens a normal PDF, and their accounting software reads the XML.

The app does not change when the formats change. ZUGFeRD, XRechnung and the EN 16931 rules get new versions over time. The API switches to each new version on its effective date. Your app sends the same JSON before and after. You do not redeploy anything.


What you need

  • A Shopify store and a staff account that may create and install apps. B2B companies are optional.
  • Node.js 20 or later. It has fetch, FormData, Blob, node:http and node:crypto built in. The code is plain JavaScript with ES modules.
  • A server with a public HTTPS address for the webhook, for example https://einvoice.meinshop.de. A small VM, a container or any Node.js host works.
  • Your company data: name, VAT id, address and IBAN. The app reads them from environment variables as the seller.
  • An InvoiceXML account. Create one. You get an API key and 100 free credits. No credit card is needed.

How the integration works

A customer pays an order in Shopify. Shopify sends the orders/paid webhook to your app. The app reads the order through the Admin GraphQL API, builds a JSON body and posts it to one endpoint. The API calculates the totals and the VAT, creates the XML, embeds it in a PDF/A-3 file, validates it and returns the PDF. The app uploads the PDF to Shopify Files and links it to the order, or lets the API email it to the customer.

CustomerFormatEndpointResult
German business (B2B)ZUGFeRD EN 16931/v1/create/zugferdHybrid PDF/A-3
German business, delivered by the APIZUGFeRD EN 16931/v1/create/zugferd/emailEmail with the PDF attached
German public buyer (B2G)XRechnung/v1/create/xrechnungXML with Leitweg-ID
French businessFactur-X EN 16931/v1/create/facturxHybrid PDF/A-3

All endpoints take the same JSON body. The app has six small files:

  • shopify.js: the access token and the Admin GraphQL call.
  • invoicexml.js: one function for JSON bodies and one for file uploads.
  • mapping.js: the order query and the mapping to the invoice JSON.
  • einvoice.js: create or email the invoice for one order.
  • storage.js: upload the file to Shopify and set the order metafield.
  • server.js and inbound.js: the webhook, the upload route for supplier invoices and the import.

From Shopify, the app uses the Order, LineItem, ShippingLine, TaxLine, MailingAddress, Customer and PurchasingCompany objects, the Files API, metafields and webhook subscriptions.


Step 1: Create the custom app and store the keys

New custom apps for a single store are created in the Shopify Dev Dashboard. Create it like this:

  1. Open the Dev Dashboard at dev.shopify.com/dashboard. Under Apps, select Create app, then Start from Dev Dashboard. Name the app.
  2. On the Versions page, set the Webhooks API version to 2026-10. Add the scopes read_orders, write_orders, read_customers and write_files. Select Release.
  3. Select Install app and install it on your store.
  4. Under Settings, copy the Client ID and the Client secret.

The server gets its access token with the client credentials grant. It posts the client id and the client secret to the store and receives a token for 24 hours. This works because the app and the store are in the same organization. Shopify no longer lets you create new custom apps in the store admin. If your store already has one from Apps, Develop apps, its Admin API access token works too: put it in place of the token request. The Shopify CLI creates the same kind of app from a template, if you prefer that.

Keep every key in environment variables. Node.js 20 reads a .env file with the --env-file flag:

# .env (never commit this file)
SHOPIFY_SHOP=meinshop                  # the store is meinshop.myshopify.com
SHOPIFY_CLIENT_ID=your-client-id
SHOPIFY_CLIENT_SECRET=your-client-secret
SHOPIFY_API_VERSION=2026-10
INVOICEXML_API_KEY=your-invoicexml-key
[email protected]      # an address on your verified sending domain
INBOUND_SECRET=a-long-random-string
MAIL_API_URL=https://api.your-mail-provider.example/send
MAIL_API_KEY=your-mail-key
SELLER_NAME=Meinshop GmbH
SELLER_VAT_ID=DE123456789
SELLER_STREET=Hafenstr. 12
SELLER_CITY=Hamburg
SELLER_ZIP=20457
SELLER_IBAN=DE02120300000000202051
SELLER_CONTACT_NAME=Buchhaltung
SELLER_CONTACT_PHONE=+49 40 1234567
[email protected]

# Start the app
node --env-file=.env server.js

Two helper files do all HTTP work. shopify.js caches the token and calls the GraphQL endpoint. invoicexml.js calls the InvoiceXML API:

// shopify.js: access token (client credentials grant) and Admin GraphQL calls
const SHOP = process.env.SHOPIFY_SHOP;
const API_VERSION = process.env.SHOPIFY_API_VERSION;
let token = null;   // { value, expiresAt }

async function accessToken() {
  if (token && Date.now() < token.expiresAt) return token.value;
  const response = await fetch(`https://${SHOP}.myshopify.com/admin/oauth/access_token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_id: process.env.SHOPIFY_CLIENT_ID,
      client_secret: process.env.SHOPIFY_CLIENT_SECRET,
    }),
  });
  if (!response.ok) throw new Error(`Shopify token ${response.status}: ${await response.text()}`);
  const data = await response.json();
  // expires_in is in seconds (24 hours). Renew the token 10 minutes early.
  token = { value: data.access_token, expiresAt: Date.now() + (data.expires_in - 600) * 1000 };
  return token.value;
}

export async function shopifyGraphql(query, variables = {}) {
  const response = await fetch(`https://${SHOP}.myshopify.com/admin/api/${API_VERSION}/graphql.json`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": await accessToken() },
    body: JSON.stringify({ query, variables }),
  });
  const result = await response.json();
  if (!response.ok || result.errors) throw new Error(`Shopify: ${JSON.stringify(result.errors ?? result)}`);
  return result.data;
}

// invoicexml.js: one function for JSON bodies, one for file uploads
const BASE_URL = "https://api.invoicexml.com";
const auth = () => ({ Authorization: `Bearer ${process.env.INVOICEXML_API_KEY}` });

export async function postJson(path, body) {
  const response = await fetch(BASE_URL + path, {
    method: "POST",
    headers: { ...auth(), "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  // A 400 answer is JSON with an errors array: one entry per violated rule, with the rule id and the JSON path
  if (!response.ok) throw new Error(`InvoiceXML ${response.status}: ${await response.text()}`);
  return Buffer.from(await response.arrayBuffer());
}

// The upload endpoints take multipart/form-data with one file field named "file"
export async function postFile(path, filename, bytes, fields = {}) {
  const form = new FormData();
  form.append("file", new Blob([bytes]), filename);
  for (const [name, value] of Object.entries(fields)) form.append(name, value);
  const response = await fetch(BASE_URL + path, { method: "POST", headers: auth(), body: form });
  if (!response.ok) throw new Error(`InvoiceXML ${response.status}: ${await response.text()}`);
  return response;
}

Do not set the Content-Type header for the upload. fetch sets it with the multipart boundary. postFile returns the whole response, because the validation report endpoints put the result in a header.


Step 2: Map Shopify order fields to JSON

The API takes an EN 16931 invoice as JSON. One GraphQL query reads everything the invoice needs from the order. The seller data comes from the environment variables. This is the mapping:

Shopify fieldJSON pathNote
Order.nameinvoice.invoiceNumber"#1001" becomes "RE-1001"
Order.createdAtinvoice.issueDateConverted to the Berlin date, yyyy-MM-dd
Order.presentmentCurrencyCodeinvoice.currencyThe currency the customer paid in
Order.poNumberinvoice.purchaseOrderReferenceThe customer's order number, BT-13
billingAddress { company address1 city zip countryCodeV2 }invoice.buyerFor B2B orders, the name comes from purchasingEntity.company.name
company location taxSettings.taxRegistrationIdinvoice.buyer.vatIdentifierB2B orders only
LineItem.name, currentQuantitylines[].item.name, lines[].quantityProduct and variant title. Refunded units are left out
discountedUnitPriceAfterAllDiscountsSetlines[].priceDetails.netPricePrice after all discounts. VAT is taken out if taxesIncluded is true
LineItem.taxLines.ratePercentagelines[].vatInformation.rate19 or 7 in Germany
shippingLines { title discountedPriceSet }One more line in lines[]Shipping is a normal invoice line
metafield custom.leitweg_idinvoice.buyerReferenceXRechnung only, on the customer or the company

You do not send totals or VAT amounts. The API calculates both from the lines. If the numbers do not add up, the API returns an error instead of a file. A wrong price never reaches the customer as an invoice.

// mapping.js: read the order with the Admin GraphQL API and build the invoice JSON
import { shopifyGraphql } from "./shopify.js";

const ORDER_QUERY = `
query EInvoiceOrder($id: ID!) {
  order(id: $id) {
    id name createdAt email poNumber taxesIncluded
    currencyCode presentmentCurrencyCode
    eInvoice: metafield(namespace: "custom", key: "e_invoice") { id }
    eInvoiceSent: metafield(namespace: "custom", key: "e_invoice_sent") { id }
    billingAddress { company firstName lastName address1 city zip countryCodeV2 }
    customer { leitwegId: metafield(namespace: "custom", key: "leitweg_id") { value } }
    purchasingEntity {
      ... on PurchasingCompany {
        company { name leitwegId: metafield(namespace: "custom", key: "leitweg_id") { value } }
        location { taxSettings { taxRegistrationId } }
      }
    }
    paymentTerms { paymentSchedules(first: 1) { nodes { dueAt } } }
    lineItems(first: 250) {
      nodes {
        name currentQuantity
        discountedUnitPriceAfterAllDiscountsSet { presentmentMoney { amount } }
        taxLines { ratePercentage }
      }
    }
    shippingLines(first: 5) {
      nodes { title discountedPriceSet { presentmentMoney { amount } } taxLines { ratePercentage } }
    }
  }
}`;

export async function loadOrder(orderGid) {
  const data = await shopifyGraphql(ORDER_QUERY, { id: orderGid });
  if (!data.order) throw new Error(`Order ${orderGid} not found`);
  return data.order;
}

const berlinDate = (iso) => new Date(iso).toLocaleDateString("sv-SE", { timeZone: "Europe/Berlin" });   // yyyy-MM-dd
const vatRate = (taxLines) => taxLines.reduce((sum, tax) => sum + (tax.ratePercentage ?? 0), 0);

// The API needs net prices. With taxesIncluded, Shopify prices contain the VAT.
function netPrice(amount, rate, taxesIncluded) {
  const price = Number(amount);
  const net = taxesIncluded ? price / (1 + rate / 100) : price;
  return Math.round(net * 10000) / 10000;
}

function line(name, quantity, unitCode, amount, taxLines, taxesIncluded) {
  const rate = vatRate(taxLines);
  return {
    quantity,
    unitCode,
    item: { name },
    priceDetails: { netPrice: netPrice(amount, rate, taxesIncluded) },
    vatInformation: { rate },
  };
}

export function buildInvoice(order) {
  const b2b = order.purchasingEntity?.company ? order.purchasingEntity : null;
  const address = order.billingAddress ?? {};
  const personName = `${address.firstName ?? ""} ${address.lastName ?? ""}`.trim();
  const dueAt = order.paymentTerms?.paymentSchedules.nodes[0]?.dueAt;

  const lines = order.lineItems.nodes
    .filter((item) => item.currentQuantity > 0)   // skip refunded and removed items
    .map((item) => line(item.name, item.currentQuantity, "H87",
      item.discountedUnitPriceAfterAllDiscountsSet.presentmentMoney.amount, item.taxLines, order.taxesIncluded));
  for (const shipping of order.shippingLines.nodes) {
    const amount = shipping.discountedPriceSet.presentmentMoney.amount;
    if (Number(amount) > 0) {
      lines.push(line(`Versand: ${shipping.title}`, 1, "C62", amount, shipping.taxLines, order.taxesIncluded));
    }
  }

  const invoice = {
    invoiceNumber: "RE-" + order.name.replace("#", ""),
    issueDate: berlinDate(order.createdAt),
    dueDate: dueAt ? berlinDate(dueAt) : null,
    currency: order.presentmentCurrencyCode,
    purchaseOrderReference: order.poNumber,
    buyerReference: b2b?.company.leitwegId?.value ?? order.customer?.leitwegId?.value ?? null,   // XRechnung only
    seller: {
      name: process.env.SELLER_NAME,
      vatIdentifier: process.env.SELLER_VAT_ID,
      postalAddress: {
        line1: process.env.SELLER_STREET, city: process.env.SELLER_CITY,
        postCode: process.env.SELLER_ZIP, country: "DE",
      },
    },
    buyer: {
      name: b2b?.company.name || address.company || personName,
      vatIdentifier: b2b?.location.taxSettings.taxRegistrationId ?? null,
      postalAddress: {
        line1: address.address1, city: address.city,
        postCode: address.zip, country: address.countryCodeV2,
      },
    },
    paymentDetails: { paymentAccountIdentifier: process.env.SELLER_IBAN },
    lines,
  };
  return invoice;
}

Shopify has no separate invoice number. The code uses the order number with the prefix RE-. Order numbers are unique in a store. Shopify has no unit of measure on order lines, so products get H87 (pieces) and shipping gets C62 (one unit). The API leaves out fields that are null, so buyerReference and dueDate stay out when they are empty. Four decimals on the net price keep the rounding small for prices with VAT included.


Step 3: Create the ZUGFeRD invoice from the Shopify order

One function takes the order id, loads the order, posts the JSON and gets the PDF back as bytes. It hands the PDF to the storage function from step 4. The metafield check at the start makes the function safe to call twice for the same order.

// einvoice.js: create the ZUGFeRD PDF for one Shopify order
import { loadOrder, buildInvoice } from "./mapping.js";
import { postJson } from "./invoicexml.js";
import { storeOnOrder } from "./storage.js";

export async function processOrder(orderGid) {
  const order = await loadOrder(orderGid);
  if (order.eInvoice || order.eInvoiceSent) return;   // done already: webhooks can arrive more than once

  const invoice = buildInvoice(order);
  const pdf = await postJson("/v1/create/zugferd", { invoice });
  await storeOnOrder(order.id, `${invoice.invoiceNumber}-zugferd.pdf`, pdf, "application/pdf");
  console.log(`ZUGFeRD invoice ${invoice.invoiceNumber} stored on ${order.name}`);
}

For customers in France, post the same body to /v1/create/facturx. Factur-X is the French name of the same standard. Pick the endpoint by billingAddress.countryCodeV2. If the API returns an error, postJson throws with the list of violated rules. The order gets no metafield, so the next run tries it again.


Step 4: Store the PDF in an order metafield

Shopify orders have no file attachments. The app stores the PDF in Shopify Files and puts a reference to the file into an order metafield. This takes three GraphQL mutations:

  1. stagedUploadsCreate returns an upload URL and the form parameters for it.
  2. The app posts the parameters and the PDF to that URL. Then fileCreate turns the upload into a file on the Files page of the admin.
  3. metafieldsSet writes the file id into the metafield custom.e_invoice of the order. The metafield also marks the order as done.

Create a metafield definition once, so the file shows on the order page. In the admin, go to Settings, Metafields and metaobjects, Orders. Add a definition named "E-Rechnung" with the namespace and key custom.e_invoice and the type File. If the definition asks which files to accept, allow all file types, so PDF and XML both fit.

// storage.js: upload the file to Shopify Files and link it to the order
import { shopifyGraphql } from "./shopify.js";

function check(userErrors) {
  if (userErrors.length) throw new Error(`Shopify: ${JSON.stringify(userErrors)}`);
}

export async function storeOnOrder(orderGid, filename, bytes, mimeType) {
  // 1. Ask Shopify for an upload target
  const staged = await shopifyGraphql(`
    mutation ($input: [StagedUploadInput!]!) {
      stagedUploadsCreate(input: $input) {
        stagedTargets { url resourceUrl parameters { name value } }
        userErrors { field message }
      }
    }`, { input: [{ resource: "FILE", filename, mimeType, httpMethod: "POST", fileSize: String(bytes.length) }] });
  check(staged.stagedUploadsCreate.userErrors);
  const target = staged.stagedUploadsCreate.stagedTargets[0];

  // 2. Upload: first the parameters Shopify returned, then the file itself
  const form = new FormData();
  for (const { name, value } of target.parameters) form.append(name, value);
  form.append("file", new Blob([bytes], { type: mimeType }), filename);
  const upload = await fetch(target.url, { method: "POST", body: form });
  if (!upload.ok) throw new Error(`Upload failed: ${upload.status}`);

  const created = await shopifyGraphql(`
    mutation ($files: [FileCreateInput!]!) {
      fileCreate(files: $files) { files { id } userErrors { field message } }
    }`, { files: [{ originalSource: target.resourceUrl, contentType: "FILE", filename }] });
  check(created.fileCreate.userErrors);

  // 3. Link the file to the order (file_reference stores the id of the file)
  const set = await shopifyGraphql(`
    mutation ($metafields: [MetafieldsSetInput!]!) {
      metafieldsSet(metafields: $metafields) { userErrors { field message } }
    }`, { metafields: [{
      ownerId: orderGid, namespace: "custom", key: "e_invoice",
      type: "file_reference", value: created.fileCreate.files[0].id,
    }] });
  check(set.metafieldsSet.userErrors);
}

Shopify processes new files in the background. The metafield takes the file id at once, so the app does not wait. Staff open the PDF from the metafield on the order page. They can attach it to an email or download it for the customer.

Files in your own storage. Files on the Files page have a public address on the Shopify CDN. Anyone with the link can open them. If your invoices must stay private, keep the PDF in your own storage, for example an S3 bucket with signed links. Then write only the link into the metafield, with the type url and the link as the value. Steps 1 and 2 of the code are then not needed.


Run it on the orders/paid webhook

Shopify calls your server when an order is paid. The server checks the signature, answers at once and creates the invoice afterwards. Shopify signs the raw request body with the client secret of the app. The header X-Shopify-Hmac-Sha256 holds the signature in Base64. Compare it with crypto.timingSafeEqual, never with ===. The same server also has the upload route for supplier invoices from the import section.

// server.js: webhook receiver and upload route, plain node:http
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
import { processOrder } from "./einvoice.js";
import { importSupplierInvoice } from "./inbound.js";
import { shopifyGraphql } from "./shopify.js";

const handled = new Set();   // webhook ids already handled; use a database table in production

async function readBody(req) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  return Buffer.concat(chunks);
}

function sameBytes(a, b) {
  return a.length === b.length && timingSafeEqual(a, b);
}

// Shopify signs the raw body with the app's client secret (HMAC-SHA256, Base64)
function validShopifyHmac(rawBody, header) {
  if (!header) return false;
  const expected = createHmac("sha256", process.env.SHOPIFY_CLIENT_SECRET).update(rawBody).digest();
  return sameBytes(Buffer.from(header, "base64"), expected);
}

createServer(async (req, res) => {
  try {
    const body = await readBody(req);

    if (req.method === "POST" && req.url === "/webhooks/orders-paid") {
      if (!validShopifyHmac(body, req.headers["x-shopify-hmac-sha256"])) return res.writeHead(401).end();
      res.writeHead(200).end();   // Shopify waits 5 seconds at most: answer first, work afterwards
      const webhookId = req.headers["x-shopify-webhook-id"];
      if (handled.has(webhookId)) return;
      handled.add(webhookId);
      const orderGid = JSON.parse(body).admin_graphql_api_id;   // gid://shopify/Order/...
      processOrder(orderGid).catch((err) => console.error(`E-invoice for ${orderGid} failed:`, err.message));
      return;
    }

    if (req.method === "POST" && req.url === "/inbound") {   // see "Import incoming supplier invoices"
      const secret = Buffer.from(String(req.headers["x-inbound-secret"] ?? ""));
      if (!sameBytes(secret, Buffer.from(process.env.INBOUND_SECRET))) return res.writeHead(401).end();
      const result = await importSupplierInvoice(
        String(req.headers["x-filename"]), body, String(req.headers["x-sender-email"]));
      return res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(result));
    }

    res.writeHead(404).end();
  } catch (err) {
    console.error(err);
    if (!res.headersSent) res.writeHead(500).end();
  }
}).listen(3000);

// Safety net: every 15 minutes, check the paid orders of the last two days
setInterval(async () => {
  const since = new Date(Date.now() - 2 * 86400000).toISOString();
  const data = await shopifyGraphql(`query ($q: String!) { orders(first: 100, query: $q) { nodes { id } } }`,
    { q: `financial_status:paid created_at:>${since}` });
  for (const { id } of data.orders.nodes) await processOrder(id).catch((err) => console.error(id, err.message));
}, 15 * 60 * 1000);

// register-webhook.js: run once with  node --env-file=.env register-webhook.js
import { shopifyGraphql } from "./shopify.js";

const data = await shopifyGraphql(`
  mutation ($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
    webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
      webhookSubscription { id }
      userErrors { field message }
    }
  }`, {
  topic: "ORDERS_PAID",
  webhookSubscription: { uri: "https://einvoice.meinshop.de/webhooks/orders-paid" },
});
console.log(JSON.stringify(data.webhookSubscriptionCreate));

Why the safety net. Shopify retries a failed webhook 8 times over 4 hours. After 8 failures in a row, Shopify deletes a subscription that was created through the Admin API. The timer finds paid orders without an e-invoice, so no order is missed while the server was down. processOrder skips orders that already have the metafield.

Which event to use. orders/paid fits orders that are paid at checkout. If you invoice on shipping, subscribe to ORDERS_FULFILLED instead. For B2B orders with payment terms, see the B2B section. The register script runs once per topic. Use the version string 2026-10 in SHOPIFY_API_VERSION and move it forward when Shopify releases a new stable version.


Send the invoice by email from the API

Your app does not have to send the invoice itself. One call lets the API create the invoice, validate it and email it to the customer. You do not change any Shopify notification template. The customer gets the order confirmation from Shopify as before, and the invoice from your own domain. In Germany this is a complete delivery: there is no central platform for B2B e-invoices, so an email with a ZUGFeRD PDF meets the mandate.

Verify your sending domain once in the dashboard. Add the domain and publish the three CNAME records it shows. Then any address on that domain can be the sender. The body is the invoice JSON plus an email object. The recipient is the email field of the order:

// einvoice.js: let the API create, validate and email the invoice
export async function emailOrder(orderGid) {
  const order = await loadOrder(orderGid);
  if (order.eInvoice || order.eInvoiceSent || !order.email) return;

  const invoice = buildInvoice(order);
  await postJson("/v1/create/zugferd/email", {
    invoice,
    email: {
      from: process.env.INVOICE_FROM,   // an address on your verified sending domain
      fromName: process.env.SELLER_NAME,
      to: order.email,
      subject: `Rechnung ${invoice.invoiceNumber} zu Ihrer Bestellung ${order.name}`,
      message: `Guten Tag,\n\nvielen Dank für Ihre Bestellung ${order.name}. ` +
        `Im Anhang finden Sie die Rechnung ${invoice.invoiceNumber} als ZUGFeRD-PDF.\n\n` +
        `Freundliche Grüße\n${process.env.SELLER_NAME}`,
    },
  });

  // 200 means: created, validated and handed to delivery. The response has no file,
  // so a text metafield records the delivery on the order.
  const set = await shopifyGraphql(`
    mutation ($metafields: [MetafieldsSetInput!]!) {
      metafieldsSet(metafields: $metafields) { userErrors { field message } }
    }`, { metafields: [{
      ownerId: order.id, namespace: "custom", key: "e_invoice_sent",
      type: "single_line_text_field", value: `${invoice.invoiceNumber} an ${order.email}`,
    }] });
  if (set.metafieldsSet.userErrors.length) throw new Error(JSON.stringify(set.metafieldsSet.userErrors));
}

Import shopifyGraphql in einvoice.js and call emailOrder from the webhook instead of processOrder. Use this route when the API should send. Use the plain endpoint from step 3 when Shopify should store the file and your team sends it. XRechnung and Factur-X have the same email route: /v1/create/xrechnung/email and /v1/create/facturx/email.


Import incoming supplier invoices

Since January 2025, German businesses must accept e-invoices. A Shopify merchant receives them too: from suppliers, the warehouse, the agency or the parcel service. Shopify has no place for supplier invoices, so the same Node.js app handles them. The route /inbound in server.js takes the raw file, its name and the sender address. A shared secret in the header X-Inbound-Secret protects it. The pipeline has three steps:

  1. Validate. Send the file to POST /v1/validate/zugferd/report (PDF) or /v1/validate/xrechnung/report (XML). The API checks the EN 16931 rules and the German rules. The response body is a PDF report. The header X-Invoice-Valid is true or false. The form field validationLanguage with the value de gives a German report. For the findings as JSON, call /v1/validate/zugferd without /report.
  2. Send the report back. If the invoice is invalid, email the PDF report to the sender and stop. The report names each violated rule with the field and a message. The supplier corrects the invoice and sends it again.
  3. Extract. If the invoice is valid, send the file to POST /v1/extract/json. The API reads the XML and returns the invoice as JSON, in the same shape as the create request.

The email with the report goes out through your mail provider's API. sendMail is a small function you complete for your provider. The field names in its body depend on the provider.

// inbound.js: validate, send the report back, or extract and save
import { appendFile, mkdir, writeFile } from "node:fs/promises";
import { basename } from "node:path";
import { postFile } from "./invoicexml.js";

// Complete this for your mail provider's HTTP API
async function sendMail({ to, subject, text, filename, content }) {
  const response = await fetch(process.env.MAIL_API_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.MAIL_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      from: process.env.INVOICE_FROM, to, subject, text,
      attachments: [{ filename, content: content.toString("base64") }],
    }),
  });
  if (!response.ok) throw new Error(`Mail API ${response.status}`);
}

export async function importSupplierInvoice(rawName, bytes, senderEmail) {
  const filename = basename(rawName);   // never trust a path from outside
  const isXml = filename.toLowerCase().endsWith(".xml");

  // 1. Validate: XML files are XRechnung, PDF files are ZUGFeRD
  const report = await postFile(isXml ? "/v1/validate/xrechnung/report" : "/v1/validate/zugferd/report",
    filename, bytes, { validationLanguage: "de" });

  // 2. Invalid: send the PDF report to the sender and stop
  if (report.headers.get("x-invoice-valid") !== "true") {
    await sendMail({
      to: senderEmail,
      subject: `Ihre Rechnung ${filename}: Prüfbericht`,
      text: "Ihre Rechnung hat die Prüfung nicht bestanden. Der Bericht im Anhang nennt die Fehler. " +
        "Bitte senden Sie uns eine korrigierte Rechnung.",
      filename: `Pruefbericht-${filename}.pdf`,
      content: Buffer.from(await report.arrayBuffer()),
    });
    return { valid: false };
  }

  // 3. Valid: read the data and save it for the bookkeeping
  const extracted = await (await postFile("/v1/extract/json", filename, bytes)).json();
  return { valid: true, folder: await saveForBookkeeping(extracted, filename, bytes) };
}

Where the files come from. Many mail providers can forward incoming mail to a URL. Point the forward of [email protected] to a small handler that calls /inbound once per attachment. A script that reads the mailbox by IMAP works too. Test the route with curl: curl -X POST --data-binary @rechnung.pdf -H "X-Inbound-Secret: ..." -H "X-Filename: rechnung.pdf" -H "X-Sender-Email: [email protected]" https://einvoice.meinshop.de/inbound.

Plain PDFs: a PDF without XML inside goes to POST /v1/parse/json instead. The API reads it with AI, also scans and photos. The response has the same JSON plus a confidence object with scores from 0.0 to 1.0. Save the invoice when the scores are high, and flag it for a check when they are low. A file that is not an invoice comes back with error code 4008.


Save the supplier invoice for the bookkeeping

Shopify has no supplier invoice object. The app saves each valid invoice in a folder: the original file and the JSON. It also adds one line to a CSV file. Your tax adviser or your accounting software imports the CSV. The original file is the invoice you must keep, so keep the folder in a backed-up place.

// inbound.js (continued): one folder per supplier invoice, one CSV line per invoice
const INBOX = process.env.INBOX_DIR ?? "./eingangsrechnungen";
const safe = (value) => String(value ?? "unbekannt").replace(/[^A-Za-z0-9-]/g, "_");
const csvCell = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;

async function saveForBookkeeping({ invoice, totals }, filename, bytes) {
  const folder = `${INBOX}/${safe(invoice.seller?.vatIdentifier)}-${safe(invoice.invoiceNumber)}`;
  await mkdir(folder, { recursive: true });
  await writeFile(`${folder}/${filename}`, bytes);   // the original e-invoice
  await writeFile(`${folder}/invoice.json`, JSON.stringify({ invoice, totals }, null, 2));

  const row = [
    invoice.issueDate, invoice.seller?.name, invoice.seller?.vatIdentifier, invoice.invoiceNumber,
    totals?.grandTotalAmount, totals?.duePayableAmount, invoice.currency, invoice.dueDate,
    invoice.paymentDetails?.paymentAccountIdentifier,
  ];
  await appendFile(`${INBOX}/eingang.csv`, row.map(csvCell).join(";") + "\n");
  return folder;
}

The supplier's VAT id and invoice number name the folder. A second upload of the same invoice lands in the same folder, so it does not create a duplicate. The CSV uses a semicolon, which German spreadsheet programs read without settings. If you use an ERP or an accounting service with an API, post the JSON there instead of the CSV line. The lines array holds each item with quantity, net price and VAT rate.


XRechnung for German public buyers

German federal, state and municipal buyers need XRechnung, not ZUGFeRD. XRechnung is XML only, without a PDF. The portals ZRE and OZG-RE check every file against the KoSIT rules. The same app creates it with a different endpoint and a few more fields:

// mapping.js, in buildInvoice before "return invoice": the extra XRechnung fields
if (invoice.buyerReference) {
  invoice.seller.contact = {
    name: process.env.SELLER_CONTACT_NAME,
    phone: process.env.SELLER_CONTACT_PHONE,
    email: process.env.SELLER_CONTACT_EMAIL,
  };
  invoice.seller.electronicAddress = { identifier: process.env.SELLER_VAT_ID, schemeId: "9930" };   // 9930 = German VAT id
  invoice.buyer.electronicAddress = invoice.buyer.vatIdentifier
    ? { identifier: invoice.buyer.vatIdentifier, schemeId: "9930" }
    : { identifier: invoice.buyerReference, schemeId: "0204" };   // 0204 = Leitweg-ID
}

// einvoice.js, in processOrder: pick the format by the Leitweg-ID
const xrechnung = Boolean(invoice.buyerReference);
const file = await postJson(xrechnung ? "/v1/create/xrechnung" : "/v1/create/zugferd", { invoice });
await storeOnOrder(order.id,
  `${invoice.invoiceNumber}-${xrechnung ? "xrechnung.xml" : "zugferd.pdf"}`,
  file, xrechnung ? "application/xml" : "application/pdf");

The Leitweg-ID is a routing id. The public buyer gives it to you when you register as a supplier. Rule BR-DE-15 rejects an invoice without it. Store it in a customer metafield with the namespace and key custom.leitweg_id and the type single line text. Create the definition under Settings, Metafields and metaobjects, Customers. For B2B orders, put the same metafield on the company. The query in step 2 reads the company value first, then the customer value. The same settings page lists the owner types your store supports, including companies.

The seller contact (name, phone, email) and the electronic addresses are required by the German rules. Scheme 9930 means a German VAT id. For Peppol delivery, use the Peppol participant id and its scheme. The API returns the XML, already checked against the KoSIT rules. The app stores it on the order like the PDF. Deliver it the way the buyer requires: upload to ZRE or OZG-RE, Peppol, or email if the buyer accepts it. /v1/render/xrechnung/to/pdf turns the XML into a readable PDF for your own records. The XRechnung API toolkit explains the format in detail.


Shopify Plus B2B, markets and taxes

Taxes included or excluded. Every order has the flag taxesIncluded. When it is true, line and shipping prices contain the VAT. The mapping then divides by 1 plus the rate. When it is false, the prices are already net. Read the flag from each order. Do not assume one value for the whole store, because it comes from the tax settings that applied to that order.

Several currencies. With Shopify Markets, a customer can pay in another currency than the shop. currencyCode is the shop currency. presentmentCurrencyCode is the currency the customer paid in. Every price field is a MoneyBag with shopMoney and presentmentMoney. The mapping uses presentmentMoney, so the invoice shows the amounts the customer paid. If your bookkeeping needs the shop currency, the same query can read shopMoney too.

B2B companies. On a B2B order, purchasingEntity is a PurchasingCompany with company, location and contact. On a normal order it is the Customer. The buyer's VAT id is location.taxSettings.taxRegistrationId. The older field taxRegistrationId directly on the company location is deprecated. The scope read_customers covers companies and company locations.

Payment terms. B2B orders with net terms, such as 30 days, are not paid at checkout. orders/paid arrives only when the customer pays. Invoice these orders on ORDERS_CREATE or ORDERS_FULFILLED instead. The mapping reads the due date from paymentTerms.paymentSchedules and sends it as dueDate.

Tax-exempt orders. If an order has no tax lines, for example for a tax-exempt company location, the rate is 0. For intra-community supplies and reverse charge, the invoice needs the matching VAT category and exemption reason. The API documentation lists the fields for them.

Orders older than 60 days. read_orders covers the last 60 days. To create invoices for older orders, add the scope read_all_orders. Apps in a merchant's own organization in the Dev Dashboard need no approval from Shopify for it.


Endpoint reference

PurposeEndpointInputOutput
Create ZUGFeRDPOST /v1/create/zugferdInvoice JSONHybrid PDF/A-3
Create and email ZUGFeRDPOST /v1/create/zugferd/emailInvoice JSON + email objectEmail sent from your domain
Create XRechnungPOST /v1/create/xrechnungInvoice JSON with Leitweg-IDXRechnung XML
Create and email XRechnungPOST /v1/create/xrechnung/emailInvoice JSON + email objectEmail sent from your domain
Create Factur-XPOST /v1/create/facturxInvoice JSONHybrid PDF/A-3
Validate with PDF reportPOST /v1/validate/zugferd/report, /xrechnung/reportPDF or XML filePDF report, X-Invoice-Valid header
Validate as JSONPOST /v1/validate/zugferd, /xrechnungPDF or XML fileFindings per rule
Extract structured dataPOST /v1/extract/jsonHybrid PDF or XML fileInvoice JSON
Parse plain PDFPOST /v1/parse/jsonAny PDF or imageInvoice JSON + confidence
Extract attachmentsPOST /v1/extract/attachmentsHybrid PDF or XML fileZIP of embedded files
Render XRechnungPOST /v1/render/xrechnung/to/pdfXRechnung XMLReadable PDF

Get started

The Shopify integration is one small Node.js app without npm packages. It creates the app token, maps the order, creates the ZUGFeRD PDF or lets the API email it, links the file to the order and checks the supplier invoices you receive. The format rules stay in the API, so the app stays the same when the formats change. This guide is part of the integrations hub, which also covers Shopware, SAP, Odoo and other platforms.

Related guides:

Start free today

Ready to automate your invoices?

Validate, convert and embed compliant e-invoices through one API. Start your 30-day free trial. No credit card required.

GDPR Compliant No credit card required Setup in minutes
Peppol UBL
Factur-X
EN 16931
142 / 142 passed
Compliant
PDF/A-3 embedded