Mutations
Create invoice
Section titled “Create invoice”mutation { createInvoice(input: { customerName: "Acme Corp" customerEmail: "billing@acme.com" collectionMethod: SEND_INVOICE dueDate: "2026-07-01" currency: "USD" lineItems: [ { description: "Web development" quantity: 40 unitAmount: "150.00" } ] }) { id invoiceNumber totalAmount status }}Each lineItems entry accepts an optional vatRateId — a per-line VAT rate override, only
consulted when the merchant is in VAT mode (a VAT number and at
least one active rate configured). It’s ignored otherwise, and it’s usable via API key (see
below).
customerName and customerEmail attach the payer’s details to the invoice. When the payment
is paid, the payer becomes a customer record keyed by the wallet they
paid from, seeded with the name and email you provided. Pass customerId instead to link an
existing customer.
lateFeesWaived: true creates the draft with late fees waived. It is refused unless collectionMethod is SEND_INVOICE.
Send invoice
Section titled “Send invoice”Sending takes one of two forms, chosen by whether you pass a chain. Both need the
invoice.manage permission and a draft invoice with a customerEmail.
Email. Omit blockchain and tokenSymbol. The customer receives the hosted
invoice page and chooses how to pay: your bank details, or any chain your wallets
support if you have the payments add-on.
If the email cannot be delivered, the invoice is not sent: success is false,
message says why, and the invoice stays a draft so you can call sendInvoice again.
mutation { sendInvoice(invoiceId: "8f0c…") { success url # the hosted invoice page invoice { id status } }}On-chain payment. Pass both blockchain and tokenSymbol to pin the invoice to
that token on that network, paid to your wallet on it. Requires the payments add-on
and a wallet on the chain. The token must match the invoice’s currency (USDC or USDT
for USD, EURC for EUR), and an invoice set to be paid outside Plirin is refused. If
the payment request is created but the email fails, success is true and message
says the email was not delivered; call sendInvoiceReminder to email the customer.
sendInvoiceReminder(invoiceId: ID!) returns the Invoice, including customerInNetworkInbox: true when the customer also receives this reminder in their Plirin bills inbox (a live invoice, an accepted sender and a verified member; never revealing a block). The dashboard uses it to add “They’ll also see it in their Plirin inbox” to its confirmation.
mutation { sendInvoiceReminder(invoiceId: "8f0c…") { id status customerInNetworkInbox }}No reminder is sent for an invoice that owes nothing, for example after a 100%
promotion code. sendInvoiceReminder answers “This invoice has nothing left to pay.”
with details.reason nothing_owed, and REST POST /api/v1/invoices/{id}/remind
answers 409 with code nothing_owed. Automatic reminders and the import chase skip
such an invoice without counting it. The invoice’s status does not change.
mutation { sendInvoice(invoiceId: "8f0c…", blockchain: "solana-mainnet", tokenSymbol: "USDC") { success url invoice { id status } }}Send invoices in bulk
Section titled “Send invoices in bulk”batchSendInvoices sends up to 50 drafts in one call, with the same two forms as
sendInvoice. Passing only one of blockchain and tokenSymbol is rejected rather
than treated as an email send.
# Emailmutation { batchSendInvoices(invoiceIds: ["8f0c…", "a91e…"]) { successCount failureCount errors { invoiceId message } }}
# On-chain payment (payments add-on)mutation { batchSendInvoices( invoiceIds: ["8f0c…", "a91e…"] blockchain: "solana-mainnet" tokenSymbol: "USDC" ) { successCount failureCount errors { invoiceId message } }}Each invoice succeeds or fails on its own, and is checked exactly as sendInvoice
checks it. These come back in errors while the rest are still sent:
- an invoice that is not a draft, or has no customer email
- for on-chain, a token that does not match that invoice’s currency
- for on-chain, an invoice set to be paid outside Plirin
- an email that could not be delivered
successCount counts only invoices whose email was delivered. On an email failure,
an email-form invoice stays a draft; an on-chain one keeps its payment request and
is marked sent. Every bulk send writes one invoice.batch_sent entry to your audit
trail, recording which form was used (mode: "email" or "onchain", plus the chain
and token), and an invoice.sent entry for each invoice that was sent.
A selection that holds a copy generated by a recurring schedule is refused as a
whole, before anything is sent, unless the merchant’s plan carries
recurring_billing: see Recurring invoices below.
Recurring invoices
Section titled “Recurring invoices”A recurring schedule lives on an invoice. setInvoiceRecurring writes it,
cancelInvoiceRecurring ends it, and the invoice_recurrence worker prepares
each copy as a draft armed to send on its own date. The worker never sends:
invoice_scheduled_send does that, and refuses a copy whose merchant is not
entitled (see Entitlement below).
mutation { setInvoiceRecurring( invoiceId: "8f0c…" period: "monthly" startDate: "2026-12-01" endDate: "2027-12-01" leadDays: 3 carry: { preset: items_only } ) { invoice { id recurring_enabled recurring_period recurring_next_date recurring_end_date leadDays recurringCarry { tax discount tip notes } } carry { preset groups { tax discount tip notes } forcedOn } }}setInvoiceRecurring(invoiceId: ID!, period: String!, endDate: String, startDate: String, leadDays: Int, carry: CarryInput): RecurringScheduleResult! needs invoice.manage. It returns RecurringScheduleResult, not Invoice, because the server resolves the carry choice and has to report what it changed.
It is a full write of the schedule, not a patch. leadDays and carry left out take their defaults rather than keeping what the row held, so a caller cannot half-change a schedule and leave the other half of an earlier choice in force. Read a saved schedule back before you re-save it.
periodisweekly,monthly,quarterlyoryearly. Anything else refuses the whole call.startDateis the first send date. Omitted, the first send is one period from now. A monthly, quarterly or yearly schedule bills on that day of the month from then on, clamped to a shorter month and back again: from the 31st you get 28 February and then 31 March.endDatebounds the send date, not the day a copy is prepared: a schedule ending 30 June does not prepare a copy for a 2 July send. LikestartDateit is read as a day on the merchant’s calendar, so a send on 30 June is made for a merchant in any timezone, andstartDateandendDateon the same day means a schedule that bills once.leadDaysis how many days before each send date the copy is prepared, which is the merchant’s review window. Bounded by the period: at most 6 onweekly, 27 onmonthly, 89 onquarterly, 364 onyearly, because a longer lead would prepare the next copy before the previous one had gone out. Omitted it is 7, or 6 on a weekly schedule.0is a real answer and means no review window: the copy is prepared on its send date.carryis what each copy carries. Omit it for an exact copy.
startDate and endDate are both read as days on the merchant’s calendar, the timezone in their settings. A bare "2026-12-01" and a picker’s "2026-12-01T00:00:00Z" both mean that calendar day there, and so does any other instant falling on that day locally. The columns themselves hold instants: recurring_next_date is the first moment of that day where the merchant is, while recurring_end_date keeps the instant the argument parsed and is compared as the day it names.
carry: { preset: items_only } # a preset, as a NAMEcarry: { groups: { tip: false } } # named groups; the rest stay carriedCarryInput takes either preset or groups, never both (CARRY_AMBIGUOUS) and never neither (CARRY_EMPTY; omit carry entirely for an exact copy). The two presets are exact and items_only. The four groups are tax, discount, tip and notes, and a group left out of groups stays carried.
Send the preset name; do not expand it into booleans. A preset is shorthand the server resolves, while a group the caller names is obeyed or refused. So { preset: items_only } succeeds for a merchant who invoices under VAT, because the tax resolves back on, while the same intent written as { groups: { tax: false, discount: false, tip: false, notes: false } } is refused for that merchant with CARRY_TAX_REQUIRED_IN_VAT_MODE. preset is an enum so this is structural on GraphQL; REST and both SDKs take the same two names as a plain string, checked on the server.
carry.forcedOn is money. Show it. It lists the groups that were asked off and turned back on, and is empty when the server changed nothing. A VAT merchant may not drop tax, and discount comes back with it whenever the invoice’s tax was computed on a discounted subtotal. That group includes the order-level discount, which is a coupon’s effect, so a schedule saved from items_only repeats that discount on every copy and under-bills the merchant. carry.preset echoes the name the caller sent, or null when they named groups, so a client can send back what it was told.
duplicateInvoice(invoiceId: ID!, carry: CarryInput): Invoice! takes the same input for a one-off copy. It still returns Invoice, and deliberately does not report forced groups. Not because the caller is looking at the copy’s amounts (the dashboard’s own duplicate flow is a toast and a list refresh, and its mutation selects only id status invoice_number), but because the form that sends it states both forcings BEFORE the save and submits the mapping those statements describe. A schedule’s copies arrive a month later under a stored answer, which is why that path reports forcedOn afterwards too. A client with no such form should use the REST route, which answers carry_forced_on.
The two paths do not surface this equally, so do not assume one notice covers both. Both of the dashboard’s forms state the consequence before the save, in the checklist’s lock reason: the discount can go only if the tax goes with it, and keeping both carries this invoice’s discounts forward, the order-level coupon included. Only a schedule save also reports it afterwards, in forcedOn, with a line telling the merchant to check the figures. And forcedOn can only ever name a group when the caller sent a preset: a groups mapping has every group it names obeyed or refused and every group it omits left carried, so there is nothing left for the server to force on, and the field comes back empty. A client that sends groups and waits for forcedOn to warn it will wait forever. This asymmetry is GraphQL’s alone. Over REST, POST /api/v1/invoices/{id}/duplicate does answer carry_forced_on, for exactly this reason: a server-to-server copy has no form to state the rules on, so the one path that cannot say it before the save is the one that says it after. Both SDKs carry the field: TypeScript’s invoices.duplicate returns DuplicatedInvoice, an alias of InvoiceWithCarryReport that types carry_forced_on, and the Python response dict holds the same key. So a REST or SDK duplicate flow should read carry_forced_on and say what it means. A GraphQL duplicateInvoice client has no field to read: it must either state the rules before it sends, as the dashboard’s checklist does, or use the REST route. It cannot work it out from the amounts, because duplicateInvoice returns no amount unless the client asks for one.
A schedule’s answer is stored and reused; a duplicate’s applies to that copy only.
Reading schedules back
Section titled “Reading schedules back”recurringSchedules: [Invoice!]! returns every invoice of the caller’s merchant that still carries a schedule, soonest next send first, nulls last, scoped to the caller’s environment. A stopped or voided schedule is not in the list. A paused schedule is, deliberately, with a null recurring_next_date: it generates nothing, and hiding it would take away the merchant’s only explanation.
Invoice also carries leadDays, recurringCarry (null for an exact copy) and pendingDueDateReanchor, which is the due-date change a held copy would take if it were sent now, and null when sending now changes nothing or the invoice is not a schedule’s copy. Its plannedSendDay, actualSendDay, dueDayBefore and dueDayAfter are days on the merchant’s calendar: render those rather than slicing a date out of the stored instant, which encodes the end of its day and falls on the next UTC date west of UTC.
Entitlement
Section titled “Entitlement”Setting a schedule up, preparing its copies and the review window need no plan feature. Sending without the merchant present needs recurring_billing, which four plans carry: invoicing_pro, invoicing_scale, growth and scale, displayed to merchants as Growth and Scale.
Gate on the feature, not on the plan. extensions.details.required_plan on a refusal is invoicing_pro, the plan to upsell to, and a client that reads it as the set of plans that have the feature will tell a merchant on Scale to buy one they already hold.
| Action | Needs recurring_billing |
|---|---|
setInvoiceRecurring, cancelInvoiceRecurring | No |
| Copies prepared as drafts by the worker | No |
| Sending a copy by hand, now | No |
| A copy sending itself on its send date | Yes |
Arming a copy for a later send (scheduledSendAt on createInvoice or updateInvoice) | Yes |
batchSendInvoices with a copy in the selection | Yes |
An unentitled copy is held, not failed: it stays a draft, still armed, with no attempt counted against it, and invoice.recurring_held records why. It is also held when its send date falls before the subscription period the merchant is currently paying for, so an upgrade does not empty a backlog into one customer’s inbox. A refusal arrives as FEATURE_NOT_AVAILABLE with extensions.details.feature_key and extensions.details.required_plan; the bulk refusal also names the copies, in recurring_invoice_ids and recurring_invoice_numbers.
Errors
Section titled “Errors”A schedule or carry refusal arrives with the code in extensions.details.code.
| Code | When |
|---|---|
RECURRING_LEAD_OUT_OF_RANGE | leadDays is negative or as long as the period. The message names the maximum for that period |
RECURRING_START_AFTER_END | The first send date is after the end date, so the schedule would never generate anything |
RECURRING_START_IN_PAST | startDate names a day before today on the merchant’s own calendar. A backdated schedule owes a copy for every period since, and generates one per worker tick until it catches up. Today itself is accepted |
RECURRING_SOURCE_VOID | The invoice has been voided, so it is not a template. Duplicate it and put the schedule on the copy. Void is the only status refused: paid, overdue and pending are all legitimate sources |
CARRY_AMBIGUOUS | carry named both a preset and groups |
CARRY_EMPTY | carry named neither. Omit carry for an exact copy |
CARRY_UNKNOWN_PRESET | A preset name the server does not know. Reachable over REST and the SDKs, where the name is a plain string |
CARRY_UNKNOWN_GROUP | A group name the server does not know. An unrecognised group is refused, never ignored: silently dropping {"taxes": false} would keep a tax the merchant asked to remove |
CARRY_INVALID_FLAG | A group’s value is not a boolean. Refused, never coerced: "false" is a truthy string |
CARRY_TAX_REQUIRED_IN_VAT_MODE | groups named tax: false for a merchant who invoices under VAT. Send preset: items_only instead, which resolves the tax back on |
CARRY_DISCOUNT_WITHOUT_TAX | groups named discount: false while keeping the tax, on an invoice whose tax was computed on its discounted subtotal. Drop the tax with the discount, or keep the discount |
Two more refusals are not carry codes. An invoice a subscription owns is refused as a plain validation error, because the subscription already advances that invoice’s billing period and layering recurrence on top would mint a second invoice for the same period. An unknown invoice id, or a period the server does not recognise, answers “Invoice not found or invalid period”.
Create payment link
Section titled “Create payment link”mutation { createPaymentLink( amount: "50.00" description: "Consulting fee" blockchain: "solana" token: "USDC" ) { paymentLink qrCode amount }}About the payment URI
Section titled “About the payment URI”createPayment and regeneratePayment return a url you can render as a QR
code or open directly. It is a Solana Pay URI and
any compatible wallet (Phantom, Solflare, Backpack and others) handles it.
solana:https://api.plirin.com/api/v1/solana/pay-request/<reference>Do not parse this URI. It is a Solana Pay transaction request: the
customer’s wallet fetches the transaction from Plirin, and the recipient and
amount are not present in the string. Earlier versions returned a transfer
request of the form solana:<address>?amount=...&spl-token=..., and code that
extracted the address or amount from it will not work against the current
format.
Nothing else changes. The field name, its type, and how you display it are the same, and the customer still scans once and approves once.
The reason for the change is that a transfer request names a single recipient, so it cannot carry Plirin’s fee alongside the merchant’s share. Plirin now builds the transaction, which means the fee is collected on every payment path rather than only when a customer connects a browser wallet.
On EVM chains, url is a hosted checkout link
Section titled “On EVM chains, url is a hosted checkout link”On Ethereum, Base and Polygon, createPayment and regeneratePayment return a
link to Plirin’s hosted checkout rather than a chain URI:
https://app.plirin.com/pay/<slug>This is breaking if you parse the value. Earlier versions returned an
EIP-681 URI of the form
ethereum:<token>@<chainId>/transfer?address=<merchant>&uint256=<amount>, and
code that extracted the recipient, amount, token or chain id from it will not
work against the current format. Rendering it as a QR code or opening it
directly is unaffected — that is what it is for.
The reason is the same one that changed the Solana URI, arriving at a different answer because the chains differ. An EIP-681 URI names one recipient, so it cannot carry Plirin’s share alongside the merchant’s. Solana can express the split inside a single wallet-fetched transaction, so its URI stays a chain URI. EVM needs the customer’s wallet to approve a batch of calls, and the EIP-5792 request that carries one has no URI form a QR can hold. The hosted page is what connects the wallet and hands it that batch.
Create refund request
Section titled “Create refund request”mutation { createRefundRequest( transactionReference: "TXN-20260510-0001" refundAmount: "25.00" reason: "Customer requested partial refund" ) { id refundReference status refundAmount blockchain refundTransactionHash explorerUrl }}createRefundRequest (and POST /api/v1/refunds) refunds a payment that did
not settle an invoice: a payment link, a point-of-sale payment or a sale. A
payment that settled an invoice is refused with details.reason
INVOICE_PAYMENT_REFUND_FROM_INVOICE. Refund it from its invoice instead
(issueCreditNote, or refundOverpayment for money beyond the invoice total),
so every refund of invoice value issues its credit note.
Approve refund
Section titled “Approve refund”mutation { approveRefund(refundReference: "REF-20260510-0001", approvalNotes: "Verified with customer") { id refundReference status }}Reject refund
Section titled “Reject refund”mutation { rejectRefund(refundReference: "REF-20260510-0001", rejectionReason: "Outside return window") { id refundReference status }}Complete refund
Section titled “Complete refund”mutation { completeRefund(refundReference: "REF-20260510-0001", transactionHash: "3vP7kN2mW9qX5yA1bC8dE4fG6hI0jR") { id refundReference status refundTransactionHash explorerUrl }}Send refund receipt
Section titled “Send refund receipt”mutation { sendRefundReceipt(refundReference: "REF-20260510-0001") { id refundReference status }}Update merchant profile
Section titled “Update merchant profile”The argument is profile, and its fields are snake_case, as in MerchantProfileInput.
mutation { updateMerchantProfile(profile: { name: "Acme Corp" email: "hello@acme.com" tax_percentage: 8.25 }) { id name tax_percentage }}Changing the login email
Section titled “Changing the login email”email is the address the account signs in with, so changing it needs the account’s
current password as the mutation’s own currentPassword argument (not a profile field):
mutation { updateMerchantProfile( profile: { email: "owner@new-domain.com" } currentPassword: "the current password" ) { id email }}Sending the current address again (in any letter case) is not a change and needs no
password, so a form that always sends email keeps working. A real change:
- without
currentPasswordis refused withdetails.code: "CURRENT_PASSWORD_REQUIRED"; - with a wrong one is refused with
details.code: "CURRENT_PASSWORD_INVALID". It counts as a failed sign-in for the account’s current address, so repeated guesses lock it exactly as failed sign-ins do, and while that lock is in force even the right password is refused with the same code; - is checked against the password before the new address is checked, so without the password the response never says whether an address is taken;
- then starts verification again for the new address, ends every existing session, emails the
old address and writes a
merchant_email_changedaudit entry.
Nothing in the update is saved when the change is refused. API keys and staff cannot change the login email.
Default currency
Section titled “Default currency”default_currency sets the currency new invoices and products start in. Any currency
from supportedCurrencies is accepted, except that while the payments add-on is on it
must be one with settleable: true (USD or EUR). With payments on, the default is also the
currency of POS payments and payment links.
mutation { updateMerchantProfile(profile: { default_currency: "GBP" }) { id defaultCurrency }}With payments on, that request is refused with a ValidationError whose details are
{ "reason": "default_currency_not_settleable", "currency": "GBP", "settleable_currencies": ["EUR", "USD"] }. Invoicing in GBP is still possible: pass
currency when creating the invoice.
Turning payments on with setPaymentsAddon(enabled: true) is refused with the same
reason while the default currency cannot settle. Change the default first.
A successful change writes a merchant.currency_changed audit entry with old_value,
new_value, new_value_settleable and payments_enabled.
Business country and tax naming
Section titled “Business country and tax naming”country_code (ISO 3166-1 alpha-2) decides what the merchant’s invoices call tax. It is
validated against the countries list and upper-cased. Once the merchant has issued an
invoice or credit note, only the account owner can change it; anyone else gets a
ValidationError with details.reason: "owner_only_after_issue".
tax_name_override and tax_id_label_override replace the country’s defaults (max 40
characters). An empty string restores the default.
mutation { updateMerchantProfile(profile: { country_code: "AU" vat_number: "53 004 085 616" }) { countryCode taxId taxLabels { name idLabel } }}vat_number is the merchant’s tax ID for every country, not only VAT ones (it is read back as
taxId, or vat_number). Where the country has a known format it is validated and stored
compacted: the example above returns taxId: "53004085616", and EU numbers keep their
country prefix. It is validated only when it or the country changes, so an unrelated save
never fails on an existing number. An empty string clears it.
Every change writes a merchant.tax_settings_changed audit entry with the before and after
value of each field.
Itemized tax (formerly VAT mode)
Section titled “Itemized tax (formerly VAT mode)”A merchant uses itemized tax once they
have a tax ID and at least one active rate. default_tax_behavior (INCLUSIVE or
EXCLUSIVE) sets whether entered prices include the tax:
mutation { updateMerchantProfile(profile: { vat_number: "DE136695976" default_tax_behavior: INCLUSIVE }) { id vat_number default_tax_behavior }}Like the rest of updateMerchantProfile, this is dashboard-only (JWT session), not callable
with an API key.
VAT rate management
Section titled “VAT rate management”Dashboard-only (see Authentication) — merchants configure their
own rates. taxRegimes { suggestedRates } offers the published rates for a country as a
starting point; nothing is created until the merchant saves one.
mutation { createVatRate( name: "Standard rate" rate: 0.19 kind: "standard" isDefault: true ) { id name rate kind isDefault isActive }}kind is one of "standard", "reduced", "zero", "exempt". An exempt rate requires a
non-empty exemptionNote (e.g. the reverse-charge article being invoked).
mutation { updateVatRate(id: "1", name: "Standard rate", rate: 0.20, kind: "standard") { id rate }}mutation { deactivateVatRate(id: "1")}mutation { setDefaultVatRate(id: "2") { id isDefault }}Deactivating or editing a rate never changes invoices that already used it — the rate is snapshotted onto each invoice line at creation time.
createPrice and updatePrice (see Pricing Models)
also accept an optional vatRateId, and Price exposes vatRateId — unlike the VAT
config/report operations above, these two mutations remain callable with an API key, so a
price’s default VAT rate can be set entirely server-side.
Card payments
Section titled “Card payments”createInvoice accepts cardPaymentsAllowed (true to offer card payment on the invoice, false never, omitted to follow the merchant’s default). Everything else about card payments is a dashboard control and is denied to API keys: connecting and disconnecting Stripe (startStripeConnect, completeStripeConnect, disconnectStripe, which are the account owner’s), changing one invoice afterwards (setInvoiceCardPayments) and the merchant default (setCardPaymentsDefault). A client pays on the hosted invoice page, so there is no mutation that takes a card payment. Refunds work as for any payment: see Refunding an invoice. The merchant’s side is in Card Payments.
Offline invoice payments
Section titled “Offline invoice payments”Record a payment made outside Plirin — a bank transfer, cash, card, or check — against an invoice, so mixed-settlement invoices (part on-chain, part offline) can still reach paid.
mutation { recordInvoicePayment( invoiceId: "550e8400-e29b-41d4-a716-446655440000" amount: "100.00" method: bank_transfer paymentDate: "2026-05-10" reference: "WIRE-4821" note: "Partial payment, remainder due end of month" ) { id status amountPaid amountRemaining }}methodis one ofbank_transfer,cash,card,check,other(lowercase —OfflinePaymentMethodenum values).amountmust be greater than 0 and no more than the invoice’s currentamount_remaining; there’s no credit-balance concept in v1, so overpayment is rejected outright.paymentDateis optional (defaults to now), can be backdated, but can’t be set in the future.- Allowed only on
sent,pending, oroverdueinvoices — notdraft,void, or already-paid. - Honors the
Idempotency-Keyheader (also used bycreatePayment) so a double-submitted form can’t double-record. - If this brings
amount_remainingto zero, the invoice flips topaidand fires the sameinvoice.paidwebhook as an on-chain settlement (see Payload Format). If an on-chain payment request was already pending for the old (higher) amount, it’s invalidated so the stale QR can’t be paid afterward.
mutation { removeInvoicePayment(paymentId: "9f1c...", reason: "Recorded against the wrong invoice") { id status amountRemaining }}- Soft-deletes the payment (the row is retained, flagged, for history) and recomputes the balance.
reasonis required: a blank one fails withREASON_REQUIRED.- If removing it un-settles a
paidinvoice, the invoice reopens: tosent, or directly tooverdueif its due day has already ended on the merchant’s calendar. - Fails with
PAYMENT_LOCKED_BY_CREDIT_NOTEif a credit note was issued against the invoice after this payment was recorded. - Fails with
INVOICE_VOID_OR_DRAFTon a void or draft invoice. - When a staff member removes a payment, the account owner gets an email saying who removed it, from which invoice, and why.
mutation { restoreInvoicePayment(paymentId: "9f1c...", reason: "The transfer did arrive") { id status amountRemaining }}- Restores a soft-deleted payment. Re-validates against the current balance first: if another payment landed in the meantime and this one no longer fits, it fails with
RESTORE_WOULD_OVERPAYrather than overpaying the invoice. reasonis required, as for a removal (REASON_REQUIREDwhen blank): a restore can mark the invoice paid. The dashboard asks for it in a confirmation dialog.- Fails with
INVOICE_VOID_OR_DRAFTon a void or draft invoice, so a restore can never turn a void invoice into a paid one. - A restore by a staff member emails the owner, as a removal does.
query { invoicePayments(invoiceId: "550e8400-e29b-41d4-a716-446655440000") { id amount method paymentDate reference note createdAt deletedAt deletedReason restoredAt events { action reason actorType actorName createdAt } }}- Returns every payment on the invoice, including soft-deleted ones (
deletedAt/deletedReasonset), so the UI can render full history. deletedAtanddeletedReasondescribe only the latest removal.eventsis the full history: one entry per removal (action: "removed") or restore ("restored"), oldest first, each with its reason, who made it (actorType:"owner","staff", or null when not known, such as a staff member since deleted;actorName: the staff member’s name, null otherwise) and when.
Errors
Section titled “Errors”| Code | When |
|---|---|
OVERPAYMENT_REJECTED | amount exceeds the invoice’s current amount_remaining |
INVALID_INVOICE_STATUS | Recording on draft/void/paid, or acting on a payment/invoice that doesn’t exist |
PAYMENT_DATE_IN_FUTURE | paymentDate is in the future |
RESTORE_WOULD_OVERPAY | Restoring would push the balance below zero |
PAYMENT_LOCKED_BY_CREDIT_NOTE | Removing or restoring a payment that a later credit note was issued against |
PAYMENT_HAS_RECORDED_REFUND | Removing or restoring a payment that has a recorded refund not voided: void the refund first |
REASON_REQUIRED | Removing or restoring without a reason |
REASON_TOO_LONG | A remove or restore reason longer than 500 characters |
INVOICE_VOID_OR_DRAFT | Removing or restoring a payment on a void or draft invoice |
Errors carry the code in extensions.details.reason.
Refunding an offline payment. Plirin moves no money for an offline payment, so a refund of one is a record of what the merchant paid back themselves. recordRefund(input: RecordRefundInput!) records it against the offline payment (paymentId) with its amount, method, date and reason; it completes at once and settles a credit note. voidRecordedRefund(refundId, reason) takes back one entered by mistake, with its credit note. Both need invoice.manage and are denied to API keys. To correct a payment recorded in error, remove it instead. See Invoice Lifecycle and Refunds.
Late fees
Section titled “Late fees”mutation { updateLateFeePolicy(input: { enabled: true feeType: "flat" amount: "15.00" graceDays: 14 repeat: "every_30_days" maxFees: 3 feeDue: "on_receipt" }) { enabled feeType amount currency }}updateLateFeePolicy needs the late_fees plan feature (Growth and Scale) and the merchant settings permission, and it affects invoices sent afterwards. See Late fees for the value ranges.
mutation { removeLateFee(id: "42", reason: "Paid by phone") { id status removedAt } setCustomerLateFeeExemption(customerId: "17", exempt: true, removeUnpaid: true) { id lateFeesExempt }}removeLateFee voids the fee invoice and marks its ledger line removed. setCustomerLateFeeExemption turns a customer’s fees off or back on, and with removeUnpaid it also removes their unpaid fees. Neither is plan-gated, so a merchant who downgrades can still waive what was charged. customerUnpaidLateFees(customerId) lists what removeUnpaid would remove.
Waiving late fees on one invoice
Section titled “Waiving late fees on one invoice”A waived invoice is sent with no late-fee terms and is never charged a fee. Invoice.lateFeesWaived: Boolean! reads the current value. You can set it when you create the invoice (createInvoice below, or the REST create route), and you can change it on an existing invoice with setInvoiceLateFeeWaiver, which works only while the invoice is a draft.
mutation { setInvoiceLateFeeWaiver(invoiceId: "8f0c…", waived: true) { id status lateFeesWaived }}setInvoiceLateFeeWaiver(invoiceId: ID!, waived: Boolean!): Invoice! needs invoice.manage and is denied to API keys. It refuses any invoice that is not a draft, whichever value you pass. A request that leaves the value as it was succeeds and writes no audit row; a real change writes invoice.late_fees_waived.
To create a draft that is already waived in one call, pass lateFeesWaived: true in the createInvoice input. It is optional and defaults to false, and true is refused unless collectionMethod is SEND_INVOICE.
mutation { createInvoice(input: { customerEmail: "billing@acme.com" collectionMethod: SEND_INVOICE dueDate: "2026-07-01" lateFeesWaived: true lineItems: [{ description: "Web development", quantity: 1, unitAmount: "150.00" }] }) { id status lateFeesWaived }}How it behaves:
- Set on the draft, read at send. Late-fee terms are frozen when the invoice leaves draft, from the policy in force then. The waiver decides what gets stamped. It only ever removes terms: it also drops terms carried over from an accepted estimate.
- Not retroactive. It never clears the terms an already-sent invoice carries and never removes a fee already charged. After send, use
removeLateFeeandsetCustomerLateFeeExemption. - Not plan-gated. Unlike
updateLateFeePolicy, it works whatever the merchant’s plan, because it forgives rather than creates a fee obligation. This matchesremoveLateFeeandsetCustomerLateFeeExemption. - On REST, creation only. The REST create route (
POST /api/v1/invoices) acceptslate_fees_waivedand, likecreateInvoice, refuseslate_fees_waived: trueunlesscollection_methodissend_invoice: it answers 400 withparam: "late_fees_waived"and creates nothing. There is no REST endpoint to set or clear the waiver on an existing invoice; that issetInvoiceLateFeeWaiver, GraphQL and the dashboard only. (Duplicating a waived invoice over REST produces a waived draft, because the copy keeps the waiver.) The TypeScript SDK types the field oninvoices.create; the Python SDK passes the params dict through unchanged. - API keys. An API key, over GraphQL or REST, can create an invoice already waived, but neither can waive an existing draft:
setInvoiceLateFeeWaiveris denied to API keys.
Errors
Section titled “Errors”The code arrives in the error’s details.reason.
| Code | When |
|---|---|
INVOICE_NOT_DRAFT | setInvoiceLateFeeWaiver on an invoice that has been sent or is otherwise not a draft |
LATE_FEES_WAIVER_NOT_APPLICABLE | createInvoice with lateFeesWaived: true and a collectionMethod other than SEND_INVOICE. An immediate sale is not created as a draft and carries no late-fee terms, so there is nothing to waive and nothing to undo later |
LATE_FEE_NOT_FOUND | The fee id is malformed or belongs to another merchant |
LATE_FEE_PAID | The fee invoice has a payment, so it cannot be removed. Issue a credit note or refund |
LATE_FEE_PAYMENT_IN_FLIGHT | A customer has started paying the fee and the payment has not settled or lapsed. Try again later |
LATE_FEE_NOT_REMOVABLE | The fee invoice is in a status that cannot be voided |
LATE_FEE_ALREADY_REMOVED | The fee was already removed |
LATE_FEE_REASON_TOO_LONG | reason is longer than the maximum length |
CUSTOMER_NOT_FOUND | The customer id is malformed or belongs to another merchant |
Bills inbox
Section titled “Bills inbox”mutation { decideSender(senderId: "42", decision: ACCEPT) saveBillToInbox(slug: "abc123") { saved senderUnblocked } markInboxSeen(asOf: "2026-10-02T09:30:00Z")}decideSender(senderId: ID!, decision: SenderDecision!): Boolean! accepts or blocks one sender (ACCEPT or BLOCK). It is the only thing besides saveBillToInbox that writes a decision, it is idempotent (two clicks or two tabs end in one record), and the latest decision wins. A first decision about a sender is refused unless that sender has a bill visible to your verified email, and the refusal is the same whether the sender does not exist or never billed you (“no bills from this sender”), so it cannot be used to probe merchant ids. Changing a decision you already made (blocking an accepted sender, or unblocking a blocked one) needs no visible bill, so it works even when none of their bills is left to show; use networkSenders to list them. It is also refused for your own account. Limited to 30 calls a minute per account; beyond that it returns “Too many requests. Try again in a minute.” with details.reason set to RATE_LIMITED. Each decision writes a network.sender_accepted or network.sender_blocked audit entry.
saveBillToInbox(slug: String!): SaveBillResult! accepts the sender of the bill with that public slug (the abc123 in /i/abc123), from the member’s own session. saved is true on success, and senderUnblocked is true when the sender had been blocked and this save lifted the block. Refusals, with the message as shown to the member:
| Message | When |
|---|---|
This is your own bill. | The bill was sent by this account (checked first) |
Bill not found. | Unknown slug, a draft, or a test-mode bill |
Verify your email to save bills to your inbox. | The account’s email is not verified |
This bill was sent to a different email. | The bill’s customer email is not the account’s email (an address stored with surrounding spaces matches no account) |
canSaveBill answers in advance whether this would succeed, without writing anything.
It has its own 30-a-minute limit, separate from decideSender’s. Both mutations (and markInboxSeen) return The network inbox is not available. while the platform switch is off.
markInboxSeen(asOf: DateTime): Boolean! records that the first page of the unfiltered inbox was shown. Pass the asOf of the page that was shown, so a bill that arrived while the page was open still counts as new. A value in the future is treated as now, and the stored mark never moves backwards. The mark is per person: it belongs to the signed-in owner or staff member, so marking it does not clear inboxNews for anyone else on the account. It clears reminders as well as new bills: both count toward the Bills badge. The dashboard now marks Bills through markPageSeen(page: BILLS), which writes the same mark; markInboxSeen stays for older clients.
Sign up from a bill
Section titled “Sign up from a bill”createMerchant accepts one extra optional argument:
mutation { createMerchant(name: "Acme Studio", email: "ignored@example.com", password: "...", billToken: "eyJ...") { id email }}createMerchant(name: String!, email: String!, password: String!, country: String, billToken: String): Merchant!
A billToken comes from the sign-up link that POST /i/{slug}/inbox-invite (below) emails to one live bill’s customer address. The link is <app>/signup#bill=<token>: the token is in the URL fragment, so it is never sent to a server. With it:
- The typed
emailis ignored. The account is created with the email address on the bill, so a token cannot be used to claim another address. - The account is marked email-verified at once and no verification email is sent. The bill’s sender is accepted. A
merchant.signed_up_from_billaudit entry records it. - The token is single use. It is consumed in the same database transaction that creates the merchant, so a failed sign-up (a weak password, an address that already has an account) does not spend it.
- Every token problem returns one message, “This sign-up link is invalid or has expired.”: a malformed or tampered token, an expired one (they last 7 days), one already used, a bill that is a draft, a test-mode bill, has no hosted page link, or has no properly formed customer email (an address stored with surrounding spaces counts as not properly formed), and a platform switch being off. Both Network Inbox switches must be on, and when either is off the token is refused without being spent, so it works again once the switch is back on.
- If the address already has an account, the error is the usual “Email already registered”, without echoing the address.
POST /i/{slug}/inbox-invite
Section titled “POST /i/{slug}/inbox-invite”A public REST route used by the hosted bill page’s “Save to a free Plirin inbox” button. It takes no body. It emails a single-use sign-up link to the bill’s customer address and returns only that it did, with a masked hint of the address:
{ "sent": true, "emailHint": "n***@buyer.example" }The response never contains the token: holding the emailed link is what proves the address, which is why the account is verified at once. Every refusal (unknown slug, draft, test-mode bill, no usable customer email, a switch off) is the same 404 with detail Not available, so the route does not reveal which, and no email is sent. It is limited per client address (10 an hour across all bills, plus 10 a minute per bill page) and per bill, whoever asks (10 attempts an hour, failed ones included, and 3 sent emails an hour; a send that fails does not count against the 3); over a limit it answers 429. If the email cannot be sent, or the limits cannot be checked, it answers 503 and sends nothing. The full address is never returned or logged. The hosted invoice response (GET /i/{slug}) gains two flags for the page: inboxInviteAvailable (a live bill with a hosted page and a usable customer email, with both switches on) and inboxSaveAvailable (the same bill rule, with the inbox switch on). A usable email is one an inbox can match: an address stored with surrounding spaces is not.
Admin: platform switches
Section titled “Admin: platform switches”For platform operators, PlatformConfig (admin API) has two new fields, and updatePlatformConfig takes the same two optional arguments:
| Field | Meaning |
|---|---|
networkInboxEnabled: Boolean! | Show members the bills inbox. Off by default, and off when no config row exists |
networkInboxInvitesEnabled: Boolean! | Offer non-members a free inbox from bill pages and invoice emails. Needs networkInboxEnabled too. Off by default |
They appear in the admin console as Network Inbox and Network Inbox Invites. See docs/operations/platform-flags.md in the repository.
Customer statements
Section titled “Customer statements”Send a statement
Section titled “Send a statement”mutation { sendCustomerStatement(input: { customerId: "17" currency: "USD" periodStart: "2026-09-01" periodEnd: "2026-09-30" cc: ["accounts@acme.example"] note: "Please pay by the 15th." }) { id number status recipients closingBalance sentAt }}sendCustomerStatement emails the statement, with its PDF attached, to the customer’s email address. It needs the invoice.manage permission and is available to API keys.
Argument (SendCustomerStatementInput) | Type | Notes |
|---|---|---|
customerId | ID! | One of your customers |
currency | String! | A three-letter code. The statement covers this currency only |
periodStart, periodEnd | Date! | YYYY-MM-DD, on your calendar. periodEnd must not be before periodStart |
cc | [String!] | At most 5 extra addresses, for this send only |
note | String | Up to 500 characters, shown in the email and printed on the PDF |
It returns a CustomerStatementRecord. A send claims the next statement number (ST-0001 live, TEST-ST-0001 in test mode) before it delivers. If delivery fails, the record is kept as failed and the call answers EMAIL_SEND_FAILED, with the record’s statement_id and number in details; GraphQL does not retry it, so call it again for a new attempt, which takes the next number. (The REST route does not retry either, but the SDKs retry a 5xx response by default: see the SDKs.) Sending again for a period you already sent is allowed and creates a new record. Scheduled statements are sent by Plirin’s own worker; they cannot be started through the API.
A statement for a period with nothing to show answers STATEMENT_NO_ACTIVITY. A customer with no email answers CUSTOMER_HAS_NO_EMAIL.
Statement settings
Section titled “Statement settings”mutation { updateStatementSettings(input: { mode: "scheduled" frequency: "monthly" dayOfMonth: 1 recipients: "open_balance" defaultNote: "Thank you for your business." }) { mode frequency dayOfMonth weekday recipients defaultNote sendHour nextRunOn paused }}Argument (StatementSettingsInput) | Values |
|---|---|
mode | manual or scheduled. Required |
frequency | monthly or weekly |
dayOfMonth | A whole number from 1 to 28 |
weekday | A whole number from 0 (Monday) to 6 (Sunday) |
recipients | open_balance (customers who owe you at the end of the period) or any_activity (customers with any entry in it) |
defaultNote | Up to 500 characters. A blank value or null clears the note |
A field you leave out keeps its stored value. A bad value is a validation error with each field named under details.fields. A saved change writes the statement.settings_changed audit event, with the policy before and after; saving an unchanged policy writes nothing. The send hour is your reminder send hour and is not set here.
A monthly schedule sends the previous calendar month’s statement, and a weekly one the previous Monday to Sunday. Turning the schedule on after this month’s day does not send a late statement.
Opt a customer out
Section titled “Opt a customer out”mutation { updateCustomer(id: "17", input: { statementsOptOut: true }) { id statementsOptOut }}statementsOptOut on CustomerInput (so also on createCustomer) stops scheduled statements for that customer. A statement can still be sent by hand. Changing it needs customer.manage and writes customer.statements_opted_out or customer.statements_opted_in, only when the value actually changes.
REST: send
Section titled “REST: send”POST /api/v1/customers/{id}/statement/send{ "currency": "USD", "from": "2026-09-01", "to": "2026-09-30", "cc": ["accounts@acme.example"], "note": "..." }Responds 201 with the sent-statement record (snake_case: id, number, customer_id, currency, period_start, period_end, trigger = api, status, failure_reason, recipients, closing_balance, sent_at, created_at). The route honours an Idempotency-Key header, so a retry with the same key and body returns the first response instead of sending again. A key reused with a different customer or body is rejected. The key’s environment decides whether it is a test or live statement.
Errors
Section titled “Errors”On GraphQL the code arrives in extensions.code, repeated in extensions.details.code. On REST it is the code of the error envelope. The full table is under Error codes.
| Code | When |
|---|---|
STATEMENTS_DISABLED | The platform switch is off |
FEATURE_NOT_IN_PLAN | The plan has no statements |
EMAIL_NOT_VERIFIED | The account email is not verified yet, so nothing can be emailed to customers |
rate_limited | More than 60 statement sends in a minute for the account |
STATEMENT_NO_ACTIVITY | Nothing to show for that customer, currency and period |
STATEMENT_TOO_LARGE | More than 1,000 activity lines in the period |
CUSTOMER_HAS_NO_EMAIL | The customer has no email address |
EMAIL_SEND_FAILED | The email provider failed. The record is kept as failed |
Attachments
Section titled “Attachments”Uploading a file is three steps: ask for an upload, POST the bytes straight to storage, then confirm.
mutation { requestAttachmentUpload( invoiceId: "7b7b2f3c-0000-4000-8000-000000000001" fileName: "scope-of-work.pdf" contentType: "application/pdf" sizeBytes: 482113 ) { attachment { id fileName sharedWithCustomer } uploadUrl fields expiresAt }}requestAttachmentUpload(invoiceId: ID, estimateId: ID, fileName: String!, contentType: String!, sizeBytes: Int!): AttachmentUpload! takes exactly one of invoiceId or estimateId. It checks the type, size, the 10-file limit and your storage, and returns a presigned form. Send it as multipart/form-data to uploadUrl with every entry of fields first and the file as the last part, named file. The form is valid for 5 minutes (expiresAt).
mutation { confirmAttachmentUpload(id: "6f0c7f0e-9d2b-4b0e-8a47-3b3c1f6f3a10") { id fileName contentType sizeBytes sharedWithCustomer createdAt } setAttachmentShared(id: "6f0c7f0e-9d2b-4b0e-8a47-3b3c1f6f3a10", shared: false) { id sharedWithCustomer } deleteAttachment(id: "6f0c7f0e-9d2b-4b0e-8a47-3b3c1f6f3a10")}confirmAttachmentUpload(id: ID!): InvoiceAttachment!reads the stored file, checks that its contents match the declared type, and charges your storage. Until it succeeds the file ispending: it is not listed and uses no storage. Confirming a file that is already confirmed returns it unchanged. A file that fails the check is discarded and nothing is charged.setAttachmentShared(id: ID!, shared: Boolean!): InvoiceAttachment!shows the file on the customer’s hosted invoice page or hides it. New invoice files are shared by default; new estimate files start internal.deleteAttachment(id: ID!): Boolean!removes the file and releases its storage at once.- The stored file name always ends in an extension that matches the verified type, so
run.batuploaded astext/plaincomes back asrun.bat.txt. Macro-enabled Office files are refused. - Everything is scoped to your current dashboard mode: a document or file from the other mode answers
ATTACHMENT_NOT_FOUNDorATTACHMENT_OWNER_NOT_FOUND, exactly like another merchant’s. - Each add, sharing change and removal writes an audit entry.
Reading comes from Queries: attachmentDownloadUrl, attachmentStorageUsage, and attachments on a single invoice or estimate.
Errors
Section titled “Errors”The code arrives in the error’s details.code.
| Code | When |
|---|---|
ATTACHMENTS_DISABLED | Attachments are not switched on for the platform, or file storage is not configured |
ATTACHMENT_TYPE_NOT_ALLOWED | contentType is not one of PDF, PNG, JPEG, WebP, HEIC, CSV, TXT, DOCX or XLSX |
ATTACHMENT_TOO_LARGE | sizeBytes is zero or over 20 MB |
ATTACHMENT_LIMIT_REACHED | The invoice or estimate already has 10 files |
STORAGE_QUOTA_EXCEEDED | Your plan’s file storage is full. Remove files or upgrade |
ATTACHMENT_OWNER_INVALID | Both or neither of invoiceId and estimateId were given |
ATTACHMENT_OWNER_NOT_FOUND | The invoice or estimate does not exist, belongs to another merchant, or is in the other mode |
ATTACHMENT_NOT_FOUND | The file does not exist, was removed, belongs to another merchant, or is in the other mode |
ATTACHMENT_UPLOAD_MISSING | confirmAttachmentUpload ran before the bytes reached storage. The file stays pending, so upload and confirm again |
ATTACHMENT_SIZE_MISMATCH | The stored file is empty or larger than sizeBytes declared. The file is discarded |
ATTACHMENT_CONTENT_MISMATCH | The contents are not the declared type, or a Word or Excel file contains macros or embedded code. The file is discarded |
ATTACHMENT_CHANGED | The stored file changed while it was being checked. The file is discarded; upload it again |
STORAGE_UNAVAILABLE | File storage credentials are not configured on the server. (The REST API also returns it, as a 503, when storage itself is unreachable.) |
The dashboard also reports S3_UPLOAD_FAILED when its own upload to storage fails, but that never comes from the API.
Admin: platform switches
Section titled “Admin: platform switches”For platform operators, PlatformConfig (admin API) has two more fields, and updatePlatformConfig takes the same two optional arguments:
| Field | Meaning |
|---|---|
invoiceAttachmentsEnabled: Boolean! | Invoice and estimate attachments. Off by default. It also needs the attachments storage bucket to be configured: with no bucket it behaves as off, and every operation answers ATTACHMENTS_DISABLED |
documentDraftsEnabled: Boolean! | Draft an invoice from an uploaded document. Needs invoiceAttachmentsEnabled too. Off by default |
They appear in the admin console as Invoice Attachments and Document Drafts. See docs/operations/platform-flags.md in the repository.
Invoice import
Section titled “Invoice import”A CSV import is three steps, following the product and customer CSV imports: ask for an upload and POST the file to storage, preview it (nothing is written), then commit.
mutation { requestInvoicesCSVUpload(originalFilename: "open-invoices.csv") { csvId uploadUrl fields expiresAt }}Send the file to uploadUrl as multipart/form-data with every entry of fields first and the file as the last part. The file is limited to 5 MB and must be UTF-8. An upload stays valid for one hour.
mutation { validateInvoicesCSV(csvId: "6f0c7f0e-9d2b-4b0e-8a47-3b3c1f6f3a10") { readyCount refusedCount alreadyImportedCount allowanceRemaining capExceeded overdueCount firstRemindersDays outstanding { currency amount } warnings rows { rowNumbers invoiceNumber status errors } } commitInvoicesCSV( csvId: "6f0c7f0e-9d2b-4b0e-8a47-3b3c1f6f3a10" chase: { mode: "date", firstOn: "2026-10-12", everyDays: 7 } ) { importId created skipped refused refusedCsv refusedCsvFilename }}validateInvoicesCSV(csvId: ID!): InvoiceImportPreview!reads the file and reports every problem per invoice.statusof a row isready,refusedoralready_imported. It writes nothing.commitInvoicesCSV(csvId: ID!, chase: InvoiceImportChaseInput): InvoiceImportResult!reads and checks the file again, then imports the valid invoices, one all-or-nothing transaction each, under a per-merchant lock.refusedCsvis the refused rows with their reasons (cells starting with=,+,-or@are escaped), ornull.chase.modeisnow,dateornone;firstOnis aYYYY-MM-DDday on the merchant’s calendar (not in the past) fordate, sent at 09:00 merchant time;everyDaysis 1 to 90 (default 7). The chase applies only to invoices that are overdue at import. Withoutchase, no reminders are scheduled. On a plan without automatic reminders the choice is ignored, an explicitnoneincluded, and each invoice that is overdue at import is given the default chase (first reminder as soon as chasing is allowed, every 7 days, 6 at most), held paused by the plan, so it starts when the merchant upgrades. An invoice that is not yet due is never given a chase here.- Limits: 500 invoices, 2,000 lines and 100 lines per invoice per file, 5 MB, 30 uploads or previews and 10 commits per hour per merchant. The currency of each invoice must be enabled for the account. Reminders are capped at 100 a day per merchant, test and live counted separately.
mutation { stopImportChase(invoiceId: "7b7b2f3c-0000-4000-8000-000000000001") { id importReminderNextAt } setImportChaseNext(invoiceId: "7b7b2f3c-0000-4000-8000-000000000001", nextOn: "2026-10-20") { id importReminderNextAt } restartImportChase(invoiceId: "7b7b2f3c-0000-4000-8000-000000000001", everyDays: 14) { id importRemindersSent } voidInvoiceImport(importId: "2c1f0a54-0000-4000-8000-000000000002") { voided skipped { invoiceId invoiceNumber reason } }}stopImportChaseclears the next reminder and keeps the other fields.setImportChaseNextmoves the next reminder to 09:00 on a day (YYYY-MM-DD, merchant calendar, not in the past).restartImportChasesets the count back to zero, clears an ended chase and sends the first reminder on the next run. The last two need a plan with automatic reminders (stopImportChasedoes not, so a chase held by the plan can still be stopped) and an invoice that is past its due date on the merchant’s calendar; a not-yet-due import follows the normal reminder schedule and is refused withCHASE_NOT_OVERDUE. All four import mutations here, includingvoidInvoiceImport, answerIMPORT_DISABLEDwhile the platform switch is off. A reminder sent by hand counts toward the 6 and moves the next automatic one back, whatever route sent it: the dashboard, RESTPOST /api/v1/invoices/{id}/remindor the admin resend. Each of the three chase mutations writes aninvoice.import_chase_changedaudit entry.voidInvoiceImportvoids every invoice of the import that has no payment, credit note or reminder since the import, and lists the others inskippedwith the reason. Invoices are voided, never deleted. It writesinvoice.import_voided.
From PDF
Section titled “From PDF”mutation { startDocumentImports(files: [{ fileName: "inv-0042.pdf", contentType: "application/pdf", sizeBytes: 182113 }]) { draft { id purpose } uploadUrl fields }}startDocumentImports(files: [DocumentImportFileInput!]!): [DocumentDraftUpload!]! reads up to 10 invoice PDFs (each up to 10 MB and 10 pages), each as a document draft with purpose import; the AI allowance for all of them is checked up front, and each file costs one upload from the same hourly budget as the CSV calls (one upload per file, so a batch of 10 uses 10 of the 30). The merchant reviews each in the dashboard, and importDocumentDraft(id: ID!, input: ImportDocumentDraftInput!): Invoice! imports the reviewed form as an issued invoice in the draft’s own test or live mode. Draft errors are those in Document drafts, plus the codes in the second table below.
Errors
Section titled “Errors”The code arrives in the error’s details.code.
| Code | When |
|---|---|
IMPORT_DISABLED | Invoice import is not switched on for the platform. Also answered by the chase mutations and voidInvoiceImport |
EMAIL_NOT_VERIFIED | The account’s email address is not verified |
CSV_STORAGE_NOT_CONFIGURED | The switch is on but the platform has no CSV upload storage configured, so requestInvoicesCSVUpload, validateInvoicesCSV and commitInvoicesCSV answer “CSV import is not available yet.” PDF import does not use that storage |
RATE_LIMITED | Too many uploads, previews or commits this hour |
CSV_NOT_UTF8 | The file is not UTF-8. Save it as CSV UTF-8 and upload again |
CSV_PARSE_ERROR | The file cannot be read as an invoice CSV: it is empty, a required column is missing or repeated, or a row cannot be parsed. Nothing is checked until this is fixed |
CSV_TOO_LARGE | The file has more than 500 invoices or 2,000 lines |
CSV_NOT_FOUND | The upload does not exist, is not an invoice import upload, or never reached storage |
CSV_EXPIRED | The upload is older than one hour. Upload the file again |
IMPORT_NOTHING_TO_IMPORT | No invoice in the file can be imported |
IMPORT_CAP_EXCEEDED | The valid invoices exceed what the plan still allows (Free: 50 per mode). Nothing is imported |
IMPORT_AMOUNT_MISMATCH | An invoice’s stored amounts did not match what was checked. It is not imported |
IMPORT_FAILED | The commit failed unexpectedly. Try again |
CHASE_INVALID | The chase choice is malformed, in the past, or outside 1 to 90 days |
CHASE_PLAN_REQUIRED | The plan has no automatic reminders |
CHASE_NOT_AVAILABLE | The invoice is not an open imported invoice |
CHASE_ENDED | setImportChaseNext on an ended chase. Restart it instead |
CHASE_NOT_OVERDUE | setImportChaseNext or restartImportChase on an imported invoice that is not yet overdue in the merchant’s timezone. It follows the normal reminder schedule until it is |
IMPORTED_INVOICE_DISCOUNT_FIXED | applyPromotionCodeToInvoice, clearPromotionCodeOnInvoice or previewPromotionCode on an imported invoice. Its discount is the figure stated at import, so no promotion code can change it. These are the public checkout’s mutations, not dashboard-only ones |
IMPORT_NOT_FOUND | The invoice or import does not exist, belongs to another merchant, or is in the other mode |
The PDF operations answer with these draft codes, in details.code:
| Code | When |
|---|---|
IMPORT_TOO_MANY_FILES | startDocumentImports got no files, or more than 10 |
IMPORT_INVALID | importDocumentDraft: the reviewed invoice fails the same checks as a CSV row (the message lists them, for example a total that does not match, or invoice.settle missing for a prior payment) |
IMPORT_DUPLICATE | importDocumentDraft: that invoice number is already in Plirin |
IMPORT_NOTHING_TO_IMPORT | importDocumentDraft: the invoice passed review but was refused when importing, for example because its number was just taken. Check it and try again |
IMPORT_FAILED | importDocumentDraft failed unexpectedly. Nothing was imported and the draft is still ready. Try again |
DRAFT_NOT_IMPORT | importDocumentDraft on an ordinary draft. Accept it with acceptDocumentDraft |
DRAFT_IS_IMPORT | acceptDocumentDraft on an import draft. Use importDocumentDraft |
IMPORT_TOTALS_MISMATCH | Not an error: a warning code on an import draft whose figures do not add up. The review screen blocks the import until the lines, discount, tax and total agree |
Admin: platform switch
Section titled “Admin: platform switch”For platform operators, PlatformConfig (admin API) has invoiceImportEnabled: Boolean!, and updatePlatformConfig takes the same optional argument. It is Invoice import in the admin console, off by default. See docs/operations/platform-flags.md in the repository.
Simulate a test payment
Section titled “Simulate a test payment”mutation { simulateTestPayment(reference: "abc123...") { id status simulated }}Settles a pending, testnet transaction with no money moving — the “simulate the customer paying” button in the try-a-payment flow. Session-auth only — denied to API keys (deny list + rationale, same treatment as offline payments, see graphql_api_key_access.py), because minting fake settlement from server-to-server credentials with no human in the loop is the obvious abuse path. Rate-limited to 10 requests/minute per merchant, since it creates real settlement side effects: the transaction flips to confirmed, its linked invoice flips to paid through the same path a genuine on-chain confirmation uses, and invoice.paid fires exactly once.
The settled transaction is permanently marked simulated: true — visible on the transaction row, and on the invoice.paid webhook payload — so it can never be mistaken for organic volume. Every call also writes an AUDIT_TEST_PAYMENT_SIMULATED audit row (actor, reference, amount): simulation is a first-class audited event, not a backdoor.
Errors
Section titled “Errors”All refusals return SIMULATION_REFUSED, with details.reason naming which of the four guardrails failed, checked in this order:
reason | When |
|---|---|
NOT_TEST_MODE | The calling merchant is not currently in test mode |
NOT_OWNED | reference belongs to a different merchant |
UNKNOWN_CHAIN | The transaction’s chain can’t be resolved from its wallet, payment link, or invoice — refused rather than treated as safe |
MAINNET_CHAIN | The transaction’s chain is a mainnet chain (details.chain names it) |
NOT_PENDING | The transaction’s status is not pending (details.status names the actual status) |
The transaction is left completely untouched on every refusal path — no partial writes.
Exceeding the rate limit returns a distinct error (not SIMULATION_REFUSED) telling you to retry in a minute. Calling this mutation with an API key is rejected before any guardrail runs — it is not reachable that way at all.
Webhook management
Section titled “Webhook management”mutation { createWebhookEndpoint( url: "https://yourapp.com/webhooks/plirin" eventTypes: ["payment.completed", "invoice.paid"] ) { id url secret eventTypes }}Create subscription
Section titled “Create subscription”mutation { createSubscription(input: { priceId: "108" customerId: "42" trialPeriodDays: 14 }) { id status collectionMethod testMode failedChargeCount currentPeriodStart currentPeriodEnd }}priceId must reference a recurring price; a one-off price is rejected. customerId and
trialPeriodDays are both optional; when trialPeriodDays is omitted, the price’s own
trialPeriodDays (if any) is used.
collectionMethod is optional and defaults to SEND_INVOICE: Plirin generates an invoice each
billing period and sends it to the customer, who pays it like any other invoice. Passing
AUTOMATIC is rejected at creation, and always will be: a brand new subscription has no
mandate, and therefore no chain, so there is nothing yet to check against the automatic-collection
support matrix. Create it as SEND_INVOICE, then have the customer authorize a mandate from the
checkout link; collectionMethod becomes AUTOMATIC at that point, if the subscription’s chain
supports it. Automatic collection is live on Solana today, not on Base or Polygon, and never on
Ethereum mainnet (see Automatic collection).
failedChargeCount and testMode are read-only fields on the returned Subscription, along
with collectionMethod: failedChargeCount counts consecutive failed billing attempts since the
last successful one (see dunning),
and testMode is the environment the subscription was created in: the API key’s own (true for an
sk_test_ key), or the dashboard’s mode at the time for one created in the dashboard. It decides the
networks the subscription collects on.
Create a subscription checkout link
Section titled “Create a subscription checkout link”createSubscriptionLink issues a customer-facing checkout link instead of starting billing
immediately. It creates the same kind of Subscription row as createSubscription, but leaves it
INCOMPLETE with a checkoutUrl for a customer to open themselves.
mutation { createSubscriptionLink(input: { priceId: "108" customerId: "42" trialPeriodDays: 14 }) { checkoutUrl subscription { id status } }}priceId must reference a recurring price, same as createSubscription. customerId is
genuinely optional: leave it unset for an open link, which whoever connects a wallet first can
complete, or set it to restrict the link to that customer, so only their own connected wallet
can complete it, and a mismatched wallet is refused. trialPeriodDays is applied at activation, not
by this call, since a checkout link has no billing period yet to start a trial in.
Plirin does not send this link anywhere. There is no email dispatch on this path; whoever calls
this mutation is responsible for delivering checkoutUrl to the customer.
collectionMethod is accepted for parity with createSubscription and rejects AUTOMATIC the same
way, for the same reason: a link that nobody has completed has no mandate and no chain yet. A
subscription created this way can still become AUTOMATIC later, once its customer authorizes a
mandate.
The returned subscription.status is INCOMPLETE until the customer completes the link at
checkoutUrl, at which point it becomes TRIALING or ACTIVE and a subscription.activated
webhook fires. A link left incomplete for 30 days (SUBSCRIPTION_INCOMPLETE_TTL_DAYS) expires into
INCOMPLETE_EXPIRED on its own.
Change a subscription’s plan
Section titled “Change a subscription’s plan”changeSubscriptionPlan moves a subscription onto a different recurring price
mid-cycle and settles the time already paid for.
mutation { changeSubscriptionPlan( id: "42" priceId: "108" prorationBehavior: CREATE_PRORATIONS ) { subscription { priceId previousPriceId pendingProrationCredit planChangedAt } proration { credit charge net remainingFraction } }}The change takes effect immediately. proration shows its working:
credit— the unused value of the old plancharge— the new plan across that same remaining windownet— what the change costs. Positive means the customer owes; negative means they are owedremainingFraction— how much of the billing period was left, so the two amounts can be shown with their derivation rather than as bare numbers
Credits, not refunds
Section titled “Credits, not refunds”A downgrade produces a credit on the subscription
(pendingProrationCredit), applied to the next invoice. It never triggers an
on-chain refund: a refund on a stablecoin rail is an irreversible transfer that
costs gas, so it is not something an API should do implicitly on a plan change.
A later upgrade in the same period draws that balance down first, so a customer who downgrades and then upgrades is never charged twice for the same days.
prorationBehavior
Section titled “prorationBehavior”| Value | Effect |
|---|---|
CREATE_PRORATIONS (default) | Credit the unused old plan, charge the new one across the same window |
NONE | Move the plan, settle nothing |
ALWAYS_INVOICE | Reserved. Currently behaves as CREATE_PRORATIONS |
What it refuses
Section titled “What it refuses”Rather than produce a plausible but invented figure, the mutation rejects:
- a price belonging to another merchant (reads as not found)
- a one-off price, which has no billing period to prorate across
- a price in a different currency, because netting a credit against a charge across currencies needs an FX rate and rate date this API does not take
- a canceled subscription, since there is no future invoice for a credit to land on
Changing to the price a subscription already has is allowed and costs exactly zero.
Revoke a subscription mandate
Section titled “Revoke a subscription mandate”revokeSubscriptionMandate stops Plirin collecting automatically against a subscription’s on-chain
mandate and drops the subscription back to SEND_INVOICE. It does not cancel the subscription.
mutation { revokeSubscriptionMandate(id: "42") { id status collectionMethod mandate { id status revokedAt } }}Requires the subscription.manage permission; a Cashier is refused.
This is Plirin-side only. On Solana, Plirin holds the SPL delegate on the customer’s token
account, not the account itself, and a delegate cannot revoke itself. Revoking here marks the
mandate so Plirin stops attempting to collect; the customer’s wallet keeps a live delegated
allowance to Plirin’s keeper until the customer revokes it themselves on chain (an SPL Revoke,
or approving a new delegate for zero). Do not treat a successful response as proof the
authorization is gone.
Revoking is idempotent for a mandate that is already revoked, exhausted or superseded:
the mutation succeeds, changes nothing, and writes no duplicate audit entry. A subscription that
never had a mandate at all is different, and still raises a validation error, since nothing there
could reasonably be read as “already revoked”.
Because a Solana token account holds exactly one delegate, revoking also revokes every other active
mandate sharing that same customer wallet and token, each of which may belong to a different
merchant. Each affected subscription emits its own subscription.mandate_revoked webhook. See
Payload Format.
Reading a subscription’s mandate
Section titled “Reading a subscription’s mandate”Subscription.mandate is a nullable field on the Subscription type, not a root query:
{ subscription(id: "42") { id collectionMethod mandate { id chain tokenSymbol tokenAddress customerWallet merchantRecipient mechanism perPeriodCap authorizedTotal periodsAuthorized periodsConsumed status authorizationTx authorizedAt expiresAt revokedAt lastVerifiedAt createdAt } }}This one field is gated differently from its siblings. Every other field on Subscription
reads under transaction.view; mandate requires subscription.manage, the same permission
revokeSubscriptionMandate needs, because it exposes the customer’s and the merchant’s wallet
addresses. A Cashier selecting mandate alongside ordinary fields gets a field error on mandate
specifically while the rest of the selection still resolves.
mandate is null only when the subscription has never had one. A mandate that exists but is
revoked, exhausted or superseded is returned with that status rather than hidden.
The keeper’s delegate pubkey (permissionRef on the underlying row) is deliberately not exposed on
this type, or on the REST equivalent: it is the same value across many mandates and many merchants,
so returning it would hand any caller a free inventory of Plirin’s live signing identities.