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 blockSurfaceStatus
Invoices with NET due datesPOST /v1/invoices + …/finalize with days_until_dueLive
Customer objects with metadataPOST /v1/customersLive
Expected payments with match_criteriaPOST /v1/expected_paymentsLive
PO number through hosted checkoutmetadata on POST /v1/checkout/sessionsLive
Dunning for failed subscription charges/v1/dunning/schedules, /v1/dunning/campaignsLive — campaigns require a subscription_id; not applicable to standalone NET-terms invoices
Criteria-driven auto-matchingRoadmap — matching is via an explicit endpoint today
First-class purchase-order object, reservation + consumption trackingRoadmap
Milestone-based termsRoadmap
There is no 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.

net-terms-invoice.sh
# 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).

declare-expected-payment.sh
# 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.

collect-po-payment.ts
// 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-expected-payment.sh
# 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_date passing does not change invoice status by itself — an invoice past due is still open. Sweep GET /v1/invoices?customer_id=… against due_date in your AR job.
  • The dunning endpoints (/v1/dunning/schedules, /v1/dunning/campaigns) retry failed subscription charges — a campaign requires a subscription_id, so they cannot chase the standalone send_invoice invoices 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) or POST /v1/invoices/:id/mark_uncollectible (bad debt, exhausted collection).

Webhooks

Event typeWhen it fires
invoice.createdA draft invoice was created.
invoice.finalizedThe invoice went draft → open; number and due date are set.
invoice.paidA payment was recorded against the invoice.
invoice.voidedThe invoice was cancelled before payment.
invoice.uncollectibleThe 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_orders object — 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.
Everything above the roadmap section runs against live routes on api.trustfabric.ai today — no feature flags required. Test keys (sk_test_…) work throughout; see the Quickstart for key setup.