Error Codes
Error response format
Section titled “Error response format”GraphQL errors are returned in the standard errors array:
{ "data": null, "errors": [ { "message": "Not authenticated", "path": ["me"], "extensions": { "code": "UNAUTHENTICATED" } } ]}Common error codes
Section titled “Common error codes”| Code | HTTP Status | Description |
|---|---|---|
UNAUTHENTICATED | 200 | Missing or invalid access token |
FORBIDDEN | 200 | Insufficient permissions for this operation |
NOT_FOUND | 200 | Requested resource does not exist |
VALIDATION_ERROR | 200 | Input validation failed — check the message for details |
IDEMPOTENCY_CONFLICT | 200 | Duplicate request with conflicting payload |
KYB_REQUIRED | 200 | Operation requires completed KYB verification |
RATE_LIMITED | 429 | Too many requests — back off and retry |
Attachment codes such as STORAGE_QUOTA_EXCEEDED | 200 | A file upload or confirm was refused. Arrives in details.code: see the Errors table under Attachments |
Verified email required
Section titled “Verified email required”A new account can build everything (drafts, customers, products, settings, test-mode work) before confirming its email address, but anything that reaches someone outside the account, or creates a credential that outlives the session, waits for that confirmation. Until the merchant clicks the link in the welcome email (resend it from the dashboard banner or Settings), these are refused. The refusal applies to the owner, to staff acting for that merchant, and to API keys of that merchant, including keys created before the rule existed.
| Surface | Refused until verified |
|---|---|
| GraphQL | setTestMode(enabled: false), createApiKey, createStaff, createWebhookEndpoint, updateWebhookEndpoint, subscribeToPlan, changePlanAtRenewal, reactivatePlan, setPaymentsAddon(enabled: true), createPlatformCardCheckout, addOrUpdateWallet, sendInvoice, sendInvoiceReminder, batchSendInvoices, sendEstimate, sendPaymentLink, sendRefundReceipt, resendKYBLinkToEmail, startStripeConnect |
| REST | POST /api/v1/invoices/{id}/send, POST /api/v1/invoices/{id}/remind, POST /api/v1/estimates/{id}/send, POST /api/v1/refunds/{reference}/send-receipt, POST and PATCH /api/v1/webhook-endpoints, POST /api/v1/api-keys, POST /api/v1/staff |
Turning things off is never gated: returning to test mode, turning the payments add-on off, deleting webhook endpoints, revoking API keys and cancelling a plan all work unverified. Automatic reminders are not sent for an unverified account.
Over GraphQL the code arrives in details.code:
{ "errors": [ { "message": "Verify your email address to do this. We sent a link to you@business.com; you can resend it from Settings.", "extensions": { "code": "PLIRIN_ERROR", "details": { "code": "EMAIL_NOT_VERIFIED", "action_required": "verify_email" } } } ]}Over REST it is a 403 with error.code email_not_verified (the SDKs raise it as a PermissionError with that .code).
Security check
Section titled “Security check”| Code | Meaning |
|---|---|
TURNSTILE_REQUIRED | A signup, sign-in, password reset or verification resend arrived without a Cloudflare Turnstile token. Complete the check on the form and send again. |
TURNSTILE_FAILED | The token did not pass (expired, already used, or from another form). Refresh the check and send again. |
TURNSTILE_UNAVAILABLE | The check is not configured on the server, so these operations are paused. Nothing to fix on your side. |
API keys never meet these codes.
Accounts and staff
Section titled “Accounts and staff”| Code | Where | Meaning |
|---|---|---|
EMAIL_NOT_ALLOWED | createMerchant, createStaff, REST staff create and email change | The address is at a reserved or disposable email domain, or (for staff) already belongs to a merchant or a Plirin admin. One message for every case: “Please use a work or personal email address you can receive mail at.” |
UNKNOWN_ROLE | createStaff, updateStaff, assignRoleToStaff, REST staff routes | A role other than Manager or Cashier (matched ignoring case). The message names the valid roles. |
A staff member may grant only roles whose permissions they hold themselves, and may not change their own roles; either is refused as a permission error.
Document drafts (dashboard only)
Section titled “Document drafts (dashboard only)”Drafting an invoice from a document is a dashboard feature: its GraphQL operations are not available to API keys. Its codes arrive in details.code on the operation’s error, or in a draft’s failureCode.
| Code | Where | Meaning |
|---|---|---|
DRAFTS_DISABLED | start, worker | Document drafts are off: the platform switch, the configuration, or the merchant’s own AI setting. A queued draft that hits this is failed and refunded without being read. |
DRAFT_PLAN_REQUIRED | start | The plan includes no drafts (Starter and free). |
DRAFT_LIMIT_REACHED | start, retry | This month’s drafts are used up. |
DRAFT_TEMPORARILY_UNAVAILABLE | start, retry | The platform’s daily limit is reached, or it cannot be checked. Nothing is charged. |
DRAFT_TYPE_NOT_ALLOWED | start | Not a PDF, PNG, JPEG, WebP, CSV or text file. |
ATTACHMENT_TOO_LARGE | start | Over 10 MB. |
DRAFT_INPUT_INVALID | start, accept | An empty upload, or an accept form with an unknown field, a non-decimal amount, overlong text or no customer. |
CURRENCY_MISSING | accept | The form has no currency. A draft never defaults one. |
DRAFT_NOT_FOUND | any by id | No such draft for this account in this mode (test or live). |
DRAFT_NOT_READY | accept, dismiss | The draft is not ready to accept, or already accepted, dismissed or expired. |
DRAFT_BUSY | dismiss | The draft is being read right now. Try again shortly. |
DRAFT_NOT_RETRYABLE | retry | Only a failed draft whose document still exists can be retried. |
DRAFT_TOO_MANY_PAGES, DRAFT_UNREADABLE, DRAFT_UPLOAD_MISSING | failureCode | The document has over 10 pages, cannot be read (encrypted or damaged), or never arrived. Refunded. |
DRAFT_MODEL_TIMEOUT, DRAFT_MODEL_ERROR, DRAFT_INVALID_OUTPUT | failureCode | Reading failed after one retry. Refunded; the draft can be retried. |
DRAFT_WORKER_ABANDONED, STORAGE_UNAVAILABLE | failureCode | Reading was interrupted, or storage was unavailable. Refunded. |
Invoice import (dashboard only)
Section titled “Invoice import (dashboard only)”Importing invoices is a dashboard feature: its GraphQL operations are denied to API keys. Its codes arrive in details.code. The full list, by operation, is under Invoice import.
| Code | Meaning |
|---|---|
IMPORT_DISABLED | Invoice import is not switched on for the platform |
EMAIL_NOT_VERIFIED | The account’s email address is not verified |
IMPORT_CAP_EXCEEDED | The file has more valid invoices than the plan still allows |
CHASE_PLAN_REQUIRED | Automatic reminders are on paid plans |
CHASE_NOT_OVERDUE | The import is not yet overdue, so it follows the normal reminder schedule and has no chase to change |
IMPORTED_INVOICE_DISCOUNT_FIXED | A promotion code cannot be applied to, previewed on or cleared from an imported invoice |
Customer statements
Section titled “Customer statements”Statement operations are available to API keys (the read queries and sendCustomerStatement), so their codes are returned on every surface. On GraphQL the code arrives in extensions.code, repeated in extensions.details.code. On REST it is the code field of the error envelope, with the HTTP status below. See Customer statements for the feature.
| Code | REST status | Meaning |
|---|---|---|
FEATURE_NOT_IN_PLAN | 403 | The plan does not include customer statements (Growth and Scale do) |
STATEMENTS_DISABLED | 403 | Customer statements are not switched on for the platform yet. They are being rolled out |
EMAIL_NOT_VERIFIED | 403 | Sending a statement emails your customer, so it needs a verified account email, like every other send. REST returns email_not_verified. Previews and downloads still work |
rate_limited | 429 | More than 60 statement sends in a minute for one account, counted across the dashboard, GraphQL and REST. Wait a minute and retry |
STATEMENT_NO_ACTIVITY | 422 | Nothing to show for that customer, currency and period, so there is nothing to email |
STATEMENT_TOO_LARGE | 422 | The period has more than 1,000 activity lines. Ask for a shorter period |
CUSTOMER_HAS_NO_EMAIL | 422 | The customer has no email address, so a statement cannot be emailed. It can still be downloaded |
STATEMENT_NOT_SENT | 409 | The PDF of a sent statement was requested for one that is still sending or failed, so no copy exists |
EMAIL_SEND_FAILED | 502 | The email provider failed. The statement is kept as failed with its own number, and no email was sent. GraphQL carries the record’s statement_id and number in extensions.details; REST and the SDKs do not, so find the failed record with GET /v1/customers/{id}/statements. Neither GraphQL nor REST retries it, but the SDKs retry a 5xx by default, and each attempt takes the next statement number and leaves its own failed record (see the SDKs) |
A request for a customer or statement that is not yours, or is in the other environment, is a plain 404 (resource_not_found). A malformed currency, date or cc, a to before from, or a note over 500 characters is a 400 (invalid_request).
Common scenarios
Section titled “Common scenarios”Invalid token
Section titled “Invalid token”{"errors": [{"message": "Not authenticated"}]}Fix: Obtain a new access token via the login mutation.
KYB not completed
Section titled “KYB not completed”{"errors": [{"message": "KYB verification required for mainnet operations"}]}Fix: Complete KYB verification before attempting mainnet payments.
Validation error
Section titled “Validation error”{"errors": [{"message": "Customer email is required"}]}Fix: Check required fields in the mutation input and provide all required values.
Rate limit exceeded
Section titled “Rate limit exceeded”HTTP 429 with Retry-After header.
Fix: Wait the specified number of seconds before retrying.
More than one sign-in operation in a request
Section titled “More than one sign-in operation in a request”HTTP 400. A request may name at most one sign-in or account operation (login, staffLogin, merchantLogin, adminLogin, createMerchant, requestPasswordReset, resetPassword, verifyEmail, resendVerificationEmail, refreshToken, changePassword), aliases included, and a JSON array of operations (query batching) is not accepted.
Fix: Send each operation in its own request.