Skip to content

TypeScript SDK

The plirin package works in Node 18+, modern browsers, and edge runtimes with zero runtime dependencies.

Terminal window
npm install plirin
import { Plirin } from "plirin";
const plirin = new Plirin(process.env.PLIRIN_API_KEY!);
const invoice = await 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 }],
});
await plirin.invoices.send(invoice.id);
console.log(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.

const plirin = new Plirin(apiKey, { baseURL: "https://api.your-domain.com" });

invoices, creditNotes, customers, estimates, payments, paymentLinks, products, refunds, subscriptions, webhookEndpoints, apiKeys, attachments, statements.

for await (const inv of plirin.invoices.listAll({ limit: 100 })) {
console.log(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.

import { readFile } from "node:fs/promises";
const attachment = await plirin.attachments.upload({
invoiceId: invoice.id, // or estimateId, never both
data: await readFile("scope-of-work.pdf"), // bytes, or a Blob
fileName: "scope-of-work.pdf",
contentType: "application/pdf",
});
await plirin.attachments.update(attachment.id, { shared: false }); // hide it from the hosted page
const files = await plirin.attachments.list({ invoiceId: invoice.id });
const url = await plirin.attachments.downloadUrl(attachment.id); // short-lived signed URL
await 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. downloadUrl returns the redirect’s target instead of following it, so it works server side only. createUpload 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 throws 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.

await plirin.refunds.approve(ref, { approval_notes: "Verified with customer" });
await plirin.refunds.reject(ref, { rejection_reason: "Outside return window" });
await plirin.refunds.complete(ref, { transaction_hash: "3vP7kN2mW9qX5yA1bC8dE4fG6hI0jR" }); // on-chain refunds only
await plirin.refunds.sendReceipt(ref); // emails the receipt PDF to the customer
const pdf = await plirin.refunds.receipt(ref); // Uint8Array of the PDF bytes

sendReceipt 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 call below is a REST route, so any HTTP client can make it.

SDK methodREST routeWhat it does
refunds.listForInvoice(invoiceId)GET /api/v1/invoices/{id}/refundsEvery refund of the invoice, oldest first, and the refundable_sources a new refund can return
refunds.record(invoiceId, params)POST /api/v1/invoices/{id}/refunds/recordRecord a refund of an offline payment. Completed at once, with a settled credit note. Returns 201
refunds.refundOverpayment(invoiceId, params)POST /api/v1/invoices/{id}/refunds/overpaymentReturn money received beyond the invoice total, with no credit note. Returns 201
refunds.voidRecorded(invoiceId, refundId, reason)POST /api/v1/invoices/{id}/refunds/{refund_id}/voidVoid a recorded refund entered by mistake, with its credit note
creditNotes.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 { idempotencyKey }); the list route is a read, and creditNotes.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).

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

const { data: refunds, refundable_sources } = await plirin.refunds.listForInvoice(invoiceId);
// refundable_sources: [
// { rail: "card", id: "…", label: "Card payment of 120.00 USD on 2026-09-30",
// refundable_amount: "120.00", disputed: false },
// { rail: "offline", id: "…", label: "Bank transfer of 80.00 …",
// refundable_amount: "80.00", disputed: 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.

const note = await plirin.creditNotes.create({
invoice_id: invoiceId,
amount: "40.00",
reason: "Item returned",
});
const issued = await plirin.creditNotes.issue(note.id, {
refund_from: { rail: "card", id: refundable_sources[0].id },
});
// The refund is pending. issued.refund_id is its integer id; find it to get its reference:
const { data } = await plirin.refunds.listForInvoice(invoiceId);
const pending = data.find((r) => r.id === issued.refund_id)!;
// Approving it sends it to Stripe:
const refund = await 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.

const refund = await plirin.refunds.record(
invoiceId,
{
payment_id: offlineSource.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",
},
{ idempotencyKey: "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.
await plirin.refunds.voidRecorded(invoiceId, refund.id, "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.

await plirin.refunds.refundOverpayment(invoiceId, {
amount: "20.00",
reason: "Customer paid twice",
refund_from: { rail: "card", id: cardSource.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 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 (null 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 null 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. The SDK throws 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 null to follow the merchant’s default) and provider_payments, the card payments made or attempted against it, newest first (ProviderPayment).

const inv = await plirin.invoices.retrieve(invoice.id);
for (const p of inv.provider_payments ?? []) {
console.log(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 nullWhen it succeeded (ISO 8601), or null
needs_refund_reasonstring or nullvoid_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 null 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.

await plirin.invoices.recordPayment(invoiceId, {
amount: "100.00",
method: "bank_transfer",
reference: "WIRE-4821",
});
await plirin.invoices.removePayment(paymentId, "Recorded against the wrong invoice");
await plirin.invoices.restorePayment(paymentId, "Removed by mistake; the transfer did arrive");
const history = await plirin.invoices.listPayments(invoiceId); // 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.

const draft = await 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" }],
});
const sent = await plirin.estimates.send(draft.id); // assigns the number and hosted_url, emails the customer
await plirin.estimates.update(sent.id, { memo: "Revised scope" }); // a sent estimate gets a new revision
await 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:

const invoice = await plirin.estimates.convert(sent.id);
const pdf = await plirin.estimates.pdf(sent.id); // Uint8Array of the PDF bytes

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 throws a PermissionError 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 an object (amounts are strings)
customers.statementPdf(id, { currency, from, to })The same statement as PDF bytes (Uint8Array)
customers.sendStatement(id, { currency, from, to, cc?, note? }, { idempotencyKey? })Emails it to the customer and resolves with the history record
customers.listStatements(id, { limit?, offset? })The statements sent to a customer, newest first, as { items, total }
statements.pdf(statementId)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. cc takes up to 5 addresses and note up to 500 characters.

const period = { currency: "USD", from: "2026-09-01", to: "2026-09-30" };
const statement = await plirin.customers.statement(customerId, period);
console.log(statement.closing_balance, statement.is_empty); // "1250.00" false
const pdf = await plirin.customers.statementPdf(customerId, period); // Uint8Array
const record = await plirin.customers.sendStatement(
customerId,
{ ...period, cc: ["accounts@acme.example"], note: "Please pay by the 15th." },
{ idempotencyKey: "stmt-17-2026-09" },
);
console.log(record.number, record.status); // "ST-0007" "sent"
const sentCopy = await plirin.statements.pdf(record.id); // what the customer received
const { items, total } = await plirin.customers.listStatements(customerId, { limit: 20 });

sendStatement retries safely with the same idempotencyKey. A statement with nothing to show throws 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 throws 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.listStatements. The SDK retries a 5xx response twice by default, and a failed send is not stored for idempotency, so one sendStatement 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 new Plirin(apiKey, { maxRetries: 0 }) and retry yourself. statements.pdf on a statement that was never sent throws 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 (StatementStatus and 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.

const product = await plirin.products.create({ name: "Pro plan", sku: "PRO-1" });
// Amounts are strings, never numbers: IEEE-754 cannot represent 0.10 exactly,
// and this is money. `amount: 49.00` is a compile error, deliberately.
const price = await plirin.products.createPrice(product.id, {
amount: "49.00",
type: "recurring",
billing_period: "month",
});
await plirin.products.list({ active: true, limit: 20 });
await plirin.products.update(product.id, { name: "Pro plan (annual)" });
await plirin.products.listPrices(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.

await plirin.products.archive(product.id);
await 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.

const sub = await plirin.subscriptions.create({ price_id: 42 });
await plirin.subscriptions.pause(sub.id);
await 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.
await 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 createLink() 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.

const sub = await 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. createLink() 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.

const link = await plirin.subscriptions.createLink({ 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".

const { subscription, proration } = await plirin.subscriptions.changePlan(sub.id, {
price_id: 108,
});
proration?.net; // "+20.00" owed, or negative if the customer is owed
proration?.credit; // unused value of the old plan
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 null.

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:

const m = await 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 null
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".
await plirin.subscriptions.revokeMandate(sub.id);

mandate() throws a 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.

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

revokeMandate() 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.

const created = await plirin.apiKeys.create({ label: "worker-2026-09" });
created.key; // the full secret, returned ONCE and never retrievable again
await plirin.apiKeys.list(); // prefixes and last_used_at, never secrets
await plirin.apiKeys.revoke(oldId);

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

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

Pass the raw request body string:

import { SignatureVerificationError } from "plirin";
try {
const event = await plirin.webhooks.constructEvent(rawBody, signatureHeader, endpointSecret);
// event.event_type, event.data
} catch (err) {
if (err instanceof SignatureVerificationError) {
// respond 400
}
}

constructEvent verifies the X-Plirin-Signature (t=…,v1=…) HMAC-SHA256 scheme with a 5-minute timestamp tolerance by default.

Every failure throws a PlirinError subclass — AuthenticationError, PermissionError, NotFoundError, InvalidRequestError, IdempotencyError, RateLimitError, APIError, APIConnectionError — each with .code, .statusCode, 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, thrown as a PermissionError with that .code. See Verified email required.