Solution overview
Pleaxy connects to QuickBooks Online and Xero out of the box. Larger finance teams usually run something else: NetSuite, SAP S/4HANA, Microsoft Dynamics 365, Oracle Fusion, Sage Intacct or Workday. You don't need to wait for a dedicated connector to use Pleaxy with them. The Pleaxy API and webhooks give you everything you need to build the round trip yourself, in three small pieces of code.
In this post, I show you how to connect Pleaxy to NetSuite, step by step: approved invoices become vendor bills, and the payments your accounts payable team makes in NetSuite come back to Pleaxy. Then I show what changes for the other ERPs, and what to keep in mind before you go live.
Pleaxy
An invoice is checked against its purchase order and contract, and the people in its approval chain approve it.
Your connector
A webhook says the invoice changed. Your connector reads it and creates a vendor bill in NetSuite.
NetSuite
Accounts payable pays the bill in NetSuite, as it pays every other bill.
Pleaxy
Your connector records the payment in Pleaxy. The invoice reads as paid, and the supplier gets the payment notice and remittance advice.
Nothing is typed twice, and each system keeps its job: Pleaxy decides what should be paid, NetSuite books it and pays it.
Prerequisites
In Pleaxy (an administrator of the buyer workspace)
- Your Workspace ID, from Company profile, and an API key to read invoices.
- A second API key with the
payments:recordscope, created with the administrator or finance role. Only a key with this scope can record payments, so keep it apart from the first. - A webhook endpoint: Buyer profile, Integrations tab, Webhooks, Add endpoint. Pick the
invoice.updatedevent and copy the signing secret; it is shown once.
In NetSuite
- REST web services and token-based authentication enabled, an integration record, and an access token for a role that can create vendor bills and run SuiteQL queries on transactions.
- A mapping from each Pleaxy supplier to its NetSuite vendor, and the expense and tax accounts their bills are booked to. A short table in your connector is enough to start.
Step 1: hear about approved invoices
First, two small helpers: one calls the Pleaxy API with an API key, the other calls NetSuite's REST web services.
// Pleaxy: your buyer workspace's API base, read with an API key.
const PLEAXY = `${process.env.PLEAXY_API_BASE_URL}/v1/workspaces/${process.env.PLEAXY_WORKSPACE_ID}`;
export async function pleaxy(path, { method = "GET", body, apiKey = process.env.PLEAXY_API_KEY } = {}) {
const res = await fetch(`${PLEAXY}${path}`, {
method,
headers: { "X-Api-Key": apiKey, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
});
if (!res.ok) throw new Error(`Pleaxy ${res.status}: ${await res.text()}`);
return res.json();
}
// NetSuite REST web services. signNetSuite adds the token-based authentication
// header (OAuth 1.0a, HMAC-SHA256) with the OAuth library you already use.
const NETSUITE = `https://${process.env.NS_ACCOUNT_HOST}.suitetalk.api.netsuite.com/services/rest`;
export async function netsuite(method, path, body, headers = {}) {
const url = `${NETSUITE}${path}`;
const res = await fetch(url, {
method,
headers: { ...signNetSuite(method, url), "Content-Type": "application/json", ...headers },
body: body && JSON.stringify(body),
});
if (!res.ok) throw Object.assign(new Error(`NetSuite ${res.status}: ${await res.text()}`), { status: res.status });
return res.status === 204 ? undefined : res.json(); // a created record answers 204
}When an invoice is approved, Pleaxy POSTs an invoice.updated event to your endpoint. The event names the invoice but doesn't contain it, so a forged request can't put anything into your books: your connector reads the invoice itself, with its own key. Check the Pleaxy-Signature header anyway, answer within 10 seconds, and do the work after. Pleaxy retries any delivery that doesn't get a 2xx, for about two days.
import crypto from "node:crypto";
import express from "express";
import { syncInvoice } from "./vendor-bill.mjs";
const app = express();
// Pleaxy signs the raw body, so read it as text and parse it after checking.
app.post("/pleaxy/webhooks", express.text({ type: "application/json" }), (req, res) => {
if (!isFromPleaxy(req.get("Pleaxy-Signature") ?? "", req.body, process.env.PLEAXY_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
res.sendStatus(204); // answer within 10 seconds, then do the work
if (event.type === "invoice.updated") {
syncInvoice(event.documentId).catch(console.error); // in production: a job queue
}
});
// Pleaxy-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">
function isFromPleaxy(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
if (!parts.t || !parts.v1 || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
const actual = Buffer.from(parts.v1, "hex");
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
}
app.listen(3000);Prefer polling, or want a safety net for a day your endpoint was down? GET /sync/events lists the same events, oldest first, from a cursor you keep. See Document sync.
Step 2: book the vendor bill
invoice.updated also fires when an invoice is rejected or its bank details change, so the connector first asks where the invoice stands and goes on only when it is approved. Then it reads the invoice and creates the vendor bill, with the supplier's invoice number, dates, lines, discounts, charges and tax, so the bill adds up to the amount Pleaxy approved.
import { pleaxy, netsuite } from "./clients.mjs";
// Your mapping: the NetSuite vendor for each Pleaxy supplier (supplierOrgId),
// and the accounts its spend and tax are booked to.
const VENDORS = {
"org_01k5x8q3m7c2vd9pzt4wbh6ner": { vendorId: "1842", expenseAccountId: "6120" },
};
const TAX_ACCOUNT_ID = "2310";
export async function syncInvoice(invoiceId) {
// invoice.updated also fires for rejections and bank details:
// book only an invoice that is approved and not yet paid.
const status = await pleaxy(`/invoices/${invoiceId}/status`);
if (status.status !== "approved") return;
// Events can arrive twice. The external ID makes the bill unique in NetSuite,
// so check for it first: a second delivery finds the bill and stops.
const externalId = `pleaxy-${invoiceId}`;
if (await billExists(externalId)) return;
const invoice = await pleaxy(`/invoices/${invoiceId}`);
const vendor = VENDORS[invoice.supplierOrgId];
if (!vendor) throw new Error(`No NetSuite vendor mapped for ${invoice.supplier}`);
// Lines, invoice-level discounts and charges, and tax add up to the invoice's total.
const line = (amount, memo, accountId = vendor.expenseAccountId) => ({ account: { id: accountId }, amount, memo });
const items = [
...invoice.lineItems.map((l) => line(l.amount, l.description)),
...(invoice.adjustments ?? []).map((a) => line(a.type === "discount" ? -a.amount : a.amount, a.description)),
...(invoice.taxAmount ? [line(invoice.taxAmount, "Tax", TAX_ACCOUNT_ID)] : []),
];
await netsuite("POST", "/record/v1/vendorBill", {
externalId,
entity: { id: vendor.vendorId },
tranId: status.invoiceNumber, // the supplier's invoice number
tranDate: status.issueDate,
dueDate: status.dueDate,
memo: [`Pleaxy ${invoiceId}`, invoice.po].filter(Boolean).join(" · "),
approvalStatus: { id: "2" }, // Approved: people already approved it in Pleaxy
expense: { items },
});
}
async function billExists(externalId) {
try {
await netsuite("GET", `/record/v1/vendorBill/eid:${externalId}?fields=id`);
return true;
} catch (err) {
if (err.status === 404) return false;
throw err;
}
}- One bill per invoice. Events can arrive twice. The bill's external ID,
pleaxy-plus the invoice ID, is unique in NetSuite, so a repeated event finds the bill and stops. - Approved once. The bill goes in as approved: the approvers already decided in Pleaxy, with the invoice matched against its order, contract and usage. If your NetSuite routes bills for approval, leave it out and they will approve it there too.
- The PDF, if you want it.
GET /invoices/{id}/documentreturns the invoice as UBL 2.1, and the invoice's PDF can be attached to the bill as a file.
Step 3: bring payments back
Accounts payable pays the bill in NetSuite, by check, ACH or a payment run. A scheduled job then finds the Pleaxy bills that are paid in full with a SuiteQL query, and records them in Pleaxy with RecordPayments, up to 50 at a time.
import { pleaxy, netsuite } from "./clients.mjs";
// Bills that came from Pleaxy, now paid in full, with the bill payment that
// settled them, changed since the last run. Run it every 15 minutes or so.
const paidBills = (since) => `
SELECT DISTINCT bill.externalid AS externalid, pay.id AS paymentid, pay.tranid AS tranid,
TO_CHAR(pay.trandate, 'YYYY-MM-DD') AS paydate
FROM transaction bill
JOIN NextTransactionLineLink link ON link.previousdoc = bill.id
JOIN transaction pay ON pay.id = link.nextdoc
WHERE bill.type = 'VendBill' AND bill.status = 'B' -- B: paid in full
AND pay.type = 'VendPymt'
AND bill.externalid LIKE 'pleaxy-%'
AND pay.lastmodifieddate >= TO_DATE('${since}', 'YYYY-MM-DD HH24:MI:SS')`;
export async function syncPayments(since) {
const { items } = await netsuite("POST", "/query/v1/suiteql", { q: paidBills(since) }, { Prefer: "transient" });
// Pleaxy records one payment for the whole amount due: for a bill paid in
// instalments, send the payment that finished it.
const latest = new Map();
for (const row of items) {
if (!latest.has(row.externalid) || row.paydate >= latest.get(row.externalid).paydate) latest.set(row.externalid, row);
}
const payments = [...latest.values()].map((row) => ({
invoiceId: row.externalid.replace(/^pleaxy-/, ""),
method: "ach", // or map it from the bill payment's payment method
paymentDate: row.paydate,
reference: row.tranid || `NS-${row.paymentid}`, // the supplier sees it: never an account number
}));
// Up to 50 a call, with a key that has the payments:record scope.
// Repeating a payment is safe: it comes back as already_recorded.
for (let i = 0; i < payments.length; i += 50) {
const { results } = await pleaxy("/invoices/payment-records", {
method: "POST",
body: { payments: payments.slice(i, i + 50) },
apiKey: process.env.PLEAXY_PAYMENTS_KEY,
});
for (const r of results.filter((r) => r.outcome === "refused")) {
console.error(`${r.invoiceId}: ${r.code} ${r.message}`);
}
}
}Each recorded invoice reads as paid in Pleaxy. Its supplier gets a payment notice and a remittance advice with your reference, and their own system gets an invoice.paid event. Recording a payment moves no money: it says what NetSuite already paid. Running the job twice is safe, because a payment recorded before comes back as already_recorded.
The same loop for SAP, Dynamics and the rest
The Pleaxy half doesn't change: the webhook, the two reads and RecordPayments are the same for every ERP. Only the ERP half does: which record the approved invoice becomes, and where the connector finds that it was paid.
- SAP S/4HANA - a supplier invoice, created through its supplier invoice API; payments from the cleared items in accounts payable.
- Microsoft Dynamics 365 Business Central - a purchase invoice, created and posted through its API; payments from the vendor ledger entries.
- Microsoft Dynamics 365 Finance - a vendor invoice, through its data entities; payments from the vendor transactions settled against it.
- Oracle Fusion Cloud ERP - a payables invoice, through its REST API; payments from the payables payments made against it.
- Sage Intacct - an AP bill; payments from the AP payments that paid it.
- Workday - a supplier invoice, through its web services; payments from the supplier invoice's payment status.
An ERP or e-invoicing hub that already takes standard documents can skip the mapping: Pleaxy gives each invoice as a UBL 2.1 Invoice, its status as a cXML StatusUpdateRequest or UBL ApplicationResponse, and each payment as a cXML PaymentRemittanceRequest or UBL RemittanceAdvice.
Good to know
- Approvals stay with people. An API key can read invoices and record payments, but it can never approve or reject an invoice: that is always the person the approval step names, in Pleaxy.
- Payments are for the full amount due. Pleaxy doesn't record part payments, so send a bill once it is paid in full.
- Your team sees what the key did. Every call that records payments raises a notification in the workspace naming the key and the invoices, and a key records at most 500 payments a day.
- Delivery problems are visible. The webhook's Deliveries list shows each event, its attempts and what your endpoint answered, and Send test sends a signed ping.
Conclusion
In this post, I showed you how to connect Pleaxy to NetSuite with three small pieces of code: a webhook receiver, a job that books approved invoices as vendor bills, and a job that brings payments back. Your approvers keep deciding in Pleaxy, your accounts payable team keeps paying in NetSuite, and your suppliers hear about every payment without anyone retyping it.
To get started, create an API key and a webhook endpoint in your buyer workspace, and read the Webhooks and Document sync references. If you run an ERP that isn't covered here, or want a hand with your first connector, talk to us.
About the author
Ryan Brown
Lead developer, Pleaxy platform
Ryan Brown is the lead developer of the Pleaxy platform. He builds the API, the webhooks and the integrations that connect Pleaxy to the accounting and ERP systems finance teams already run.