Skip to content

Python SDK

The plirin package supports Python 3.10+.

Terminal window
pip install plirin
from plirin import Plirin
plirin = Plirin("sk_live_...")
invoice = plirin.invoices.create({
"collection_method": "send_invoice",
"blockchain": "solana",
"token_symbol": "USDC",
"customer_email": "buyer@example.com",
"line_items": [{"description": "Design work", "amount": "500.00", "quantity": 1}],
})
plirin.invoices.send(invoice["id"])
print(invoice["hosted_invoice_url"])

VAT-registered EU merchants can attach a VAT rate to each line item (a vatRateId, defaulting to the merchant’s standard rate) so the invoice renders a per-rate VAT breakdown. See Tax & VAT settings and the GraphQL mutations reference for the exact field.

plirin = Plirin(api_key, base_url="https://api.your-domain.com")

invoices, credit_notes, customers, estimates, payments, payment_links, products, refunds, subscriptions, webhook_endpoints, api_keys, attachments, statements.

for inv in plirin.invoices.list_all({"limit": 100}):
print(inv["id"], inv["status"])

Attach a file to an invoice or estimate. upload does the whole upload in one call: it registers the file, sends the bytes straight to storage, then confirms it. The storage request carries no Authorization header, so your API key never leaves Plirin.

with open("scope-of-work.pdf", "rb") as f:
attachment = plirin.attachments.upload(
invoice_id=invoice["id"], # or estimate_id=..., never both
data=f.read(),
file_name="scope-of-work.pdf",
content_type="application/pdf",
)
plirin.attachments.update(attachment["id"], shared=False) # hide it from the hosted page
files = plirin.attachments.list(invoice_id=invoice["id"])
url = plirin.attachments.download_url(attachment["id"]) # short-lived signed URL
plirin.attachments.delete(attachment["id"])

Files added to an invoice are shared with the customer by default (files added to an estimate start internal), and anyone with the invoice link can open a shared file, so pass shared=False to update for anything internal. download_url returns the redirect’s target instead of following it, so the bytes do not pass through your process. create_upload and confirm are the individual steps, for when the bytes are sent from somewhere else. If the storage step fails the file is never confirmed, so it never appears in list.

Limits: PDF, PNG, JPEG, WebP, HEIC, CSV, TXT, Word and Excel files, 20 MB each, 10 per document, within your plan’s storage. A sk_test_ key only sees test files. The error codes are listed under Using the API: a refused file raises a PlirinError whose .param is the attachment code, and a full plan is a 402 plan_limit_exceeded.

Move a refund through approval, settlement, and receipting by reference. The refund object includes blockchain, transaction_hash, and explorer_url once the refund is settled on-chain.

plirin.refunds.approve(ref, approval_notes="Verified with customer")
plirin.refunds.reject(ref, rejection_reason="Outside return window")
plirin.refunds.complete(ref, transaction_hash="3vP7kN2mW9qX5yA1bC8dE4fG6hI0jR") # on-chain refunds only
plirin.refunds.send_receipt(ref) # emails the receipt PDF to the customer
pdf = plirin.refunds.receipt(ref) # bytes of the PDF

send_receipt emails it to the customer; receipt returns the bytes so you can attach it to your own record or send it through your own channel.

A refund goes back the way the money came: to the card through Stripe, on-chain, or, for a payment made by bank transfer, cash or check, as a recorded refund (you paid the customer back outside Plirin, so no money moves through Plirin). See Refunds for what each one does and who may approve it. Each method below is a REST route, so any HTTP client can make the same call.

SDK methodREST routeWhat it does
refunds.list_for_invoice(invoice_id)GET /api/v1/invoices/{id}/refundsEvery refund of the invoice, oldest first, and the refundable_sources a new refund can return
refunds.record(invoice_id, ...)POST /api/v1/invoices/{id}/refunds/recordRecord a refund of an offline payment. Completed at once, with a settled credit note. Returns 201
refunds.refund_overpayment(invoice_id, ...)POST /api/v1/invoices/{id}/refunds/overpaymentReturn money received beyond the invoice total, with no credit note. Returns 201
refunds.void_recorded(invoice_id, refund_id, reason=...)POST /api/v1/invoices/{id}/refunds/{refund_id}/voidVoid a recorded refund entered by mistake, with its credit note
credit_notes.issue(id, refund_from=...)POST /api/v1/credit-notes/{id}/issueIssue a draft credit note. On a paid invoice, refund the payment refund_from names

The record, overpayment and void routes honour Idempotency-Key (pass idempotency_key=); the list route is a read, and credit_notes.issue takes no key. A key only reaches invoices of its own mode: a sk_test_ key never records or voids a refund on a live invoice (it answers 404).

list_for_invoice returns each payment that can be refunded, with what is left of it:

result = plirin.refunds.list_for_invoice(invoice_id)
for source in result["refundable_sources"]:
print(source["rail"], source["id"], source["refundable_amount"], source["disputed"])
# card 3f2b... 120.00 False
# offline 9a61... 80.00 False

rail is card, stablecoin or offline. A source with disputed true cannot be refunded: it has a card dispute that needs a response, is under review, or was lost (an inquiry and a won dispute do not block it). When an invoice has one refundable payment, refund_from can be left out.

Create a credit note for the amount, then issue it with the card payment as the source. A card refund is created awaiting approval; approve it to send it to Stripe. Usually 5 to 10 days to reach the customer.

note = plirin.credit_notes.create({
"invoice_id": invoice_id,
"amount": "40.00",
"reason": "Item returned",
})
card = next(s for s in result["refundable_sources"] if s["rail"] == "card")
issued = plirin.credit_notes.issue(note["id"], refund_from={"rail": "card", "id": card["id"]})
# The refund is pending. issued["refund_id"] is its integer id; find it to get its reference:
refunds = plirin.refunds.list_for_invoice(invoice_id)["data"]
pending = next(r for r in refunds if r["id"] == issued["refund_id"])
# Approving it sends it to Stripe:
refund = plirin.refunds.approve(pending["refund_reference"])
refund["status"] # "approved" (Stripe has it), or "completed" or "failed" if Stripe answered at once

An approved card refund is usually not confirmed until Stripe says so. Listen for the refund.completed and refund.failed webhooks rather than polling. A refund that Stripe refused returns status: "failed" with a failure_code, such as CHARGE_DISPUTED, and its credit note is void. If Stripe could not be reached and the request may have been sent, approve still returns approved: Plirin looks for the refund at Stripe on its own and settles it, so do not approve it a second time.

refund = plirin.refunds.record(
invoice_id,
payment_id=offline_source["id"], # a refundable_sources id with rail "offline"
amount="45.00",
method="bank_transfer", # bank_transfer, cash, card, check or other
refunded_at="2026-10-05T09:00:00Z", # optional, never in the future
reference="WIRE-4821", # optional, up to 100 characters
reason="Duplicate order",
idempotency_key="refund-INV-0042-1",
)
refund["rail"] # "offline"
refund["status"] # "completed"
refund["credit_note_id"] # the settled credit note
# Entered by mistake: voids the refund and its credit note.
plirin.refunds.void_recorded(invoice_id, refund["id"], reason="Recorded against the wrong invoice")

The void takes the refund’s integer id, not its reference. Only a completed offline refund can be voided, and a void that would leave the paid invoice owing money is refused.

plirin.refunds.refund_overpayment(
invoice_id,
amount="20.00",
reason="Customer paid twice",
refund_from={"rail": "card", "id": card["id"]}, # optional when only one payment can be refunded
)

A card overpayment refund is created pending and goes to Stripe on refunds.approve. An offline one is recorded at once and may carry method, refunded_at and reference inside refund_from.

A refund dict carries these fields beyond the on-chain ones:

FieldDescription
idInteger ID. It is the starting_after cursor and the ID the void route takes
railstablecoin, card or offline
invoice_id, credit_note_idThe invoice refunded, and the credit note it settles (None for an overpayment refund)
method, reference, refunded_atA recorded refund’s method, your own reference for it, and when you made it
failure_codePlirin’s code for why a card refund failed, never Stripe’s text
statuspending, approved, rejected, completed, failed or voided

payment_reference, blockchain and transaction_hash are None for card and offline refunds. A credit note carries origin (manual, refund_in_plirin, refund_in_stripe or chargeback) and line_items, its amount split across the invoice’s lines and VAT (description, net, vat, gross, rate as a fraction such as "0.2", kind). A credit note stored before the split was kept returns line_items: [].

A refusal from the invoice refund routes, or from issuing a credit note with refund_from, is a 400 whose error.code is the lowercase reason, raised as an InvalidRequestError with that .code:

.codeMeaning
refund_source_requiredThe invoice has more than one refundable payment: pass refund_from
refund_source_not_foundrefund_from names a payment that is not a refundable payment of this invoice
payment_disputedThe card payment has a dispute that blocks refunds
refund_exceeds_refundableMore than is left to refund of that payment. The message says how much is left
refund_exceeds_overpayment, not_overpaidThe overpayment is smaller than the refund, or there is none
invoice_not_paidA refund is recorded only on a paid invoice
invalid_amount, reason_required, currency_mismatchThe amount, reason or currency is not acceptable
invalid_refund_method, refund_date_in_future, invalid_referenceA recorded refund’s method, date or reference is not acceptable
refund_not_voidable, void_would_reopen_invoiceThe refund is not a recorded refund, or voiding it would leave the paid invoice owing money
refund_refusedThe refund was refused for a reason not listed here. The message says why
refund_destination_unknownAn on-chain refund needs the payer’s wallet, and none is on record

A refund that is not on the invoice you voided it from is a 404 with the code resource_not_found. Creating a credit note for more than the invoice has left to credit is a 400 with the code invalid_request and a message that says so.

Approving a pending card refund (refunds.approve) can be refused with an uppercase .code, because the refusal comes from the Stripe side of Plirin:

.codeMeaning
PAYMENT_DISPUTEDThe card payment has a dispute that blocks refunds
CREDIT_OVER_COMMITTEDThe same money was already refunded in Stripe, so sending this one would refund it twice. Reject the request
STRIPE_ACCOUNT_UNREACHABLEPlirin can no longer reach the merchant’s Stripe account: refund in the Stripe dashboard
STRIPE_UNREACHABLEStripe could not be reached and nothing was sent. The refund is pending again, and approving it again is safe
PLATFORM_KEY_INVALID, STRIPE_API_ERRORStripe rejected Plirin’s platform key (PLATFORM_KEY_INVALID) or rate limited the request (STRIPE_API_ERROR) before any refund was made. The refund is pending again, and approving it again is safe
REFUND_ALREADY_AT_STRIPE, PAYMENT_NOT_REFUNDABLEThe refund was already sent, or the card payment did not succeed

A key acts as the owner, so approving through the API is not subject to the manager approval limit.

When the merchant takes card payments through their own Stripe account, each invoice carries card_payments_allowed (True, False, or None to follow the merchant’s default) and provider_payments, the card payments made or attempted against it, newest first (plirin.types.ProviderPayment).

inv = plirin.invoices.retrieve(invoice["id"])
for p in inv.get("provider_payments", []):
print(p["rail"], p["status"], p["amount"], p["currency"], p["needs_refund_reason"])

Each entry in provider_payments has:

FieldTypeMeaning
idstringThe payment’s Plirin ID
railstringHow the client paid: card today
statusstringrequires_payment (started), processing, succeeded, failed or canceled. Only succeeded reduces the invoice’s balance
amount, currencystringWhat was charged, a decimal string at the currency’s own precision, and its ISO code
platform_fee_amountstringPlirin’s fee on this payment, taken from the merchant’s payout. Zero unless the platform has switched the fee on and the merchant is on the free Starter plan
amount_refundedstringThe part already refunded
livemodebooleanFalse for a test-mode payment, made in Stripe’s test mode
succeeded_atstring or NoneWhen it succeeded (ISO 8601), or None
needs_refund_reasonstring or Nonevoid_invoice (the client paid as the invoice was voided) or overpaid: money the invoice did not need. Plirin does not refund it by itself

card_payments_allowed is the invoice’s own setting. It is None while the invoice follows the merchant’s default. You can set it when you create an invoice over GraphQL (createInvoice(input: { cardPaymentsAllowed })), but changing it afterwards, the merchant default, and connecting Stripe are dashboard-only and denied to API keys. Whether a card can actually be taken also depends on the merchant’s Stripe connection, so True here means the merchant’s intent.

A card payment cannot be started through the API: the client pays on the hosted invoice page. Refund one the same way as any payment (see Refunding an invoice), and read its disputes through the payment.dispute_closed webhook. When a card payment settles the invoice, invoice.paid fires with a payments[] entry whose rail is card and whose reference is the Stripe PaymentIntent ID. For the merchant side, see Card Payments.

Record payments made outside Plirin — bank transfer, cash, card, check — against an invoice.

plirin.invoices.record_payment(invoice_id, amount="100.00", method="bank_transfer", reference="WIRE-4821")
plirin.invoices.remove_payment(payment_id, reason="Recorded against the wrong invoice")
plirin.invoices.restore_payment(payment_id, reason="Removed by mistake; the transfer did arrive")
history = plirin.invoices.list_payments(invoice_id) # includes soft-deleted rows

Dashboard/session-token only in v1 — these are denied to API keys server-side. See GraphQL Mutations for the field reference and error codes.

Quotes with online acceptance. Requires a plan that includes estimates; without one every call returns 403 plan_limit_exceeded. A sk_test_ key only sees test estimates and a sk_live_ key only sees live ones.

draft = plirin.estimates.create({
"customer_email": "client@example.com",
"expires_on": "2026-11-15", # a date on your calendar
# Quantities and amounts are strings, never floats.
"line_items": [{"description": "Consulting", "quantity": "2", "unit_amount": "150.00"}],
})
sent = plirin.estimates.send(draft["id"]) # assigns the number and hosted_url, emails the customer
plirin.estimates.update(sent["id"], {"memo": "Revised scope"}) # a sent estimate gets a new revision
plirin.estimates.void(sent["id"], reason="Superseded")

If the customer agreed off-platform, convert it yourself. The result is a draft invoice, in the same shape as invoices.retrieve:

invoice = plirin.estimates.convert(sent["id"])
pdf = plirin.estimates.pdf(sent["id"]) # bytes of the PDF

Errors that depend on the estimate’s state return 409 with a specific code, such as estimate_not_sendable or estimate_already_decided. The GraphQL estimate operations are dashboard-only and denied to API keys; use these methods instead.

A statement of one customer’s account for a period: opening balance, invoices, payments, credit notes and refunds, closing balance, what is still unpaid and its ageing. It needs a plan with statements (Growth and Scale), and customer statements are being rolled out, so they may not be switched on for your account yet: until they are, every call raises a plirin.PermissionError (not the Python builtin of the same name) with code STATEMENTS_DISABLED. A sk_test_ key reads test-mode statements and a sk_live_ key live ones. See Customer statements for what a statement shows.

MethodDoes
customers.statement(id, currency=, from_=, to=)The statement as a dict (amounts are strings)
customers.statement_pdf(id, currency=, from_=, to=)The same statement as PDF bytes
customers.send_statement(id, currency=, from_=, to=, cc=None, note=None, idempotency_key=None)Emails it to the customer and returns the history record
customers.list_statements(id, limit=20, offset=0)The statements sent to a customer, newest first, as {"items": [...], "total": n}
statements.pdf(statement_id)The PDF of a statement that was sent, exactly as the customer received it

from_ and to are YYYY-MM-DD dates on your calendar (the wire name of from_ is from, which Python reserves). cc takes up to 5 addresses and note up to 500 characters.

statement = plirin.customers.statement(customer_id, currency="USD", from_="2026-09-01", to="2026-09-30")
print(statement["closing_balance"], statement["is_empty"]) # "1250.00" False
pdf = plirin.customers.statement_pdf(customer_id, currency="USD", from_="2026-09-01", to="2026-09-30")
record = plirin.customers.send_statement(
customer_id,
currency="USD",
from_="2026-09-01",
to="2026-09-30",
cc=["accounts@acme.example"],
note="Please pay by the 15th.",
idempotency_key="stmt-17-2026-09",
)
print(record["number"], record["status"]) # ST-0007 sent
sent_copy = plirin.statements.pdf(record["id"]) # what the customer received
page = plirin.customers.list_statements(customer_id, limit=20)

send_statement retries safely with the same idempotency_key. A statement with nothing to show raises an InvalidRequestError (HTTP 422) with code STATEMENT_NO_ACTIVITY. A customer with no email is the same class with CUSTOMER_HAS_NO_EMAIL, and a period with more than 1,000 activity lines is STATEMENT_TOO_LARGE. An email provider failure raises an APIError with EMAIL_SEND_FAILED (HTTP 502): the record stays failed and no email was sent. The error carries no statement id; find the failed record with customers.list_statements. The SDK retries a 5xx response twice by default, and a failed send is not stored for idempotency, so one send_statement call can make up to three attempts. Each attempt takes the next statement number and leaves its own failed record (only the last one can succeed). To get one attempt per call, create the client with Plirin(api_key, max_retries=0) and retry yourself. statements.pdf on a statement that was never sent raises an IdempotencyError (HTTP 409) with STATEMENT_NOT_SENT. The codes are in Error codes, and the error is read from .code.

A statement’s opening_differs_from names the statement sent for the period just before (number, period_start, period_end, closing_balance) when its closing balance differs from this opening balance, because activity dated in that period was recorded after it was sent; otherwise it is null.

A sent-statement record’s status is sending, sent or failed, and its trigger is dashboard, api or schedule; both are typed as closed sets (plirin.StatementStatus and plirin.StatementTrigger).

Scheduling statements and opting a customer out are dashboard settings, not SDK methods.

The catalogue. Products carry the descriptive fields; a price attaches an amount and a billing cadence to one.

product = plirin.products.create(name="Pro plan", sku="PRO-1")
# Amounts are strings, never floats: binary floating point cannot represent
# 0.10 exactly, and this is money.
price = plirin.products.create_price(
product["id"], amount="49.00", type="recurring", billing_period="month"
)
plirin.products.list(active=True, limit=20)
plirin.products.update(product["id"], name="Pro plan (annual)")
plirin.products.list_prices(product["id"])

A recurring price must specify billing_period (day, week, month, or year). The API rejects it otherwise rather than inventing a cadence you never chose. Omitting currency falls back to your account’s default.

Supported currencies are USD, EUR, GBP, AUD, CAD, CHF, NZD and SGD; anything else is rejected rather than stored. Note that denomination and settlement are separate concerns: USD and EUR have stablecoins and can be collected on-chain, while the rest are invoice-only — the invoice states the amount and your customer pays by whatever means you agreed, which you record against the invoice. An invoice in an invoice-only currency is always MANUAL, even with the payments add-on, because no chain can settle it.

Retire a product without deleting it. Archived products drop out of list() but keep their history, which matters because invoices and subscriptions still reference them.

plirin.products.archive(product["id"])
plirin.products.unarchive(product["id"])

Archiving something already archived returns a 400 rather than succeeding silently, so a call that reports success really did change something.

Recurring billing, including mid-cycle plan changes. Each period, Plirin generates an invoice and sends it; the customer pays it the way they would pay any other invoice.

sub = plirin.subscriptions.create(price_id=42)
plirin.subscriptions.pause(sub["id"])
plirin.subscriptions.resume(sub["id"])
# Defaults to cancelling at period end, so the customer keeps time they have
# already paid for. Pass False to end it immediately.
plirin.subscriptions.cancel(sub["id"])

A subscription’s status is one of incomplete, incomplete_expired, trialing, active, past_due, on_hold, paused, or canceled. on_hold means dunning has exhausted its retry schedule (1, 3, 5, and 7 days after a failed billing attempt) without collecting payment; treat it like past_due for access control. A subscription returns to active on its own once the outstanding invoice is paid.

collection_method is "send_invoice" (the default) or "automatic". Passing "automatic" to create() or create_link() is always rejected: a brand new subscription has no mandate, and therefore no chain to check against Plirin’s automatic-collection support matrix. Create it as "send_invoice"; it becomes "automatic" once the customer authorizes an on-chain mandate from the checkout link, on a chain that supports it.

sub = plirin.subscriptions.create(price_id=42, collection_method="send_invoice")
sub["collection_method"] # "send_invoice"
sub["test_mode"] # whether this subscription bills in test mode
sub["failed_charge_count"] # consecutive failed billing attempts, reset on recovery

create() starts billing immediately. create_link() instead issues a customer-facing checkout link: the subscription is created "incomplete" and stays that way until the customer opens the link and connects a wallet themselves.

link = plirin.subscriptions.create_link(price_id=42, customer_id=17)
link["status"] # "incomplete"
link["checkout_url"] # where to send the customer

Leave customer_id unset for an open link: whoever connects a wallet first becomes the subscriber. Pass it to restrict the link to that customer, so only their own connected wallet can complete it; a different wallet is refused.

The SDK does not send this link anywhere, and neither does Plirin. There is no email dispatch on this path. Deliver checkout_url to the customer yourself.

Once the customer completes the link, the subscription moves to "trialing" or "active", customer_id is set (or confirmed, for a restricted link), and its first invoice is minted and sent. A link left incomplete for 30 days expires on its own into "incomplete_expired".

result = plirin.subscriptions.change_plan(sub["id"], price_id=108)
result["proration"]["net"] # "20.00" owed, or negative if the customer is owed
result["proration"]["credit"] # unused value of the old plan
result["proration"]["charge"] # the new plan over the same remaining window

The change takes effect immediately. Unused time is credited to the next invoice (subscription["pending_proration_credit"]), never refunded on-chain: a stablecoin refund is an irreversible transfer that costs gas.

Pass proration_behavior="none" to move the plan without settling anything; proration then comes back None.

A subscription collects automatically only while it holds an active on-chain mandate, authorized by the customer from the checkout link. Two methods read and end one:

m = plirin.subscriptions.mandate(sub["id"])
m["chain"] # "solana"
m["token_symbol"] # "USDC"
m["customer_wallet"] # the payer
m["merchant_recipient"] # your payout wallet at authorization time, or None
m["mechanism"] # "spl_delegate"
m["per_period_cap"] # "50.000000" - a decimal string, always 6 places
m["authorized_total"] # "600.000000"
m["periods_authorized"] # 12
m["periods_consumed"] # 0
m["status"] # "active" | "revoked" | "exhausted" | "superseded" | "pending" | "failed"
# Stops Plirin collecting; returns the subscription, now "send_invoice".
plirin.subscriptions.revoke_mandate(sub["id"])

mandate() raises NotFoundError only when the subscription has never had a mandate. One that exists but is revoked, exhausted or superseded comes back with that status rather than 404ing.

revoke_mandate() is idempotent: calling it again on an already-revoked mandate succeeds and changes nothing. Calling it on a subscription that never had a mandate raises InvalidRequestError.

revoke_mandate() does not remove the on-chain authorization. On Solana, Plirin holds the SPL delegate on the customer’s token account, and a delegate cannot revoke itself. This stops Plirin collecting; the customer’s wallet keeps a live delegated allowance to Plirin until the customer revokes it themselves on chain. It can also revoke sibling mandates: a Solana token account holds one delegate, so a customer paying two subscriptions from the same wallet and token has both revoked at once, possibly across merchants.

Key rotation, not bootstrapping — every call is authenticated with an existing key, so the first one still comes from the dashboard.

created = plirin.api_keys.create(label="worker-2026-09")
created["key"] # the full secret, returned ONCE and never retrievable again
plirin.api_keys.list() # prefixes and last_used_at, never secrets
plirin.api_keys.revoke(old_id)

Revoking the key you are currently authenticating with locks this client out of subsequent calls.

Every POST automatically attaches a UUID Idempotency-Key (override with idempotency_key=). Requests retry on network errors, 429, and 5xx with exponential backoff (default 2 retries), reusing the same key so retries never double-charge.

from plirin import SignatureVerificationError
try:
event = plirin.webhooks.construct_event(raw_body, signature_header, endpoint_secret)
# event["event_type"], event["data"]
except SignatureVerificationError:
... # respond 400

Pass the raw request body string. construct_event verifies the X-Plirin-Signature (t=…,v1=…) HMAC-SHA256 scheme with a 5-minute timestamp tolerance by default.

Every failure raises a PlirinError subclass — AuthenticationError, PermissionError, NotFoundError, InvalidRequestError, IdempotencyError, RateLimitError, APIError, APIConnectionError — each with .code, .status_code, and .param.

An account whose owner has not verified their email gets a 403 email_not_verified from the send, remind, webhook-endpoint, API-key and staff routes, raised as a PermissionError with that .code. See Verified email required.