Skip to content

Mutations

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.

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 }
}
}

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.

# Email
mutation {
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.

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.

  • period is weekly, monthly, quarterly or yearly. Anything else refuses the whole call.
  • startDate is 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.
  • endDate bounds 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. Like startDate it is read as a day on the merchant’s calendar, so a send on 30 June is made for a merchant in any timezone, and startDate and endDate on the same day means a schedule that bills once.
  • leadDays is 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 on weekly, 27 on monthly, 89 on quarterly, 364 on yearly, 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. 0 is a real answer and means no review window: the copy is prepared on its send date.
  • carry is 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 NAME
carry: { groups: { tip: false } } # named groups; the rest stay carried

CarryInput 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.

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.

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.

ActionNeeds recurring_billing
setInvoiceRecurring, cancelInvoiceRecurringNo
Copies prepared as drafts by the workerNo
Sending a copy by hand, nowNo
A copy sending itself on its send dateYes
Arming a copy for a later send (scheduledSendAt on createInvoice or updateInvoice)Yes
batchSendInvoices with a copy in the selectionYes

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.

A schedule or carry refusal arrives with the code in extensions.details.code.

CodeWhen
RECURRING_LEAD_OUT_OF_RANGEleadDays is negative or as long as the period. The message names the maximum for that period
RECURRING_START_AFTER_ENDThe first send date is after the end date, so the schedule would never generate anything
RECURRING_START_IN_PASTstartDate 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_VOIDThe 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_AMBIGUOUScarry named both a preset and groups
CARRY_EMPTYcarry named neither. Omit carry for an exact copy
CARRY_UNKNOWN_PRESETA preset name the server does not know. Reachable over REST and the SDKs, where the name is a plain string
CARRY_UNKNOWN_GROUPA 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_FLAGA group’s value is not a boolean. Refused, never coerced: "false" is a truthy string
CARRY_TAX_REQUIRED_IN_VAT_MODEgroups named tax: false for a merchant who invoices under VAT. Send preset: items_only instead, which resolves the tax back on
CARRY_DISCOUNT_WITHOUT_TAXgroups 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”.

mutation {
createPaymentLink(
amount: "50.00"
description: "Consulting fee"
blockchain: "solana"
token: "USDC"
) {
paymentLink
qrCode
amount
}
}

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.

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.

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.

mutation {
approveRefund(refundReference: "REF-20260510-0001", approvalNotes: "Verified with customer") {
id
refundReference
status
}
}
mutation {
rejectRefund(refundReference: "REF-20260510-0001", rejectionReason: "Outside return window") {
id
refundReference
status
}
}
mutation {
completeRefund(refundReference: "REF-20260510-0001", transactionHash: "3vP7kN2mW9qX5yA1bC8dE4fG6hI0jR") {
id
refundReference
status
refundTransactionHash
explorerUrl
}
}
mutation {
sendRefundReceipt(refundReference: "REF-20260510-0001") {
id
refundReference
status
}
}

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
}
}

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 currentPassword is refused with details.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_changed audit entry.

Nothing in the update is saved when the change is refused. API keys and staff cannot change the login email.

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.

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.

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.

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.

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.

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
}
}
  • method is one of bank_transfer, cash, card, check, other (lowercase — OfflinePaymentMethod enum values).
  • amount must be greater than 0 and no more than the invoice’s current amount_remaining; there’s no credit-balance concept in v1, so overpayment is rejected outright.
  • paymentDate is optional (defaults to now), can be backdated, but can’t be set in the future.
  • Allowed only on sent, pending, or overdue invoices — not draft, void, or already-paid.
  • Honors the Idempotency-Key header (also used by createPayment) so a double-submitted form can’t double-record.
  • If this brings amount_remaining to zero, the invoice flips to paid and fires the same invoice.paid webhook 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.
  • reason is required: a blank one fails with REASON_REQUIRED.
  • If removing it un-settles a paid invoice, the invoice reopens: to sent, or directly to overdue if its due day has already ended on the merchant’s calendar.
  • Fails with PAYMENT_LOCKED_BY_CREDIT_NOTE if a credit note was issued against the invoice after this payment was recorded.
  • Fails with INVOICE_VOID_OR_DRAFT on 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_OVERPAY rather than overpaying the invoice.
  • reason is required, as for a removal (REASON_REQUIRED when blank): a restore can mark the invoice paid. The dashboard asks for it in a confirmation dialog.
  • Fails with INVOICE_VOID_OR_DRAFT on 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/deletedReason set), so the UI can render full history.
  • deletedAt and deletedReason describe only the latest removal. events is 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.
CodeWhen
OVERPAYMENT_REJECTEDamount exceeds the invoice’s current amount_remaining
INVALID_INVOICE_STATUSRecording on draft/void/paid, or acting on a payment/invoice that doesn’t exist
PAYMENT_DATE_IN_FUTUREpaymentDate is in the future
RESTORE_WOULD_OVERPAYRestoring would push the balance below zero
PAYMENT_LOCKED_BY_CREDIT_NOTERemoving or restoring a payment that a later credit note was issued against
PAYMENT_HAS_RECORDED_REFUNDRemoving or restoring a payment that has a recorded refund not voided: void the refund first
REASON_REQUIREDRemoving or restoring without a reason
REASON_TOO_LONGA remove or restore reason longer than 500 characters
INVOICE_VOID_OR_DRAFTRemoving 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.

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.

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 removeLateFee and setCustomerLateFeeExemption.
  • Not plan-gated. Unlike updateLateFeePolicy, it works whatever the merchant’s plan, because it forgives rather than creates a fee obligation. This matches removeLateFee and setCustomerLateFeeExemption.
  • On REST, creation only. The REST create route (POST /api/v1/invoices) accepts late_fees_waived and, like createInvoice, refuses late_fees_waived: true unless collection_method is send_invoice: it answers 400 with param: "late_fees_waived" and creates nothing. There is no REST endpoint to set or clear the waiver on an existing invoice; that is setInvoiceLateFeeWaiver, 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 on invoices.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: setInvoiceLateFeeWaiver is denied to API keys.

The code arrives in the error’s details.reason.

CodeWhen
INVOICE_NOT_DRAFTsetInvoiceLateFeeWaiver on an invoice that has been sent or is otherwise not a draft
LATE_FEES_WAIVER_NOT_APPLICABLEcreateInvoice 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_FOUNDThe fee id is malformed or belongs to another merchant
LATE_FEE_PAIDThe fee invoice has a payment, so it cannot be removed. Issue a credit note or refund
LATE_FEE_PAYMENT_IN_FLIGHTA customer has started paying the fee and the payment has not settled or lapsed. Try again later
LATE_FEE_NOT_REMOVABLEThe fee invoice is in a status that cannot be voided
LATE_FEE_ALREADY_REMOVEDThe fee was already removed
LATE_FEE_REASON_TOO_LONGreason is longer than the maximum length
CUSTOMER_NOT_FOUNDThe customer id is malformed or belongs to another merchant
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:

MessageWhen
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.

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 email is 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_bill audit 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.

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.

For platform operators, PlatformConfig (admin API) has two new fields, and updatePlatformConfig takes the same two optional arguments:

FieldMeaning
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.

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)TypeNotes
customerIdID!One of your customers
currencyString!A three-letter code. The statement covers this currency only
periodStart, periodEndDate!YYYY-MM-DD, on your calendar. periodEnd must not be before periodStart
cc[String!]At most 5 extra addresses, for this send only
noteStringUp 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.

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
modemanual or scheduled. Required
frequencymonthly or weekly
dayOfMonthA whole number from 1 to 28
weekdayA whole number from 0 (Monday) to 6 (Sunday)
recipientsopen_balance (customers who owe you at the end of the period) or any_activity (customers with any entry in it)
defaultNoteUp 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.

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.

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.

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.

CodeWhen
STATEMENTS_DISABLEDThe platform switch is off
FEATURE_NOT_IN_PLANThe plan has no statements
EMAIL_NOT_VERIFIEDThe account email is not verified yet, so nothing can be emailed to customers
rate_limitedMore than 60 statement sends in a minute for the account
STATEMENT_NO_ACTIVITYNothing to show for that customer, currency and period
STATEMENT_TOO_LARGEMore than 1,000 activity lines in the period
CUSTOMER_HAS_NO_EMAILThe customer has no email address
EMAIL_SEND_FAILEDThe email provider failed. The record is kept as failed

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 is pending: 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.bat uploaded as text/plain comes back as run.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_FOUND or ATTACHMENT_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.

The code arrives in the error’s details.code.

CodeWhen
ATTACHMENTS_DISABLEDAttachments are not switched on for the platform, or file storage is not configured
ATTACHMENT_TYPE_NOT_ALLOWEDcontentType is not one of PDF, PNG, JPEG, WebP, HEIC, CSV, TXT, DOCX or XLSX
ATTACHMENT_TOO_LARGEsizeBytes is zero or over 20 MB
ATTACHMENT_LIMIT_REACHEDThe invoice or estimate already has 10 files
STORAGE_QUOTA_EXCEEDEDYour plan’s file storage is full. Remove files or upgrade
ATTACHMENT_OWNER_INVALIDBoth or neither of invoiceId and estimateId were given
ATTACHMENT_OWNER_NOT_FOUNDThe invoice or estimate does not exist, belongs to another merchant, or is in the other mode
ATTACHMENT_NOT_FOUNDThe file does not exist, was removed, belongs to another merchant, or is in the other mode
ATTACHMENT_UPLOAD_MISSINGconfirmAttachmentUpload ran before the bytes reached storage. The file stays pending, so upload and confirm again
ATTACHMENT_SIZE_MISMATCHThe stored file is empty or larger than sizeBytes declared. The file is discarded
ATTACHMENT_CONTENT_MISMATCHThe contents are not the declared type, or a Word or Excel file contains macros or embedded code. The file is discarded
ATTACHMENT_CHANGEDThe stored file changed while it was being checked. The file is discarded; upload it again
STORAGE_UNAVAILABLEFile 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.

For platform operators, PlatformConfig (admin API) has two more fields, and updatePlatformConfig takes the same two optional arguments:

FieldMeaning
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.

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. status of a row is ready, refused or already_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. refusedCsv is the refused rows with their reasons (cells starting with =, +, - or @ are escaped), or null.
  • chase.mode is now, date or none; firstOn is a YYYY-MM-DD day on the merchant’s calendar (not in the past) for date, sent at 09:00 merchant time; everyDays is 1 to 90 (default 7). The chase applies only to invoices that are overdue at import. Without chase, no reminders are scheduled. On a plan without automatic reminders the choice is ignored, an explicit none included, 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 }
}
}
  • stopImportChase clears the next reminder and keeps the other fields. setImportChaseNext moves the next reminder to 09:00 on a day (YYYY-MM-DD, merchant calendar, not in the past). restartImportChase sets 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 (stopImportChase does 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 with CHASE_NOT_OVERDUE. All four import mutations here, including voidInvoiceImport, answer IMPORT_DISABLED while 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, REST POST /api/v1/invoices/{id}/remind or the admin resend. Each of the three chase mutations writes an invoice.import_chase_changed audit entry.
  • voidInvoiceImport voids every invoice of the import that has no payment, credit note or reminder since the import, and lists the others in skipped with the reason. Invoices are voided, never deleted. It writes invoice.import_voided.
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.

The code arrives in the error’s details.code.

CodeWhen
IMPORT_DISABLEDInvoice import is not switched on for the platform. Also answered by the chase mutations and voidInvoiceImport
EMAIL_NOT_VERIFIEDThe account’s email address is not verified
CSV_STORAGE_NOT_CONFIGUREDThe 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_LIMITEDToo many uploads, previews or commits this hour
CSV_NOT_UTF8The file is not UTF-8. Save it as CSV UTF-8 and upload again
CSV_PARSE_ERRORThe 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_LARGEThe file has more than 500 invoices or 2,000 lines
CSV_NOT_FOUNDThe upload does not exist, is not an invoice import upload, or never reached storage
CSV_EXPIREDThe upload is older than one hour. Upload the file again
IMPORT_NOTHING_TO_IMPORTNo invoice in the file can be imported
IMPORT_CAP_EXCEEDEDThe valid invoices exceed what the plan still allows (Free: 50 per mode). Nothing is imported
IMPORT_AMOUNT_MISMATCHAn invoice’s stored amounts did not match what was checked. It is not imported
IMPORT_FAILEDThe commit failed unexpectedly. Try again
CHASE_INVALIDThe chase choice is malformed, in the past, or outside 1 to 90 days
CHASE_PLAN_REQUIREDThe plan has no automatic reminders
CHASE_NOT_AVAILABLEThe invoice is not an open imported invoice
CHASE_ENDEDsetImportChaseNext on an ended chase. Restart it instead
CHASE_NOT_OVERDUEsetImportChaseNext 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_FIXEDapplyPromotionCodeToInvoice, 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_FOUNDThe 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:

CodeWhen
IMPORT_TOO_MANY_FILESstartDocumentImports got no files, or more than 10
IMPORT_INVALIDimportDocumentDraft: 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_DUPLICATEimportDocumentDraft: that invoice number is already in Plirin
IMPORT_NOTHING_TO_IMPORTimportDocumentDraft: the invoice passed review but was refused when importing, for example because its number was just taken. Check it and try again
IMPORT_FAILEDimportDocumentDraft failed unexpectedly. Nothing was imported and the draft is still ready. Try again
DRAFT_NOT_IMPORTimportDocumentDraft on an ordinary draft. Accept it with acceptDocumentDraft
DRAFT_IS_IMPORTacceptDocumentDraft on an import draft. Use importDocumentDraft
IMPORT_TOTALS_MISMATCHNot 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

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.

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.

All refusals return SIMULATION_REFUSED, with details.reason naming which of the four guardrails failed, checked in this order:

reasonWhen
NOT_TEST_MODEThe calling merchant is not currently in test mode
NOT_OWNEDreference belongs to a different merchant
UNKNOWN_CHAINThe transaction’s chain can’t be resolved from its wallet, payment link, or invoice — refused rather than treated as safe
MAINNET_CHAINThe transaction’s chain is a mainnet chain (details.chain names it)
NOT_PENDINGThe 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.

mutation {
createWebhookEndpoint(
url: "https://yourapp.com/webhooks/plirin"
eventTypes: ["payment.completed", "invoice.paid"]
) {
id
url
secret
eventTypes
}
}
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.

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.

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 plan
  • charge — the new plan across that same remaining window
  • net — what the change costs. Positive means the customer owes; negative means they are owed
  • remainingFraction — how much of the billing period was left, so the two amounts can be shown with their derivation rather than as bare numbers

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.

ValueEffect
CREATE_PRORATIONS (default)Credit the unused old plan, charge the new one across the same window
NONEMove the plan, settle nothing
ALWAYS_INVOICEReserved. Currently behaves as CREATE_PRORATIONS

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.

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.

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.