PO matching & NET terms
Enterprise buyers open a purchase order before they sign, and they pay on NET terms — not on charge. This guide shows the supported pattern for running that flow on PAID today: invoices with NET-30/60/90 due dates, a PO number carried on every object, and pre-declared expected payments for reconciliation. It also says plainly which parts are still on the roadmap.
What exists today
| Building block | Surface | Status |
|---|---|---|
| Invoices with NET due dates | POST /v1/invoices + …/finalize with days_until_due | Live |
| Customer objects with metadata | POST /v1/customers | Live |
Expected payments with match_criteria | POST /v1/expected_payments | Live |
| PO number through hosted checkout | metadata on POST /v1/checkout/sessions | Live |
| Dunning for failed subscription charges | /v1/dunning/schedules, /v1/dunning/campaigns | Live — campaigns require a subscription_id; not applicable to standalone NET-terms invoices |
| Criteria-driven auto-matching | — | Roadmap — matching is via an explicit endpoint today |
| First-class purchase-order object, reservation + consumption tracking | — | Roadmap |
| Milestone-based terms | — | Roadmap |
purchase_orders endpoint yet. The pattern below stamps the PO number
onto three objects you already have — the invoice (memo + idempotency key), the
checkout session (metadata), and the expected payment
(match_criteria) — so every record carries the same join key. The roadmap items
are tracked publicly on the comparison matrix.1. Invoice on NET terms
Invoices move draft → open → paid (or void / uncollectible). Line items can only be added while the invoice is a draft;
finalizing makes it immutable, assigns a human-readable number (INV-…), and sets due_date from days_until_due. Pass 30, 60,
or 90 — any positive integer works; omitting it defaults to NET-30. Use collection_method: "send_invoice" for terms billing: it marks the invoice as
payable on terms rather than by an immediate charge.
# 1. The buyer — one customer object per counterparty.
curl https://api.trustfabric.ai/v1/customers \
-H "Authorization: Bearer $PAID_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Globex Procurement",
"email": "[email protected]",
"metadata": {"vendor_id": "V-0042"}
}'
# => { "id": "cus_…", ... }
# 2. Draft invoice. The PO number goes in memo (shown to the buyer's AP
# team) and in the idempotency key — one invoice per PO, so retries
# and duplicate submissions collapse onto the same invoice.
curl https://api.trustfabric.ai/v1/invoices \
-H "Authorization: Bearer $PAID_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "cus_YOUR_CUSTOMER",
"currency": "USD",
"collection_method": "send_invoice",
"idempotency_key": "inv-PO-4711",
"memo": "PO-4711",
"description": "Q3 implementation services"
}'
# => { "id": "inv_…", "status": "draft", ... }
# 3. Line items — draft invoices only; totals are recomputed server-side.
# unit_amount is int64 minor units ($50,000.00 -> 5000000).
curl https://api.trustfabric.ai/v1/invoices/inv_YOUR_INVOICE/line_items \
-H "Authorization: Bearer $PAID_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"description": "Implementation services (PO-4711)",
"quantity": 1,
"unit_amount": 5000000,
"currency": "USD"
}'
# 4. Finalize on NET-60: due_date = finalization date + days_until_due.
# Omitted (or <= 0) defaults to NET-30; pass 90 for NET-90.
curl https://api.trustfabric.ai/v1/invoices/inv_YOUR_INVOICE/finalize \
-H "Authorization: Bearer $PAID_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"days_until_due": 60}'
# => { "status": "open", "number": "INV-…", "due_date": "…", ... }One honest note on delivery: POST /v1/invoices/:id/send records sent_at for your audit trail — it does not email the invoice for you. Deliver it
through your own channel. Buyers with a customer-portal session can also fetch the PDF via GET /v1/public/customer_portal/sessions/:id/invoices/:invoice_id/pdf.
2. Pre-declare the expected payment
An expected payment is the receivable side of the ledger: you tell PAID "I expect this amount,
in this currency, from this counterparty, within N days." It moves open → matched, cancelled, or expired — all terminal.
Expiry is automated: a sweep flips open records past their expires_at to expired, so set expires_in_days to your
terms window plus a grace period (default 30, maximum 365).
# Pre-declare the inbound: "I expect $50,000 from this counterparty."
# match_criteria is a free-form string map — put the PO number and
# invoice id here so the open receivable carries its own join keys.
curl https://api.trustfabric.ai/v1/expected_payments \
-H "Authorization: Bearer $PAID_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount_minor": 5000000,
"currency": "USD",
"direction": "credit",
"counterparty_id": "cus_YOUR_CUSTOMER",
"description": "PO-4711 / NET-60 invoice",
"match_criteria": {
"po_number": "PO-4711",
"invoice_id": "inv_YOUR_INVOICE"
},
"expires_in_days": 75
}'
# => { "ID": "expp_…", "Status": "open", "ExpiresAt": "…", ... }
# Note: response keys on this surface are Go-style capitalized
# (ID, Status, MatchCriteria) rather than snake_case.match_criteria is stored and returned on every read, but nothing consumes it
automatically yet: today, matching is performed via the explicit POST /v1/expected_payments/:id/match endpoint (admin scope). Criteria-driven
auto-matching during reconciliation is on the roadmap — don't build a flow that assumes an
inbound payment will close the record on its own.3. Collect and record the payment
If the buyer pays through hosted checkout, put the PO number in the session's metadata so the payment is attributable without parsing bank references. When
your webhook receives payment_intent.captured (or checkout.session.completed for hosted-checkout flows; see Webhooks for signature verification), record the payment against
the invoice with POST /v1/invoices/:id/pay — recording is explicit, not
automatic, which is what you want when a single wire can cover several invoices.
// Plain fetch — no SDK required (Node 18+, Bun, Deno, Workers).
const API = "https://api.trustfabric.ai";
const headers = {
"Authorization": "Bearer " + process.env.PAID_SECRET_KEY,
"Content-Type": "application/json",
};
// 1. Hosted checkout carrying the PO number in metadata, so the payment
// that comes back is attributable to PO-4711 without string-parsing
// bank references.
const res = await fetch(API + "/v1/checkout/sessions", {
method: "POST",
headers: { ...headers, "Idempotency-Key": "pay-PO-4711" },
body: JSON.stringify({
mode: "payment",
currency: "USD",
payment_method_types: ["card"],
customer_id: "cus_YOUR_CUSTOMER",
line_items: [
{ ad_hoc: { amount: 5000000, currency: "USD", description: "INV (PO-4711)" }, quantity: 1 },
],
metadata: { po_number: "PO-4711", invoice_id: "inv_YOUR_INVOICE" },
success_url: "https://example.com/thanks",
cancel_url: "https://example.com/cancel",
}),
});
const session = await res.json(); // { id, url, ... } — send the buyer to session.url
// 2. In your webhook handler, when payment_intent.captured arrives,
// record the payment against the invoice. Status flips open -> paid
// and the invoice.paid webhook fires. (Hosted checkout also emits
// checkout.session.completed if you'd rather key off the session.)
await fetch(API + "/v1/invoices/inv_YOUR_INVOICE/pay", {
method: "POST",
headers,
body: JSON.stringify({ payment_intent_id: event.data.id }),
});4. Close the loop
Once the money has arrived and the invoice is paid, close the expected payment.
Query open receivables with GET /v1/expected_payments?status=open, list a buyer's
invoices with GET /v1/invoices?customer_id=cus_…, and match your open records to
observed financial events by the po_number you stamped in match_criteria.
# Close the receivable against the observed financial event.
# This endpoint requires admin scope; matched is terminal — the
# record never re-opens.
curl https://api.trustfabric.ai/v1/expected_payments/expp_YOUR_EXPECTED/match \
-H "Authorization: Bearer $PAID_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"financial_event_id": "YOUR_FINANCIAL_EVENT_ID"}'
# Or cancel it if the PO was withdrawn:
curl https://api.trustfabric.ai/v1/expected_payments/expp_YOUR_EXPECTED/cancel \
-H "Authorization: Bearer $PAID_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "PO-4711 withdrawn by buyer"}'When the buyer doesn't pay
due_datepassing does not change invoice status by itself — an invoice past due is stillopen. SweepGET /v1/invoices?customer_id=…againstdue_datein your AR job.- The dunning endpoints (
/v1/dunning/schedules,/v1/dunning/campaigns) retry failed subscription charges — a campaign requires asubscription_id, so they cannot chase the standalonesend_invoiceinvoices this guide creates. Until invoice-level dunning ships (see the roadmap below), escalation is your AR job's due-date sweep plus the terminal endpoints below. - Terminal outcomes:
POST /v1/invoices/:id/void(cancel before payment) orPOST /v1/invoices/:id/mark_uncollectible(bad debt, exhausted collection).
Webhooks
| Event type | When it fires |
|---|---|
invoice.created | A draft invoice was created. |
invoice.finalized | The invoice went draft → open; number and due date are set. |
invoice.paid | A payment was recorded against the invoice. |
invoice.voided | The invoice was cancelled before payment. |
invoice.uncollectible | The invoice was written off as bad debt. |
On the roadmap
Said plainly, so you can plan around it — these do not exist yet, in any beta or flag-gated form. When they ship, they will appear in the API reference and this guide:
- A first-class
purchase_ordersobject — the PO as an API resource with its own balance. - Reservation and consumption tracking: invoices reserving against a PO's remaining balance, with partial consumption across multiple invoices.
- Criteria-driven auto-matching of expected payments during reconciliation.
- Invoice-level dunning — retry and escalation for past-due NET-terms invoices (today's
dunning campaigns require a
subscription_id). - Milestone-based payment terms (pay-on-delivery-milestone rather than pay-by-date).
- W-9 / W-8BEN / W-8BEN-E capture per customer.
api.trustfabric.ai today — no feature flags required. Test keys
(sk_test_…) work throughout; see the Quickstart for
key setup.