Python SDK
The plirin package supports Python 3.10+.
Install
Section titled “Install”pip install plirinQuickstart
Section titled “Quickstart”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.
Sandbox / self-host
Section titled “Sandbox / self-host”plirin = Plirin(api_key, base_url="https://api.your-domain.com")Resources
Section titled “Resources”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"])Attachments
Section titled “Attachments”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 pagefiles = plirin.attachments.list(invoice_id=invoice["id"])url = plirin.attachments.download_url(attachment["id"]) # short-lived signed URLplirin.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.
Refund lifecycle
Section titled “Refund lifecycle”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 onlyplirin.refunds.send_receipt(ref) # emails the receipt PDF to the customerpdf = plirin.refunds.receipt(ref) # bytes of the PDFsend_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.
Refunding an invoice
Section titled “Refunding an invoice”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 method | REST route | What it does |
|---|---|---|
refunds.list_for_invoice(invoice_id) | GET /api/v1/invoices/{id}/refunds | Every 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/record | Record 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/overpayment | Return 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}/void | Void a recorded refund entered by mistake, with its credit note |
credit_notes.issue(id, refund_from=...) | POST /api/v1/credit-notes/{id}/issue | Issue 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).
Choosing the payment
Section titled “Choosing the payment”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 Falserail 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.
Refund a card payment
Section titled “Refund a card payment”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 onceAn 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.
Record a refund you paid yourself
Section titled “Record a refund you paid yourself”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.
Refund an overpayment
Section titled “Refund an overpayment”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.
Refund and credit note fields
Section titled “Refund and credit note fields”A refund dict carries these fields beyond the on-chain ones:
| Field | Description |
|---|---|
id | Integer ID. It is the starting_after cursor and the ID the void route takes |
rail | stablecoin, card or offline |
invoice_id, credit_note_id | The invoice refunded, and the credit note it settles (None for an overpayment refund) |
method, reference, refunded_at | A recorded refund’s method, your own reference for it, and when you made it |
failure_code | Plirin’s code for why a card refund failed, never Stripe’s text |
status | pending, 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: [].
Refund errors
Section titled “Refund errors”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:
.code | Meaning |
|---|---|
refund_source_required | The invoice has more than one refundable payment: pass refund_from |
refund_source_not_found | refund_from names a payment that is not a refundable payment of this invoice |
payment_disputed | The card payment has a dispute that blocks refunds |
refund_exceeds_refundable | More than is left to refund of that payment. The message says how much is left |
refund_exceeds_overpayment, not_overpaid | The overpayment is smaller than the refund, or there is none |
invoice_not_paid | A refund is recorded only on a paid invoice |
invalid_amount, reason_required, currency_mismatch | The amount, reason or currency is not acceptable |
invalid_refund_method, refund_date_in_future, invalid_reference | A recorded refund’s method, date or reference is not acceptable |
refund_not_voidable, void_would_reopen_invoice | The refund is not a recorded refund, or voiding it would leave the paid invoice owing money |
refund_refused | The refund was refused for a reason not listed here. The message says why |
refund_destination_unknown | An 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:
.code | Meaning |
|---|---|
PAYMENT_DISPUTED | The card payment has a dispute that blocks refunds |
CREDIT_OVER_COMMITTED | The same money was already refunded in Stripe, so sending this one would refund it twice. Reject the request |
STRIPE_ACCOUNT_UNREACHABLE | Plirin can no longer reach the merchant’s Stripe account: refund in the Stripe dashboard |
STRIPE_UNREACHABLE | Stripe could not be reached and nothing was sent. The refund is pending again, and approving it again is safe |
PLATFORM_KEY_INVALID, STRIPE_API_ERROR | Stripe 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_REFUNDABLE | The 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.
Card payments on an invoice
Section titled “Card payments on an invoice”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:
| Field | Type | Meaning |
|---|---|---|
id | string | The payment’s Plirin ID |
rail | string | How the client paid: card today |
status | string | requires_payment (started), processing, succeeded, failed or canceled. Only succeeded reduces the invoice’s balance |
amount, currency | string | What was charged, a decimal string at the currency’s own precision, and its ISO code |
platform_fee_amount | string | Plirin’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_refunded | string | The part already refunded |
livemode | boolean | False for a test-mode payment, made in Stripe’s test mode |
succeeded_at | string or None | When it succeeded (ISO 8601), or None |
needs_refund_reason | string or None | void_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.
Offline invoice payments
Section titled “Offline invoice 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 rowsDashboard/session-token only in v1 — these are denied to API keys server-side. See GraphQL Mutations for the field reference and error codes.
Estimates
Section titled “Estimates”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 customerplirin.estimates.update(sent["id"], {"memo": "Revised scope"}) # a sent estimate gets a new revisionplirin.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 PDFErrors 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.
Customer statements
Section titled “Customer statements”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.
| Method | Does |
|---|---|
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 receivedpage = 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.
Products and prices
Section titled “Products and prices”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.
Archiving
Section titled “Archiving”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.
Subscriptions
Section titled “Subscriptions”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 modesub["failed_charge_count"] # consecutive failed billing attempts, reset on recoveryCheckout links
Section titled “Checkout links”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 customerLeave 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".
Changing plan mid-cycle
Section titled “Changing plan mid-cycle”result = plirin.subscriptions.change_plan(sub["id"], price_id=108)
result["proration"]["net"] # "20.00" owed, or negative if the customer is owedresult["proration"]["credit"] # unused value of the old planresult["proration"]["charge"] # the new plan over the same remaining windowThe 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.
Mandates
Section titled “Mandates”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 payerm["merchant_recipient"] # your payout wallet at authorization time, or Nonem["mechanism"] # "spl_delegate"m["per_period_cap"] # "50.000000" - a decimal string, always 6 placesm["authorized_total"] # "600.000000"m["periods_authorized"] # 12m["periods_consumed"] # 0m["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.
API keys
Section titled “API keys”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 secretsplirin.api_keys.revoke(old_id)Revoking the key you are currently authenticating with locks this client out of subsequent calls.
Idempotency & retries
Section titled “Idempotency & retries”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.
Webhooks
Section titled “Webhooks”from plirin import SignatureVerificationError
try: event = plirin.webhooks.construct_event(raw_body, signature_header, endpoint_secret) # event["event_type"], event["data"]except SignatureVerificationError: ... # respond 400Pass 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.
Errors
Section titled “Errors”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.