Invoices
An invoice is a billable document — line items, totals, discounts, payment terms — that produces a charge against a customer or account. Invoices are how subscriptions bill (one invoice per cycle), and they're also a first-class primitive for manual B2B billing: net-30 terms, ad-hoc invoices for consulting work, milestone billing.
Where invoices come from
Two creation paths:
- Automatic from subscriptions — each billing cycle, the subscription bills the customer directly via the renewal chain (
Subscription → ChargeIntent → Charge → Transfer). An invoice is not generated for the normal renewal — it's only created in the optional metered-billing trueup path (Billings::HandleSubscriptionRenewalService). If you need an invoice artifact per renewal for accounting or PDF delivery, generate one application-side off thesubscription.renewedwebhook. - Manual —
POST /v1/invoicesto create an invoice directly. Useful for one-off B2B billing where you're not running a recurring subscription, or for charge flows that need invoice-style itemization and customer-facing payment terms.
Both paths produce the same primitive. From here on, "invoice" means either.
Collection methods
The collection_method field controls how the invoice settles:
auto_charge— Frame charges theauto_charge_payment_methodwhen the invoice is issued. For subscriptions, this is the default. For B2B manual invoices, use this when you've stored the customer's payment method and have authority to charge.request_payment— Frame doesn't auto-charge. Instead, the invoice is delivered to the customer (email, dashboard, or via a payment link) and they pay it on their own. Use for net-terms billing.
auto_charge_payment_method is required if collection_method = auto_charge. Without it, the invoice has nothing to charge.
Lifecycle
Invoices carry a status state machine:
draft— created but not finalized. Line items can still be added/edited; not yet sent to the customer or charged.outstanding— issued and awaiting payment. Set when you callissueon the invoice and the due date is in the future.due— issued and the due date has arrived. Same operational state asoutstandingfor anoutstandinginvoice that's tipped over its due date — Frame transitions automatically.overdue— due date passed without payment.paid— fully collected.written_off— manually marked uncollectible.voided— canceled before collection.
Transitions:
draft → outstanding | dueviaissue(which-state depends on whether the due date is future or past).outstanding → duevia time passing (mark_as_due).due → overduevia time passing (mark_as_overdue).outstanding | due | overdue → paidviapay.- Terminal:
paid,written_off,voided.
Fields
curl --request POST \
--url https://api.framepayments.com/v1/invoices \
--header "Authorization: Bearer $FRAME_SECRET_KEY" \
--header 'Content-Type: application/json' \
--data '{
"customer": "<customer_id>",
"collection_method": "auto_charge",
"payment_method": "<payment_method_id>",
"net_terms": 0,
"description": "Pro Plan — June 2026",
"promotion_codes": ["LAUNCH10"],
"line_items": [
{
"product": "<product_id>",
"quantity": 1
}
]
}'
The interesting fields:
customer/account— owner. Mutually exclusive, optional only in unusual flows.collection_method— see above.payment_method— request-body parameter name. Frame maps it to theauto_charge_payment_methodfield on the invoice record. Required forauto_charge.net_terms— days from issue to due date.0means due immediately on issue;30is net-30; etc.number— invoice number for your internal/customer reference. Frame can auto-generate; supply if you want a specific format.description/memo— descriptive text.promotion_codes[]— array of code strings; Frame validates each and applies the discounts (viaDiscounts::ComputeService) to the invoice's gross amount.line_items[]— array of line items. Each entry takesproduct(FK) andquantityonly — pricing is inherited from the product. See invoice line items for the constraints.metadata— k/v pairs for your internal tracking.
On the response, the invoice's amount_cents is the final post-discount total. The breakdown lives in gross_amount_cents, discount_amount_cents, and discount_count.
Issuing
A draft invoice doesn't bill anyone. Call POST /v1/invoices/<id>/issue to transition it to outstanding (or due, if the due date has already passed):
- For
auto_chargeinvoices, Frame attempts the charge againstauto_charge_payment_methodduring the issue flow. - For
request_paymentinvoices, Frame surfaces the payment link / sends notification per your platform's setup.
Once issued, the line-item composition is frozen. Edits after issue land on the next invoice (for subscriptions) or require voiding and re-creating.
Discounts
Promotion codes apply at the invoice level via promotion_codes[] on create. Frame's Discounts::ComputeService evaluates each code against the validation rules and computes the resulting discount.
The fields on the invoice after discount application:
gross_amount_cents— total before discounts.discount_amount_cents— total discount applied.discount_count— number of codes that successfully resolved (0 if all rejected).amount_cents— final amount to charge (gross_amount_cents - discount_amount_cents).
If discount_count is 0 after you sent codes, none of them resolved — surface this to the customer ("your code couldn't be applied — checkout proceeded at full price") rather than silently dropping the discount.
Failed collections
When the auto_charge_payment_method charge fails on issue (for auto_charge invoices):
- The invoice transitions to
outstanding/dueas if the issue succeeded — the invoice exists, it just hasn't been collected. - The parent subscription (if any) transitions its
renewal_statustofailed.
Whether Frame auto-retries depends on which path the failure happened on:
- Subscription renewals do auto-retry. When a renewal charge fails, Frame schedules a retry 24 hours later (
Subscriptions::RenewSubscriptionJob), up to a maximum of 3 attempts. After the third failure, the subscription transitions topast_due/unpaidand stops retrying. - Manual invoice charges do not auto-retry. A failed
POST /v1/invoices/<id>/payon a standalone invoice — i.e. one not created by the subscription renewal flow — leaves the invoice in its outstanding state. There's no dunning engine for these. Re-issue manually, prompt the customer to update their payment method, or whatever your platform's recovery flow does.
Failed-collection recovery for manual invoices is a key area where your platform owns the workflow. See build subscriptions for the subscription side; for manual invoices, listen for invoice.overdue and trigger your own retry.
Webhooks
Invoice events fire on state transitions and line-item mutations:
invoice.created— fired on initial creation (even drafts).invoice.updated— any mutation to the invoice itself.invoice.issued— draft → outstanding / due.invoice.paid— collected successfully.invoice.overdue— due → overdue.invoice.voided— voided before collection.invoice.deleted— soft-deleted (draft only).invoice.line_item.created— line item added.invoice.line_item.updated— line item edited.invoice.line_item.deleted— line item removed.
Subscribe to invoice.issued and invoice.paid for the typical "did this bill go out / did it collect" tracking.
Gotchas
Symptom: you created an invoice but no charge happened. Why: drafts don't charge. You need to call issue to transition the invoice out of draft. Fix: POST /v1/invoices/<id>/issue.
Symptom: you applied a promotion_codes array but discount_amount_cents came back zero. Why: none of the codes resolved — one of the validation checks failed for each. Fix: inspect each code's status via GET /v1/promotion_codes/<id>; surface the issue to the customer at checkout.
Symptom: you edited a line item on an issued invoice and it didn't change the charge amount. Why: line items freeze on issue. Edits after that don't retroactively re-bill. Fix: void the invoice and create a new one if the change matters, or absorb it into the next billing cycle.
Symptom: request_payment collection method but the customer says they never got an email. Why: notification delivery depends on the customer record having a deliverable email and on your Frame settings being configured for outbound mail. Fix: check the customer's email field; check Frame's delivery logs in the dashboard.
Symptom: a manually-created invoice failed collection and now sits in overdue indefinitely. Why: manual invoice charges don't auto-retry (only subscription renewals do, on the 24h × 3 schedule). Without application-side intervention, manual invoices stay overdue. Fix: build a recovery flow — listen for invoice.overdue, prompt the customer, re-issue once the payment method is updated.
Reference
For the full API surface, see POST/v1/invoices and the rest of the Invoices resource.