Automation MCP Server Blog Pricing
Automation

ZUGFeRD and XRechnung in NetSuite: E-Invoicing with SuiteScript 2.1 and One API Call

Many international groups run their German subsidiary on NetSuite OneWorld. That subsidiary must send and receive e-invoices now. This guide shows how to create ZUGFeRD invoices with a small SuiteScript 2.1 library, a User Event script and one REST API. It also covers email delivery by the API, XRechnung for public buyers, and checking and importing supplier invoices as vendor bills.

This guide adds ZUGFeRD and XRechnung to Oracle NetSuite. You write one SuiteScript 2.1 library and a few small entry scripts. They read the standard invoice, customer and subsidiary records and call one REST API. It is written for NetSuite OneWorld with a German subsidiary, and it works on SuiteTax and on Legacy Tax. In the email section, the API sends the invoice to the customer itself. In the import section, supplier invoices are checked and turned into vendor bills. If you want the API calls in plain JavaScript first, outside NetSuite, read the Node.js guide.


Why NetSuite needs ZUGFeRD now

A German subsidiary on NetSuite must send and receive structured e-invoices. Every German business has had to accept e-invoices since January 2025. Businesses with more than EUR 800,000 turnover in the previous year must issue them from January 2027. All other businesses follow in January 2028. The rule applies per legal entity, so it applies to your German subsidiary, not to the group. The Germany mandate page lists the dates and formats.

The printed invoice is not enough. An Advanced PDF/HTML template produces a plain PDF. The mandate asks for structured data: a PDF for people, with XML inside for software. That hybrid file is ZUGFeRD. Oracle also offers an Electronic Invoicing SuiteApp with country templates. The scripts in this guide do not depend on it. They read standard fields and post JSON to one endpoint.

Format updates do not touch the scripts. ZUGFeRD, XRechnung and the EN 16931 rules get new versions regularly. The API switches to each new version on its effective date. Your scripts send the same JSON before and after. No new SDF project, no new deployment, no regression test in the sandbox.


What you need

  • NetSuite with SuiteScript 2.1. OneWorld or a single company, SuiteTax or Legacy Tax. Server SuiteScript must be enabled under Setup, Company, Enable Features, SuiteCloud.
  • An administrator, or a role with the Secrets Management permission. The API key is stored as an API secret.
  • Three custom fields under Customization, Lists, Records, & Fields. A transaction body checkbox custbody_einvoice_done on sales transactions. An entity field custentity_leitweg_id on customers, for public buyers only. An other record field custrecord_ixml_iban on the subsidiary, for the IBAN.
  • Three File Cabinet folders: one for created e-invoices, one inbox for supplier invoices and one for processed supplier invoices.
  • Complete subsidiary data: legal name, main address and VAT registration number of the German subsidiary. The scripts read them 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 user saves a new invoice in NetSuite. A User Event script runs in afterSubmit and calls the library. The library loads the invoice, the customer and the subsidiary, builds a JSON body and posts it to one endpoint. The API calculates the totals and the VAT, builds the XML, embeds it in a PDF/A-3 file, validates it and returns the PDF. The library saves the PDF in the File Cabinet and attaches it to the invoice, 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. You upload these files to SuiteScripts/invoicexml in the File Cabinet and create one script record for each entry script:

  • ixml_lib.js: the library. API helper, mapping, create, attach, email, import and the vendor bill.
  • ixml_invoice_ue.js: the User Event script on the invoice record.
  • ixml_invoice_mr.js: a Map/Reduce script for invoices that have no e-invoice yet.
  • ixml_email_capture.js: the Email Capture plug-in that receives supplier invoices.
  • ixml_inbox_mr.js: a Map/Reduce script that checks and imports the received files.
  • relay.mjs: a small upload relay for supplier PDFs. It runs outside NetSuite. The import section explains why.

Everything else is standard NetSuite: the invoice, customer, subsidiary, currency, vendor and vendorbill records, the File Cabinet, and the modules N/https, N/record, N/search, N/file and N/email.


Step 1: Store the API key as an API secret

Go to Setup, Company, Preferences, API Secrets and click Create New. Enter a name and the ID _invoicexml_api_key. NetSuite adds the prefix, so the full ID is custsecret_invoicexml_api_key. Paste the API key into the Password field. Clear Allow For All Scripts and list the InvoiceXML scripts in Restrict To Scripts. Clear Allow For All Domains and enter api.invoicexml.com and the host of your relay in Restrict To Domains.

The script never reads the key. It passes a placeholder to https.createSecureString, and NetSuite inserts the secret when the request is sent. The first part of the library holds the settings and the JSON helper. Every other part of this guide adds functions to the same file.

/**
 * @NApiVersion 2.1
 * @NModuleScope SameAccount
 */
// SuiteScripts/invoicexml/ixml_lib.js (part 1: settings and the API helper)
define(['N/https', 'N/record', 'N/search', 'N/file', 'N/email'],
    (https, record, search, file, email) => {

    const API = 'https://api.invoicexml.com';
    const CONFIG = {
        pdfFolderId: 1201,                // created e-invoices
        inboxFolderId: 1202,              // supplier invoices waiting for import
        doneFolderId: 1203,               // imported or rejected supplier invoices
        germanSubsidiaries: [3],          // subsidiaries that issue e-invoices
        relayUrl: 'https://relay.firma.de/upload',   // PDF uploads, see the import section
        authorEmployeeId: 7,              // employee who sends the report emails
        fromEmail: '[email protected]',   // an address on your verified sending domain
        expenseAccountId: 58,             // default expense account for imported bills
        purchaseTaxCodes: { 19: 21, 7: 22, 0: 23 },   // Legacy Tax only: rate to tax code id
        subsidiaryByVat: { DE123456789: 3 },          // buyer VAT id to subsidiary id
        unitCodes: { 'Stück': 'H87', 'Each': 'H87', 'Stunde': 'HUR', 'Hour': 'HUR', 'kg': 'KGM' }
    };

    // NetSuite replaces the placeholder with the API secret when the request is sent
    const authHeader = () => https.createSecureString({
        input: 'Bearer {custsecret_invoicexml_api_key}'
    });

    const readHeader = (response, name) => {
        const key = Object.keys(response.headers || {})
            .find((k) => k.toLowerCase() === name.toLowerCase());
        return key ? response.headers[key] : null;
    };

    // JSON in, file out. Every https.post costs 10 governance units.
    const postJson = (path, body) => {
        const response = https.post({
            url: API + path,
            headers: { 'Authorization': authHeader(), 'Content-Type': 'application/json' },
            body: JSON.stringify(body)
        });
        if (response.code !== 200) {
            // 400 returns JSON with an errors array: one entry per violated rule
            throw new Error('InvoiceXML ' + response.code + ': ' + response.body);
        }
        return response;
    };

    // Parts 2 to 8 of this guide go here

    return { CONFIG, buildInvoice, createZugferd, createEInvoice, emailZugferd, importFile };
});

The IDs in CONFIG are internal IDs from your account: folders, employee, account and tax codes. Keep them in this one place. If administrators should change them without a deployment, move them to script parameters on the script records. An API error ends in a thrown Error. The calling script decides what happens next.


Step 2: Map NetSuite fields to JSON

The API takes an EN 16931 invoice as JSON. The invoice data comes from the invoice record and its item sublist. The buyer is the customer and the billing address of the invoice. The seller is the subsidiary of the invoice. This is the mapping:

NetSuite fieldJSON pathNote
tranidinvoice.invoiceNumberBT-1. The invoice number
trandate, duedateinvoice.issueDate, dueDateDate objects, written as yyyy-MM-dd
currencyinvoice.currencyThe symbol of the currency record is the ISO code
otherrefnuminvoice.purchaseOrderReferenceThe PO/Check Number field, BT-13
subsidiary: legalname, mainaddressinvoice.sellerOne seller per subsidiary
federalidnumber or taxregistration sublistinvoice.seller.vatIdentifierLegacy Tax or SuiteTax
entity: companyname, vatregnumberinvoice.buyerOn SuiteTax, the VAT id is on the Tax Registrations subtab
billingaddress: addr1, city, zip, countryinvoice.buyer.postalAddressCountry as ISO code (DE, AT, FR)
custrecord_ixml_iban on the subsidiaryinvoice.paymentDetails.paymentAccountIdentifierThe IBAN of the German subsidiary
item sublist: description, quantity, ratelines[].item.name, quantity, priceDetails.netPriceSubtotal, description and group lines are skipped
unitslines[].unitCodeUN/ECE code: H87 for pieces, HUR for hours
taxrate1 or taxdetails sublistlines[].vatInformation.rateLegacy Tax or SuiteTax
custentity_leitweg_idinvoice.buyerReferenceXRechnung only

The body has no totals and no VAT amounts. The API calculates both from the lines. When the numbers do not add up, the API answers with an error and does not create a file. So a rounding problem in NetSuite never reaches the customer.

    // ixml_lib.js (part 2: read the invoice and build the JSON)
    const pad = (n) => String(n).padStart(2, '0');
    const ymd = (d) => d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) : null;

    const address = (addr) => ({
        line1: addr.getValue({ fieldId: 'addr1' }),
        city: addr.getValue({ fieldId: 'city' }),
        postCode: addr.getValue({ fieldId: 'zip' }),
        country: addr.getValue({ fieldId: 'country' })       // ISO code, for example DE
    });

    // SuiteTax: Tax Registrations subtab. Legacy Tax: one body field.
    const vatId = (rec, legacyField, country) => {
        const rows = rec.getLineCount({ sublistId: 'taxregistration' });
        for (let i = 0; i < rows; i++) {
            const nexus = rec.getSublistValue({ sublistId: 'taxregistration', fieldId: 'nexuscountry', line: i });
            if (!country || nexus === country) {
                return rec.getSublistValue({ sublistId: 'taxregistration', fieldId: 'taxregistrationnumber', line: i });
            }
        }
        return rec.getValue({ fieldId: legacyField }) || null;
    };

    // SuiteTax: Tax Details subtab, linked by taxdetailsreference. Legacy Tax: taxrate1.
    const rateReader = (inv) => {
        const byRef = {};
        const rows = inv.getLineCount({ sublistId: 'taxdetails' });
        for (let i = 0; i < rows; i++) {
            const ref = inv.getSublistValue({ sublistId: 'taxdetails', fieldId: 'taxdetailsreference', line: i });
            byRef[ref] = parseFloat(inv.getSublistValue({ sublistId: 'taxdetails', fieldId: 'taxrate', line: i })) || 0;
        }
        return (line) => rows > 0
            ? byRef[inv.getSublistValue({ sublistId: 'item', fieldId: 'taxdetailsreference', line })] || 0
            : parseFloat(inv.getSublistValue({ sublistId: 'item', fieldId: 'taxrate1', line })) || 0;
    };

    const SKIP = ['Subtotal', 'Description', 'Group', 'EndGroup'];

    const buildInvoice = (invoiceId) => {
        const inv = record.load({ type: record.Type.INVOICE, id: invoiceId });
        const sub = record.load({ type: record.Type.SUBSIDIARY, id: inv.getValue({ fieldId: 'subsidiary' }) });
        const cust = record.load({ type: record.Type.CUSTOMER, id: inv.getValue({ fieldId: 'entity' }) });
        const billing = inv.getSubrecord({ fieldId: 'billingaddress' });
        const subAddress = sub.getSubrecord({ fieldId: 'mainaddress' });
        const rateOf = rateReader(inv);

        const lines = [];
        for (let i = 0; i < inv.getLineCount({ sublistId: 'item' }); i++) {
            const get = (fieldId) => inv.getSublistValue({ sublistId: 'item', fieldId, line: i });
            const text = (fieldId) => inv.getSublistText({ sublistId: 'item', fieldId, line: i });
            const type = get('itemtype');
            if (SKIP.includes(type)) continue;
            const amount = parseFloat(get('amount')) || 0;
            let quantity = parseFloat(get('quantity')) || 1;
            let netPrice = parseFloat(get('rate'));
            if (isNaN(netPrice)) netPrice = amount / quantity;
            if (type === 'Discount') {
                // EN 16931 does not allow a negative price, so a discount gets a negative quantity
                quantity = -1;
                netPrice = Math.abs(amount);
            }
            lines.push({
                quantity,
                unitCode: CONFIG.unitCodes[text('units')] || 'C62',
                item: { name: get('description') || text('item') },
                priceDetails: { netPrice },
                vatInformation: { rate: rateOf(i) }
            });
        }

        const currency = search.lookupFields({
            type: search.Type.CURRENCY, id: inv.getValue({ fieldId: 'currency' }), columns: ['symbol']
        }).symbol;

        return {
            invoiceNumber: inv.getValue({ fieldId: 'tranid' }),
            issueDate: ymd(inv.getValue({ fieldId: 'trandate' })),
            dueDate: ymd(inv.getValue({ fieldId: 'duedate' })),
            currency,
            purchaseOrderReference: inv.getValue({ fieldId: 'otherrefnum' }) || null,
            buyerReference: cust.getValue({ fieldId: 'custentity_leitweg_id' }) || null,   // XRechnung only
            seller: {
                name: sub.getValue({ fieldId: 'legalname' }) || sub.getValue({ fieldId: 'name' }),
                vatIdentifier: vatId(sub, 'federalidnumber', subAddress.getValue({ fieldId: 'country' })),
                postalAddress: address(subAddress)
            },
            buyer: {
                name: cust.getValue({ fieldId: 'companyname' }) || billing.getValue({ fieldId: 'addressee' }),
                vatIdentifier: vatId(cust, 'vatregnumber', null),
                postalAddress: address(billing)
            },
            paymentDetails: { paymentAccountIdentifier: sub.getValue({ fieldId: 'custrecord_ixml_iban' }) || null },
            lines
        };
    };

The API ignores fields with null, so buyerReference stays out of B2B invoices. The unit names come from your units of measure. Add your own names to CONFIG.unitCodes. Lines without a known unit get C62, the generic code. The field IDs above are the standard IDs. Custom forms and localization SuiteApps can add more fields, so check the IDs once in the Records Browser of your account.


Step 3: Create the ZUGFeRD invoice

One function builds the body, posts it and receives the PDF. NetSuite hands a binary response with the content type application/pdf to the script as a base64 string. That is exactly the format file.create expects for a PDF file.

    // ixml_lib.js (part 3: create the ZUGFeRD PDF)
    const createZugferd = (invoiceId) => {
        const invoice = buildInvoice(invoiceId);
        const response = postJson('/v1/create/zugferd', { invoice });
        // response.body is the PDF as a base64 string
        return storeAndAttach(invoiceId, invoice.invoiceNumber + '-zugferd.pdf', file.Type.PDF, response.body);
    };

Open the first PDF from the File Cabinet once in your sandbox. It should open as a normal invoice in any PDF viewer. For French customers, post the same body to /v1/create/facturx. Factur-X is the French name of the same standard. Pick the endpoint by the country of the billing address.


Step 4: Save the PDF and attach it to the invoice

Save the PDF in the File Cabinet with file.create and File.save(). Then link it to the invoice with record.attach. The file appears on the invoice under Communication, Files. Users can open it or send it with the standard email button. The checkbox custbody_einvoice_done marks the invoice as done.

    // ixml_lib.js (part 4: File Cabinet and attachment)
    const storeAndAttach = (invoiceId, name, fileType, contents) => {
        const options = { name, fileType, contents, folder: CONFIG.pdfFolderId };
        if (fileType === file.Type.XMLDOC) options.encoding = file.Encoding.UTF_8;
        const fileId = file.create(options).save();
        record.attach({ record: { type: 'file', id: fileId }, to: { type: 'invoice', id: invoiceId } });
        record.submitFields({ type: record.Type.INVOICE, id: invoiceId, values: { custbody_einvoice_done: true } });
        return fileId;
    };


/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 */
// SuiteScripts/invoicexml/ixml_invoice_ue.js: deploy on the Invoice record
define(['N/log', './ixml_lib'], (log, lib) => ({
    afterSubmit: (context) => {
        // CREATE only: the submitFields call above runs as XEDIT and does not start a loop
        if (context.type !== context.UserEventType.CREATE) return;
        const invoice = context.newRecord;
        if (!lib.CONFIG.germanSubsidiaries.includes(Number(invoice.getValue({ fieldId: 'subsidiary' })))) return;
        try {
            lib.createZugferd(invoice.id);
        } catch (e) {
            // The invoice is saved already. The Map/Reduce script tries again on its next run.
            log.error({ title: 'InvoiceXML ' + invoice.id, details: e.message });
        }
    }
}));


/**
 * @NApiVersion 2.1
 * @NScriptType MapReduceScript
 */
// SuiteScripts/invoicexml/ixml_invoice_mr.js: schedule every 15 minutes
define(['N/search', 'N/log', './ixml_lib'], (search, log, lib) => ({
    getInputData: () => search.create({
        type: search.Type.INVOICE,
        filters: [
            ['mainline', 'is', 'T'], 'AND',
            ['custbody_einvoice_done', 'is', 'F'], 'AND',
            ['subsidiary', 'anyof', lib.CONFIG.germanSubsidiaries], 'AND',
            ['trandate', 'onorafter', 'daysago30']
        ],
        columns: ['tranid']
    }),
    map: (context) => {
        lib.createZugferd(JSON.parse(context.value).id);
    },
    summarize: (summary) => {
        summary.mapSummary.errors.iterator().each((key, error) => {
            log.error({ title: 'InvoiceXML invoice ' + key, details: error });
            return true;
        });
    }
}));

When to run it. The User Event script creates the e-invoice right after a user saves a new invoice. It runs in afterSubmit, so the invoice is already saved when the API is called. If the API returns an error, the script logs it and the user can keep working. The Map/Reduce script finds every invoice of the German subsidiaries without the checkbox and tries again. Deploy it with a schedule under Customization, Scripting, Script Deployments. It also handles invoices from CSV imports and integrations, if you prefer not to call the API while a user waits.

Approval and edits. If your invoices go through an approval workflow, start the e-invoice when the invoice is approved. Check the approval status in afterSubmit on EDIT, or add the status to the Map/Reduce filters. An invoice that changes after the e-invoice exists needs a credit memo or a new e-invoice, the same as on paper.


Send the invoice by email from the API

NetSuite does not have to send the invoice. One call lets the API create the invoice, validate it and email it to the customer. The PDF never passes through your account. In Germany this is a complete delivery: there is no central platform for B2B e-invoices, and an email with a ZUGFeRD PDF fulfils the mandate.

Verify your sending domain once in the dashboard. Add the domain and publish the three CNAME records the dashboard shows. After that, any address on the domain can be the sender. The customer sees your address, and a reply lands in your mailbox. The request body is the invoice JSON plus an email object. The recipient is the email field of the invoice, which NetSuite fills from the customer:

    // ixml_lib.js (part 5: let the API send the invoice)
    const emailZugferd = (invoiceId) => {
        const invoice = buildInvoice(invoiceId);
        const to = search.lookupFields({ type: search.Type.INVOICE, id: invoiceId, columns: ['email'] }).email;
        if (!to) throw new Error('Invoice ' + invoice.invoiceNumber + ' has no email address');

        postJson('/v1/create/zugferd/email', {
            invoice,
            email: {
                from: CONFIG.fromEmail,         // an address on your verified sending domain
                fromName: invoice.seller.name,
                to,
                subject: 'Rechnung ' + invoice.invoiceNumber,
                message: 'Guten Tag,\n\nanbei erhalten Sie unsere Rechnung ' + invoice.invoiceNumber +
                         ' als ZUGFeRD-PDF.\n\nMit freundlichen Grüßen\n' + invoice.seller.name
            }
        });
        // 200 means: created, validated and handed to delivery. The response has no file.
        record.submitFields({ type: record.Type.INVOICE, id: invoiceId, values: { custbody_einvoice_done: true } });
    };

Use this route when the API should send. Use the plain endpoint from step 3 when NetSuite should store the file and send it itself. To switch, call lib.emailZugferd instead of lib.createZugferd in both entry scripts. XRechnung and Factur-X have the same email route: /v1/create/xrechnung/email and /v1/create/facturx/email.


Import incoming supplier invoices

Accepting e-invoices has been mandatory in Germany since January 2025. Supplier invoices now arrive as ZUGFeRD PDFs and XRechnung XML files. The import has three steps. First validate. Then, for an invalid file, send the report back. For a valid file, extract the data.

  1. Validate. Upload the file to POST /v1/validate/zugferd/report (PDF) or /v1/validate/xrechnung/report (XML). The API runs the EN 16931 rules and the German rules. The body of the response is a PDF validation report. The header X-Invoice-Valid is true or false. The form field validationLanguage picks the report language; de gives a German report. For the findings as JSON instead, call /v1/validate/zugferd without /report.
  2. Send the report back. For an invalid invoice, email the PDF report to the sender and stop. The report lists every violated rule with the field and a message. The supplier corrects the invoice and sends it again.
  3. Extract. For a valid invoice, upload 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.

Receive the email. An Email Capture plug-in implementation gets its own address from NetSuite. Forward your invoice mailbox, for example [email protected], to that address. Oracle supports only SuiteScript 1.0 for this plug-in type, so it stays short. It saves every PDF and XML attachment in the inbox folder and keeps the sender address in the file description:

// SuiteScripts/invoicexml/ixml_email_capture.js: Email Capture plug-in (SuiteScript 1.0)
var INBOX_FOLDER_ID = 1202;

function process(email) {
    var sender = email.getFrom().getEmail();
    var attachments = email.getAttachments() || [];
    for (var i = 0; i < attachments.length; i++) {
        var name = attachments[i].getName() || '';
        if (!/\.(pdf|xml)$/i.test(name)) continue;
        // getValue() is text for text files and base64 for binary files; getType() says which
        var f = nlapiCreateFile(new Date().getTime() + '-' + name, attachments[i].getType(), attachments[i].getValue());
        f.setFolder(INBOX_FOLDER_ID);
        f.setDescription(sender);       // the import sends the report to this address
        nlapiSubmitFile(f);
    }
}

Upload the file. The upload endpoints take multipart/form-data with one field named file. XML files are text, so the script builds the multipart body as a string, the way Oracle's N/https example does. For binary files, Oracle's https.post reference states that NetSuite does not send them with a multipart/form-data content type. So supplier PDFs go through a small relay: NetSuite posts the file as base64 in JSON, and the relay forwards it as a multipart upload. It runs on any HTTPS host, for example a serverless function or a container:

// relay.mjs (Node.js 18 or later, no packages): JSON from NetSuite in, multipart/form-data out
import http from 'node:http';

const PATHS = ['/v1/validate/zugferd/report', '/v1/validate/xrechnung/report', '/v1/extract/json', '/v1/parse/json'];

http.createServer(async (req, res) => {
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const { path, fileName, base64, validationLanguage } = JSON.parse(Buffer.concat(chunks).toString());
    if (!PATHS.includes(path)) return res.writeHead(400).end();

    const form = new FormData();
    form.append('file', new Blob([Buffer.from(base64, 'base64')]), fileName);
    if (validationLanguage) form.append('validationLanguage', validationLanguage);

    // The Authorization header from NetSuite is passed on. The relay stores no key and no file.
    const upstream = await fetch('https://api.invoicexml.com' + path, {
        method: 'POST', headers: { Authorization: req.headers.authorization }, body: form
    });
    const headers = { 'Content-Type': upstream.headers.get('content-type') || 'application/json' };
    const valid = upstream.headers.get('x-invoice-valid');
    if (valid) headers['X-Invoice-Valid'] = valid;
    res.writeHead(upstream.status, headers).end(Buffer.from(await upstream.arrayBuffer()));
}).listen(process.env.PORT || 8080);

Process the inbox. A Map/Reduce script reads the inbox folder every 15 minutes. For each file it calls importFile: validate, send the report back or extract, and create the vendor bill. Processed files move to the done folder, so each file runs once.

    // ixml_lib.js (part 6: upload, validate, report back, extract)
    const postFile = (path, source, language) => {
        const headers = { 'Authorization': authHeader() };
        let response;
        if (['XMLDOC', 'PLAINTEXT'].includes(source.fileType)) {
            // Text file: build the multipart body by hand, as in Oracle's N/https example
            const b = 'ixml' + Date.now();
            const parts = ['--' + b, 'Content-Disposition: form-data; name="file"; filename="' + source.name + '"',
                'Content-Type: application/xml', '', source.getContents()];
            if (language) parts.push('--' + b, 'Content-Disposition: form-data; name="validationLanguage"', '', language);
            parts.push('--' + b + '--', '');
            headers['Content-Type'] = 'multipart/form-data; boundary=' + b;
            response = https.post({ url: API + path, headers, body: parts.join('\r\n') });
        } else {
            // Binary file: getContents() returns base64, the relay sends it as multipart/form-data
            headers['Content-Type'] = 'application/json';
            response = https.post({ url: CONFIG.relayUrl, headers, body: JSON.stringify({
                path, fileName: source.name, base64: source.getContents(), validationLanguage: language
            }) });
        }
        if (response.code !== 200) throw new Error('InvoiceXML ' + response.code + ': ' + response.body);
        return response;
    };

    const moveToDone = (source) => {
        source.folder = CONFIG.doneFolderId;
        source.save();
    };

    const importFile = (fileId) => {
        const source = file.load({ id: fileId });
        const isXml = /\.xml$/i.test(source.name);

        // 1. Validate. XML files are XRechnung, PDF files are ZUGFeRD.
        const check = postFile(isXml ? '/v1/validate/xrechnung/report' : '/v1/validate/zugferd/report', source, 'de');

        // 2. Invalid: send the PDF report to the sender and stop
        if (readHeader(check, 'X-Invoice-Valid') !== 'true') {
            email.send({
                author: CONFIG.authorEmployeeId,
                recipients: [source.description],
                subject: 'Ihre Rechnung ' + source.name + ': Prüfbericht',
                body: '<p>Guten Tag,</p><p>Ihre Rechnung hat die Prüfung nicht bestanden. ' +
                      'Der Prüfbericht im Anhang nennt jeden Fehler. Bitte senden Sie uns eine korrigierte Rechnung.</p>',
                attachments: [file.create({ name: 'Pruefbericht.pdf', fileType: file.Type.PDF, contents: check.body })]
            });
            moveToDone(source);
            return null;
        }

        // 3. Valid: read the data and create the vendor bill
        const data = JSON.parse(postFile('/v1/extract/json', source).body);
        const billId = createVendorBill(data.invoice, data.invoice.totals || {});
        record.attach({ record: { type: 'file', id: fileId }, to: { type: 'vendorbill', id: billId } });
        moveToDone(source);
        return billId;
    };


/**
 * @NApiVersion 2.1
 * @NScriptType MapReduceScript
 */
// SuiteScripts/invoicexml/ixml_inbox_mr.js: schedule every 15 minutes
define(['N/search', './ixml_lib'], (search, lib) => ({
    getInputData: () => search.create({
        type: 'file',
        filters: [['folder', 'anyof', lib.CONFIG.inboxFolderId]],
        columns: ['name']
    }),
    map: (context) => {
        lib.importFile(JSON.parse(context.value).id);
    }
}));

Plain PDFs: a PDF without XML inside goes to POST /v1/parse/json instead. The API reads it with AI. Typed, scanned and photographed invoices all work. The response is the same JSON plus a confidence object with scores from 0.0 to 1.0. Create the bill when the scores are high. Leave the file in a review folder when they are low. A file that is not an invoice comes back with error code 4008. The relay already accepts the path.


Create the vendor bill from the JSON

The JSON from both endpoints has the same shape, so one function creates the NetSuite document. It finds the vendor by the VAT id of the seller. It picks the subsidiary by the VAT id of the buyer. Then it creates a vendorbill with one expense line per invoice line and the status Pending Approval. Accounts payable checks the bill in the approval queue and approves it.

    // ixml_lib.js (part 7: the vendor bill, pending approval)
    const findVendor = (vat) => {
        let vendorId = null;
        search.create({ type: search.Type.VENDOR, filters: [['vatregnumber', 'is', vat]] })
            .run().each((result) => { vendorId = result.id; return false; });
        if (!vendorId) throw new Error('No vendor with VAT id ' + vat);
        return vendorId;
    };

    const toDate = (s) => {
        const [y, m, d] = s.split('-').map(Number);
        return new Date(y, m - 1, d);
    };

    const createVendorBill = (inv, totals) => {
        const bill = record.create({ type: record.Type.VENDOR_BILL, isDynamic: true });
        bill.setValue({ fieldId: 'entity', value: findVendor(inv.seller.vatIdentifier) });

        // OneWorld: the buyer VAT id says which subsidiary received the invoice
        const subsidiary = CONFIG.subsidiaryByVat[(inv.buyer || {}).vatIdentifier];
        if (subsidiary) bill.setValue({ fieldId: 'subsidiary', value: subsidiary });

        bill.setValue({ fieldId: 'tranid', value: inv.invoiceNumber });        // the supplier's invoice number
        bill.setValue({ fieldId: 'trandate', value: toDate(inv.issueDate) });
        if (inv.dueDate) bill.setValue({ fieldId: 'duedate', value: toDate(inv.dueDate) });
        bill.setValue({ fieldId: 'approvalstatus', value: 1 });               // 1 = Pending Approval
        bill.setValue({ fieldId: 'memo', value: 'E-invoice. Total: ' + totals.grandTotalAmount + ' ' + inv.currency });

        (inv.lines || []).forEach((line) => {
            const rate = (line.vatInformation || {}).rate || 0;
            const amount = Math.round((line.quantity || 1) * line.priceDetails.netPrice * 100) / 100;
            bill.selectNewLine({ sublistId: 'expense' });
            bill.setCurrentSublistValue({ sublistId: 'expense', fieldId: 'account', value: CONFIG.expenseAccountId });
            bill.setCurrentSublistValue({ sublistId: 'expense', fieldId: 'amount', value: amount });
            bill.setCurrentSublistValue({ sublistId: 'expense', fieldId: 'memo', value: line.item.name });
            if (CONFIG.purchaseTaxCodes[rate]) {
                // Legacy Tax: the tax code for this rate. SuiteTax calculates the tax itself.
                bill.setCurrentSublistValue({ sublistId: 'expense', fieldId: 'taxcode', value: CONFIG.purchaseTaxCodes[rate] });
            }
            bill.commitLine({ sublistId: 'expense' });
        });
        return bill.save();
    };

The status field approvalstatus is used when vendor bill approval is turned on under Setup, Accounting, Accounting Preferences, Approval Routing. Without approval routing, leave that line out and save the bill as it is; your team reviews it in the bill list. The total from the invoice goes into the memo, so the clerk can compare it with the bill. The currency comes from the vendor record. The source file is attached to the bill, so the original e-invoice is one click away for the auditor.

On Legacy Tax, the vendor's VAT id is in vatregnumber. On SuiteTax, it is on the Tax Registrations subtab of the vendor. Check in a saved vendor search of your account which filter holds it, and use that filter in findVendor. To match purchase orders, search the open purchaseorder records of the vendor by purchaseOrderReference and transform the order into the bill with record.transform.


XRechnung for German public buyers

A German public buyer (federal, state or municipal) needs XRechnung, not ZUGFeRD. XRechnung is XML only. There is no PDF. The portals ZRE and OZG-RE check every file against the KoSIT rules. The library creates it with one more endpoint and a few more fields:

    // ixml_lib.js (part 8: XRechnung for customers with a Leitweg-ID)
    // In buildInvoice, write "const invoice = {" instead of "return {", then add before the end:
        if (invoice.buyerReference) {
            invoice.seller.contact = {
                name: invoice.seller.name,
                phone: sub.getValue({ fieldId: 'phone' }),
                email: sub.getValue({ fieldId: 'email' })
            };
            invoice.seller.electronicAddress = { identifier: invoice.seller.vatIdentifier, schemeId: '9930' };
            invoice.buyer.electronicAddress = invoice.buyer.vatIdentifier
                ? { identifier: invoice.buyer.vatIdentifier, schemeId: '9930' }
                : { identifier: invoice.buyerReference, schemeId: '0204' };
        }
        return invoice;

    // One function that picks the format by the customer
    const createEInvoice = (invoiceId) => {
        const invoice = buildInvoice(invoiceId);
        if (!invoice.buyerReference) {
            const pdf = postJson('/v1/create/zugferd', { invoice });
            return storeAndAttach(invoiceId, invoice.invoiceNumber + '-zugferd.pdf', file.Type.PDF, pdf.body);
        }
        const xml = postJson('/v1/create/xrechnung', { invoice });
        return storeAndAttach(invoiceId, invoice.invoiceNumber + '-xrechnung.xml', file.Type.XMLDOC, xml.body);
    };

The Leitweg-ID is a routing id. The public buyer assigns it when you register as a supplier. Rule BR-DE-15 rejects an invoice without it. Create the entity field custentity_leitweg_id under Customization, Lists, Records, & Fields, Entity Fields, and apply it to customers. Fill it on every public-sector customer. The mapping copies it into buyerReference. The seller contact (name, phone, email) and the electronic addresses are mandatory under the German rules. Scheme 9930 stands for a German VAT id. A buyer without a VAT id gets the Leitweg-ID with scheme 0204. To use the new function, call lib.createEInvoice instead of lib.createZugferd in both entry scripts.

The API answers with the XML. It is already checked against the KoSIT rules. The library saves it as an XML file and attaches it to the invoice. Send it the way the buyer asks for: 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 files. XRechnung is also allowed for B2B invoices, so the /email route works for it too. The XRechnung API toolkit explains the format in depth.


OneWorld subsidiaries, SuiteTax and governance

One seller per subsidiary. In OneWorld, each subsidiary is its own legal entity with its own VAT id, address and bank account. The library reads the seller from the subsidiary field of each invoice. So one deployment serves every subsidiary. CONFIG.germanSubsidiaries limits it to the German ones. A French subsidiary uses the same library with /v1/create/facturx. Fill the legal name, the main address, the VAT registration and custrecord_ixml_iban on every subsidiary that issues e-invoices. Without OneWorld, load the seller from Company Information with config.load and config.Type.COMPANY_INFORMATION from N/config; the VAT field there is employerid.

Choosing the VAT id. On Legacy Tax, the subsidiary has one VAT registration number in federalidnumber. On SuiteTax, a subsidiary can hold several registrations on the Tax Registrations subtab, for example a German and an Austrian VAT id. The function vatId takes the row whose nexuscountry matches the country of the subsidiary address. If an invoice uses another registration, the transaction shows it in the Subsidiary Tax Reg. Number field. Use that value for seller.vatIdentifier on those invoices.

SuiteTax lines. On SuiteTax, the item sublist has no rate column. The rates are on the Tax Details subtab. The function rateReader links each item line to its tax line by taxdetailsreference. On Legacy Tax, the Tax Details subtab does not exist, and the function reads taxrate1. The same code runs in both kinds of account.

Governance. One e-invoice costs well under 100 usage units. Each https.post costs 10 units. The record loads, the file save, the attachment and the checkbox update cost the rest. A User Event script may use 1,000 units, so one invoice fits easily. Each map call of a Map/Reduce script also has 1,000 units, and the script handles one invoice per call. After 10,000 units, or after the Yield After Minutes on the deployment, the job yields and NetSuite continues it in a new job. So a month-end batch of thousands of invoices needs no extra code. A scheduled script would work too, but it stops at 10,000 units and does not yield on its own.

Time limits. https.post waits up to 5 seconds for the connection and up to 45 seconds for the request. If a call times out, the invoice keeps its unchecked box and the next Map/Reduce run creates it.

Sandbox and production. API secrets are stored per account. Create the secret in the sandbox and in production. Use a separate InvoiceXML API key for each, so test invoices never mix with real ones.


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 whole integration is one library and four small scripts: store the key, map, create and attach or email, check and import what comes in. No format logic in NetSuite. Nothing to redeploy when the formats change. This guide is part of the integrations hub, which also covers SAP, Business Central, 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