Audit events
Every significant action performed by a merchant, staff member, or the system is recorded to an immutable audit log. You can view the audit log in the dashboard under Settings → Audit Trail, or query it programmatically via the auditLogs GraphQL query.
Log structure
Section titled “Log structure”Each audit log entry contains the following fields:
| Field | Type | Description |
|---|---|---|
actorType | string | Who performed the action: merchant, staff, or system |
actorId | string | ID of the actor (merchant or staff ID) |
action | string | The action identifier (see Actions below) |
targetType | string | Type of the record the action was performed on. A stored target uses the record’s class name, for example Merchant, Staff, Transaction, Customer, MerchantWallet, Product, Price or Subscription. Invoices, estimates, credit notes and refunds are named from details instead and read Invoice, Estimate, CreditNote or Refund. Null when the action is not about one record. See Targets |
targetId | integer | Integer ID of the affected record. Null for invoices, estimates, credit notes and refunds, whose IDs are in details instead |
targetLabel | string | Human-readable name of the affected record, e.g. Invoice INV-0042 · Acme Co (ap@acme.com) |
details | JSON | Additional event-specific metadata |
ipAddress | string | IP address of the request originator (when available) |
source | string | How the action was triggered (see Sources below) |
createdAt | ISO 8601 datetime | When the event was recorded |
Targets
Section titled “Targets”Invoices, estimates and credit notes have UUID IDs, which do not fit the integer targetId, so their audit entries carry the record’s ID in details instead. Plirin reads it from there when you query the log and fills in targetType and targetLabel for you. This applies to every entry, including ones recorded before targets were named.
details key | targetType | targetLabel |
|---|---|---|
credit_note_id | CreditNote | Credit note CN-0003 · Acme Co (ap@acme.com) |
estimate_id | Estimate | Estimate EST-0007 · Acme Co (ap@acme.com) |
refund_reference | Refund | Refund RF-ABC123 · Acme Co (ap@acme.com) |
invoice_id | Invoice | Invoice INV-0042 · Acme Co (ap@acme.com) |
- When an entry carries more than one of these keys, the first in the table wins. A credit-note entry also names its invoice, and an
estimate.convertedentry names the invoice it produced, but both are about the credit note or estimate. - The customer part is the customer’s name and email, or whichever of the two is known, and is left out when neither is. Staff whose role does not include viewing customers see the label without it.
- When the record has since been deleted, the label is built from what the entry recorded at the time, marked
(deleted):Invoice INV-0007 (deleted) · Acme Co (ap@acme.com). An entry too old to have recorded a number falls back to a short ID, such asInvoice 57f8922d. - Only your own records are named. Test-mode and live records are both included, since the audit log itself is not split by mode.
- The merchant
auditLogsquery has no target filter. Plirin’s internal support tooling can filter the audit log by target type and ID, but only on stored targets, so it cannot find a target derived this way.
Record fields in details
Section titled “Record fields in details”So that an entry still says what it was about after the record is gone, entries about one of these records include its number and customer alongside its ID. They are added to the event-specific fields described under each action below, never in place of them.
| Entries about | details fields |
|---|---|
| An invoice | invoice_id, invoice_number, customer_name, customer_email |
| An estimate | estimate_id, estimate_number, customer_name, customer_email |
| A credit note | credit_note_id, credit_note_number, invoice_id, invoice_number, customer_name, customer_email (the customer of the credited invoice) |
customer_name and customer_email are the values on the invoice or estimate, or its linked customer’s when those are empty, and are null when neither exists.
Sources
Section titled “Sources”| Source | Description |
|---|---|
dashboard | Action was taken via the Plirin web dashboard |
sdk | Action was triggered via the Plirin API or SDK |
system | Action was performed by Plirin’s internal automation (e.g. scheduled jobs, webhooks processing) |
webhook | Action was triggered by an inbound webhook from a third-party provider |
Actions
Section titled “Actions”Analytics
Section titled “Analytics”| Action | Description |
|---|---|
analytics.exported | An analytics report was exported. details includes {family, timeframe, row_count, format}. |
API Keys
Section titled “API Keys”| Action | Description |
|---|---|
api_key.created | A new API key was created |
api_key.revoked | An API key was revoked |
Auth & Security
Section titled “Auth & Security”| Action | Description |
|---|---|
auth_rate_limit_exceeded | Too many authentication attempts triggered a rate-limit block |
auth_lockout | Five failed sign-ins for one email address from one network (an IPv4 /24 or IPv6 /64) within 15 minutes refuse that address from that network for 15 minutes; 50 from any networks refuse it everywhere. details.scope says which (network or global); details.email_hash identifies the address without storing it. A wrong current password when changing the login email counts as a failed sign-in for the account’s current address (details.surface is emailChange) |
auth_unlock | Plirin operations lifted every sign-in lock on an address. details hold the address hash and the operator |
admin_login_failed | A platform admin sign-in attempt failed (Plirin staff only, never shown to merchants) |
admin_login_success | A platform admin signed in: a session was issued, after the two-factor code (details.stage mfa or enrollment) or on the password alone where two-factor is not required (stage password). The admin’s last sign-in time is set at the same moment (Plirin staff only) |
admin_password_ok | A platform admin’s password was accepted. Not a sign-in: details.next names the step still to come (mfa or enrollment, or none when the password alone signs in). A security alert is sent when no sign-in follows within 10 minutes (Plirin staff only) |
admin_logout | A platform admin signed out, which ends every session of theirs on the server (Plirin staff only) |
admin_mfa_enrolled | A platform admin set up two-factor sign-in with an authenticator app; every other session of theirs ended (Plirin staff only) |
admin_mfa_enrollment_code_issued | A single-use two-factor enrollment code was issued for a platform admin: by an owner creating the admin or resetting their two-factor (details.issued_by), or by Plirin operations on the server (source cli, details.operator). Setting up two-factor needs this code as well as the password; it expires after 24 hours. details.via names the path; the code itself is never recorded (Plirin staff only) |
admin_mfa_failed | A platform admin entered a wrong two-factor or recovery code. details.stage says where: login, step_up, enrollment, enrollment_code (a wrong enrollment code) or regenerate. A right code for a step already used (the code that just signed the admin in, offered again) is refused as “just used” and is not recorded here (Plirin staff only) |
admin_mfa_locked | Five wrong two-factor codes for one platform admin, counted per admin across sign-ins and networks, paused their two-factor for details.lock_seconds (15 minutes, doubling on each repeat up to 24 hours). A correct password does not reset the count (Plirin staff only) |
admin_mfa_recovery_code_used | A platform admin used one of their single-use recovery codes instead of an authenticator code. details.remaining is how many are left (Plirin staff only) |
admin_mfa_recovery_codes_regenerated | A platform admin replaced their recovery codes; the old ones stopped working (Plirin staff only) |
admin_mfa_reset | A platform admin’s two-factor sign-in was removed, by an owner or by Plirin operations on the server (source cli); they set it up again at their next sign-in, with the new enrollment code issued alongside (admin_mfa_enrollment_code_issued) (Plirin staff only) |
admin_step_up | A platform admin confirmed a two-factor code to unlock sensitive admin actions for five minutes (Plirin staff only) |
turnstile_failed | A dashboard signup, sign-in, password reset or verification resend was refused by the Cloudflare Turnstile check. details hold the form (turnstile_action) and the reason, never the token |
merchant_login_failed | A merchant login attempt failed |
merchant_login_success | A merchant successfully logged in |
staff_login_failed | A staff login attempt failed |
staff_login_success | A staff member successfully logged in |
staff_token_refresh | A staff session token was refreshed |
password_reset_failed | A password reset attempt failed |
password_reset_requested | A password reset was requested |
password_reset_success | A password was successfully reset |
email_verification_sent | A verification email was sent to the account’s address |
email_verified | The account’s email address was confirmed |
email_verification_failed | A verification link was invalid or had expired |
Idempotency
Section titled “Idempotency”| Action | Description |
|---|---|
idempotency.conflict | A request arrived with an idempotency key that conflicts with a prior request |
idempotency.replay | A duplicate request was detected and the cached response was replayed |
Customers
Section titled “Customers”| Action | Description |
|---|---|
customer.created | A new customer record was created |
customer.updated | A customer record was updated |
customer_upserted | A customer was created or updated via upsert |
customer.csv_exported | Customer data was exported as a CSV |
customer.csv_imported | Customers were imported from a CSV file |
customer.late_fees_exempted | A customer’s “No late fees” switch changed, from the dashboard or a CSV import; details.exempt is the new value and details.previous the old one; a dashboard change that also removed unpaid fees adds removed_fee_count, removed_fee_ids and kept_paid (fees already paid are never touched) |
customer.statements_opted_out | A customer was opted out of scheduled statements. Recorded only when the setting changes. A statement can still be sent to them by hand. The target is the customer; details is {name, email} |
customer.statements_opted_in | A customer was opted back in to scheduled statements. Recorded only when the setting changes. The target is the customer; details is {name, email} |
Credit notes
Section titled “Credit notes”| Action | Description |
|---|---|
credit_note.created | A credit note was created against an invoice; details.amount is the credit amount and details.origin says why it exists: manual (a draft you created, which has no financial effect until it is issued), refund_in_plirin (made by a refund you recorded in Plirin), refund_in_stripe (made when Plirin picked up a refund from the Stripe dashboard) or chargeback (made by a lost card dispute) |
credit_note.issued | A credit note was issued. details.path says how: paid_refund (the invoice was paid, so the credit is returned as an on-chain refund), paid_card_refund (returned to the card through Stripe, once approved) or paid_recorded_refund (a refund you paid outside Plirin, recorded); each links its refund in details.refund_reference. unpaid_balance_reduction means the credit reduced what is still owed (details.new_remaining is the new balance) |
credit_note.settled | An issued credit note finished taking effect. details.path is unpaid_balance_reduction (immediately), refund_completed (its on-chain refund completed), card_refund_completed (Stripe confirmed its card refund) or recorded_refund (a recorded refund, settled as it is recorded) |
credit_note.voided | A credit note was voided. When a refund took it down, details.path says which: refund_rejected (the refund request was rejected), card_refund_failed (Stripe failed the card refund; details.reason is the failure code) or recorded_refund_voided (the recorded refund was voided; details.reason is the merchant’s reason) |
Estimates
Section titled “Estimates”| Action | Description |
|---|---|
estimate.created | A new estimate (quote) was created as a draft |
estimate.updated | An estimate was edited; details.revision says whether this was a draft edit (revision 1) or a new revision of a sent estimate |
estimate.sent | An estimate was sent to the customer, on first send or a revision resend; details.revision says which |
estimate.voided | An estimate was voided |
estimate.expired | A sent estimate passed the end of its expiry date on the merchant’s calendar and was expired automatically; attributed to the merchant with source system |
estimate.accepted | The customer accepted the estimate on the hosted page; details.signer_name carries the typed signature name |
estimate.declined | The customer declined the estimate on the hosted page; details.reason carries their optional reason |
estimate.converted | An accepted estimate was converted to a draft invoice; details.invoice_id links it, and details.converted_by_merchant marks a dashboard “Convert now” |
estimate.hosted_page_viewed | The hosted estimate page was opened, logged at most once per visitor per hour |
AI drafts
Section titled “AI drafts”| Action | Description |
|---|---|
agent.proposal_created | A document was uploaded to draft an invoice from; details carry the proposal_id, the attachment_id, its content_type and size_bytes |
agent.proposal_accepted | The merchant reviewed a draft and created an invoice from it; details carry the proposal_id and the invoice_id. The invoice’s own invoice.created entry carries source: document_draft, the proposal_id, the model and the prompt_version |
agent.proposal_dismissed | The merchant dismissed a draft and its document was deleted; details.refunded is true when the draft was dismissed before it was read, which returns it to the monthly allowance |
agent.proposal_retried | The merchant retried a failed draft, which uses one draft from the monthly allowance |
Invoices
Section titled “Invoices”| Action | Description |
|---|---|
invoice.created | A new invoice was created. An imported invoice’s entry carries source: import, import_id, kind and original_number. An invoice created already waived (lateFeesWaived: true on GraphQL createInvoice, or late_fees_waived: true on the REST create route) carries late_fees_waived: true; no separate invoice.late_fees_waived entry is written for it |
invoice.updated | An invoice was updated |
invoice.sent | An invoice was sent to the customer |
invoice.paid | An invoice was marked as paid |
invoice.voided | An invoice was voided |
invoice.attachment_added | A file was attached to an invoice or estimate |
invoice.attachment_deleted | An attachment was removed |
invoice.attachment_sharing_changed | An attachment was shared with, or hidden from, the customer |
invoice.late_fee_applied | An automatic late fee was charged as a new linked invoice; details carry the original invoice_id, the fee_invoice_id, the fee sequence and amount; attributed to the merchant with source system |
invoice.late_fee_skipped | An automatic late fee was due but not charged; details.reason is policy_off, plan, customer_exempt, payment_in_flight, below_minimum or processing_error (the fee could not be processed and is retried the next day), and details.cleared says whether the schedule stopped for good or moved one day; attributed to the merchant with source system |
invoice.late_fee_removed | A merchant or staff member removed a charged late fee: the fee invoice was voided and its ledger line kept as removed (it still counts towards the invoice’s fee cap); details carry the original invoice_id, the fee_invoice_id, the fee sequence, amount and the reason (or null); source dashboard |
invoice.late_fees_waived | A merchant or staff member turned “No late fees on this invoice” on or off for a draft; only a real change writes a row, and an invoice created already waived is recorded on its invoice.created entry instead. details carry invoice_id, invoice_number, late_fees_waived (the new value) and previous; source dashboard |
invoice.duplicated | An invoice was duplicated |
invoice.partial_payment_received | A partial payment was received against an invoice |
invoice.reminder_sent | A payment reminder was manually sent |
invoice.reminders_changed | Invoice reminder settings were changed |
invoice.auto_reminder_sent | An automatic payment reminder was sent |
invoice.email_failed | An invoice or reminder email could not be delivered; kind says which, error gives the mail server’s reason |
invoice.auto_overdue | An invoice was automatically marked as overdue |
invoice.batch_sent | A batch of invoices was sent |
invoice.batch_voided | A batch of invoices was voided |
invoice.import_committed | Open invoices were imported from a CSV or PDF. Details: import_id, kind, file_name, created, skipped, refused, chase_mode |
invoice.import_voided | ”Void this import” voided the import’s untouched invoices. Details: import_id, voided, skipped |
invoice.import_chase_changed | The import chase of one invoice was stopped, rescheduled or restarted. Details: invoice_id, change, next_at, every_days |
invoice.branding_updated | Invoice branding settings were updated |
invoice.csv_exported | Invoice data was exported as a CSV |
reconciliation.csv_exported | On-chain settlement was exported for reconciliation against the ledger. Details record how many rows were unreconciled, since that is the number a support conversation usually starts from. |
invoice.hosted_page_viewed | A customer viewed the hosted invoice payment page |
invoice.milestone_created | A payment milestone was added to an invoice |
invoice.milestone_paid | A payment milestone was marked as paid |
invoice.milestones_removed | Milestones were removed from an invoice |
invoice.prefix_changed | The invoice number prefix was changed |
invoice.recurring_created | A recurring invoice schedule was created, or an existing one was saved again (a save is a full write of the schedule, so this row is the whole of it). details carries invoice_id, period, next_date (the first send date), lead_days (how many days before each send date the copy is created), carry (the resolved choice of what each copy carries, null for an exact copy) and carry_forced_on (the groups asked off and turned back on, empty when the server changed nothing) |
invoice.recurring_cancelled | A recurring invoice schedule was stopped on purpose (the Recurring page’s Stop button, cancelInvoiceRecurring, or the REST delete route). The copy already sitting in its review window is disarmed and kept as a plain draft, so it is not sent. Voiding the invoice behind a schedule stops it the same way but writes invoice.voided, not this row |
invoice.recurring_blocked | A recurring schedule stopped generating because the copy settings saved on it are ones this business may no longer use (most often: tax was set not to carry, and the business has since registered for VAT). Written by the system once per invoice, ever, not once per attempt: the row is what stops the five-minute retry from writing a row every five minutes, so it is never written a second time for the same invoice even after the schedule is fixed and later blocked again. Saving the schedule again is what unblocks generation; it does not remove or supersede this row, so read the row as “this invoice was blocked at least once”, and read invoice.recurring_generated to see whether it has generated since. details carries invoice_id, invoice_number, code, reason and the stored carry |
invoice.recurring_generated | A new invoice was generated from a recurring schedule, as a draft armed to send on send_date. details carries source_invoice_id, new_invoice_id, new_invoice_number, period, send_date (when this copy goes out), next_date (the slot after it), and what the schedule said to do: lead_days, carry (null for an exact copy) and anchor_day. When this schedule has drafts that are currently DISARMED (no scheduled_send_at), details.superseded_invoice_ids lists them, most recently created first, up to 20; the key is absent when there are none. Read it as a state and not an event: a disarmed draft is listed again on every later generation, and the row does not establish WHY it is disarmed. A stop or a void disarms the copy in the review window, and cancelScheduledSend disarms one by hand, and the two are indistinguishable on the row. Informational, not a list to act on destructively: every draft in it is one the merchant was deliberately left to send or delete themselves |
invoice.recurring_paused | A recurring schedule was paused because one of its copies ran out of send attempts. Written by the scheduled-send worker when it gives up on that copy, once per pause: while a schedule is already paused a second copy of it giving up writes nothing, and a new row is only possible after the merchant has set the schedule up again. details.reason is send_failed, and details also carries source_invoice_id (the schedule), invoice_id and invoice_number (the copy that failed) and attempts. A paused schedule stays listed on the Recurring page, showing as paused, and generates nothing until it is set up again. Copies that pile up unsent never pause a schedule: a hold writes invoice.recurring_held and generation continues |
invoice.recurring_held | A generated copy was not sent automatically. The schedule is unaffected and still generating, and the copy is still a draft you can send yourself at any time. details.reason is plan (your plan does not include sending recurring invoices automatically) or outside_paid_period (its send date is earlier than the subscription period you are currently paying for, so releasing it would send a backlog at once). A copy for a day that had already passed is not held at all: it is never given a send date, so it is an ordinary draft and this event does not apply to it. Written once per invoice per reason, not once per attempt, so a merchant who upgrades and is still held gets a second row with the second reason. details also carries invoice_id, invoice_number, source_invoice_id and scheduled_send_at. Three further reason values exist defensively, not_armed, no_merchant and no_period, and none is reachable through the worker’s own query: treat any of them appearing as a fault to report rather than something to act on |
invoice.due_date_reanchored | A recurring copy went out on a different day from the one it was generated for, so its due date was re-measured from the day it actually went out, keeping the same payment terms. details carries invoice_id, invoice_number, source_invoice_id, net_days (the terms preserved), and two kinds of date that are not interchangeable: planned_send_day, actual_send_day, due_day_before and due_day_after are days on the merchant’s own calendar, which is what you read to show a date, while due_date_before and due_date_after are the stored instants, which encode the end of the due day and so fall on the next UTC date for a merchant west of UTC. Read the instants only to reconcile against the invoice’s own due_date. Nothing is written when a copy goes out on its planned day, which is the normal case |
invoice.payment_recorded | An offline payment (bank transfer, cash, card, check, other) was recorded against an invoice |
invoice.payment_removed | An offline payment was removed from an invoice |
invoice.payment_restored | A previously-removed offline payment was restored |
invoice.payment_correction_notice_failed | The owner’s email about a staff member removing or restoring a payment failed twice; written by the system so support can tell the owner |
invoice.refund_recorded | A refund the merchant made outside Plirin was recorded against an offline payment, so no money moved through Plirin. details carries refund_reference, payment_id, amount, method, reference, refunded_at and, when there is one, credit_note_id; details.overpayment is true when it returned an overpayment (no credit note) |
invoice.refund_recorded_voided | A recorded refund was voided, with its credit note; details.reason says why |
late_fee_policy.updated | The automatic late-fee policy was saved; details.before is the previous policy (null the first time) and details.after the new one |
Customer statements
Section titled “Customer statements”| Action | Description |
|---|---|
statement.emailed | A customer statement was emailed on request, from the dashboard or the API. The actor is the owner, the staff member or the API key (shown as “API key”). The target is the customer; details includes {number, period_start, period_end, currency, closing_balance, recipient_count, trigger} |
statement.scheduled_sent | A scheduled customer statement was emailed. Recorded as a system action, shown as “Plirin (scheduled)”. The target is the customer; details has the same fields as statement.emailed |
statement.send_failed | A statement email could not be delivered. The target is the customer; details includes {number, period_start, period_end, currency, trigger, reason, attempt}, where attempt is the record’s attempt count (1 for the first try, 2 for the retry; a scheduled record stuck mid-send and closed without a retry keeps the count it had). A row is written on every failed attempt. Recorded as the person or API key that sent it for an on-demand send, and as a system action for a scheduled one. A failed on-demand send keeps its number, and sending again takes the next one; a scheduled send is retried once, on the same record |
statement.downloaded | A live statement PDF was downloaded for a period, from the dashboard or the API. The actor is the owner, the staff member or the API key. Re-downloading a statement that was already sent is not recorded. The target is the customer; details includes {period_start, period_end, currency, closing_balance} |
statement.allowance_reached | The month’s scheduled-statement allowance ran out; the remaining customers were skipped until next month. Recorded as a system action, at most once per merchant per month (the merchant’s own calendar month). The target is the merchant; details is {month, allowance}, for example {"month": "2026-10", "allowance": 200} |
statement.settings_changed | The scheduled-statement settings changed. Recorded only when a value actually changed. The target is the merchant; details is {before, after}, each {mode, frequency, day_of_month, weekday, recipients, default_note, scheduled_since}. scheduled_since is the merchant-local date (YYYY-MM-DD) the current schedule started or last moved, or null before a schedule has been saved |
KYB & AML
Section titled “KYB & AML”Legacy actions
Section titled “Legacy actions”| Action | Description |
|---|---|
kyb_initiated | KYB verification was initiated |
kyb_submitted | KYB documents and information were submitted |
kyb_approved | KYB verification was approved |
kyb_rejected | KYB verification was rejected |
kyb_requires_resubmission | KYB was flagged as requiring resubmission |
kyb_status_refreshed | KYB status was refreshed from the provider |
kyb_reconciliation_fixed | A KYB reconciliation discrepancy was corrected |
kyb_perpetual_event_deferred | A KYB perpetual monitoring event was deferred |
kyb_webhook_invalid_sig | An inbound KYB webhook had an invalid signature |
kyb_webhook_parse_failed | An inbound KYB webhook payload failed to parse |
kyb_webhook_unknown_event_kind | An inbound KYB webhook contained an unrecognised event type |
kyb_webhook_unknown_merchant | An inbound KYB webhook referenced an unknown merchant |
Detailed KYB/AML actions
Section titled “Detailed KYB/AML actions”| Action | Description |
|---|---|
kyb.aml.hard_hit | An AML hard-hit check was performed |
kyb.aml.review_decision | A manual AML review decision was recorded |
kyb.aml.screened | A subject was screened against AML lists |
kyb.approval.held | A verification approval was withheld because AML or wallet-screening evidence was missing or adverse |
kyb.aml.rescreen_requested | An operator cleared recorded AML verdicts so the merchant is screened again; the previous result is kept in the event details |
kyb.consent.given | The merchant gave consent for KYB processing |
kyb.docs.archived | KYB documents were archived |
kyb.docs.backup_failed | A KYB document backup attempt failed |
kyb.docs.backup_started | A KYB document backup was started |
kyb.gdpr.erasure_executed | A GDPR erasure request was executed for KYB data |
kyb.link.refreshed | A KYB verification link was refreshed |
kyb.provider.request | A request was sent to the KYB provider |
kyb.provider.response | A response was received from the KYB provider |
kyb.reconciliation.tick | A scheduled KYB reconciliation check ran |
kyb.tier.fully_verified | The merchant reached full KYB verification tier |
kyb.tier.upgrade_failed | A KYB tier upgrade attempt failed |
kyb.tier.upgrade_started | A KYB tier upgrade was initiated |
kyb.tier.verified | A KYB tier verification completed successfully |
kyb.vendor.outage | A KYB vendor outage was detected |
kyb.wallet.sanctioned | A wallet was found on a sanctions list during KYB screening |
kyb.wallet.screened | A wallet was screened during KYB processing |
Merchant
Section titled “Merchant”| Action | Description |
|---|---|
merchant_created | A new merchant account was created |
merchant_profile_updated | The merchant’s business profile was updated |
merchant_email_changed | The account’s login email was changed, after the current password was confirmed. Verification starts again for the new address, existing sessions end, and the old address is told. details hold from and to |
merchant_mainnet_enabled | Mainnet (live) mode was enabled for the merchant |
merchant_mainnet_disabled | Mainnet (live) mode was disabled for the merchant |
merchant.test_mode.enabled | The dashboard was switched to test mode. details includes {merchant_id, from_mode, to_mode}. Only actual transitions are recorded, so re-selecting the mode already in use writes nothing. |
merchant.test_mode.disabled | The dashboard was switched to live mode, meaning mainnet payments are no longer refused. Requires an approved KYB verification. details includes {merchant_id, from_mode, to_mode}. |
merchant.currency_changed | The account’s default invoicing currency was changed. details includes {field, old_value, new_value}. Written only on an actual change, and in addition to the generic merchant_profile_updated entry, because every invoice issued afterwards is denominated differently — which is the question a reconciliation asks months later. Supported currencies are USD, EUR, GBP, AUD, CAD, CHF, NZD and SGD; only USD and EUR can be settled on-chain. |
merchant.ai_features_changed | The account owner turned AI features (drafting invoices from documents) on or off. details carry before and after |
merchant.tax_settings_changed | A setting that changes what future invoices call their tax, the registration number they print, or the rate that taxes them: business country, flat tax rate, tax ID, tax name or tax ID label override, or default tax behaviour. details includes changes (a list of {field, old_value, new_value}, one per field that actually changed) and the resolved tax name and ID label before and after. Written in addition to merchant_profile_updated, and only when at least one of these fields changed. Invoices already issued keep the wording they were issued with. |
tax_rate.created | A tax rate (for example “Standard 19%”) was added. details includes {vat_rate_id, name, rate, kind, is_default, exemption_note}. rate is a decimal fraction as a string ("0.19000"). |
tax_rate.updated | A tax rate was edited. details includes vat_rate_id and changes, a list of {field, old_value, new_value} for the fields that actually changed. Saving a rate unchanged writes nothing. Invoices already issued keep the rate they were issued at. |
tax_rate.deactivated | A tax rate was retired. It stops appearing for new invoices but is kept for the invoices that used it. details includes {vat_rate_id, name, rate}. |
tax_rate.default_changed | A different tax rate became the default for new invoice lines. details includes {old_default_id, new_default_id}; old_default_id is null when there was no default. Re-selecting the current default writes nothing. |
merchant.track_selected | The merchant selected an onboarding track (e.g. invoicing). details includes {track, plan_code}. Deprecated: new accounts start on invoicing and enable payments through the add-on below, so this is only written by older clients. |
merchant.payments_addon_enabled | The stablecoin payments add-on was turned on, moving the account to a plan that grants payments. details includes {from_plan_code, to_plan_code, enabled}. Owner-only, and denied to API keys. Nothing is written when payments is already on, so one entry always means one real change. |
merchant.payments_addon_disabled | The stablecoin payments add-on was turned off, returning the account to its invoicing plan. Invoicing features are unaffected. details includes {from_plan_code, to_plan_code, enabled}. |
merchant_wallet_added | A wallet address was added to the merchant account |
merchant_wallet_removed | A wallet address was removed from the merchant account |
merchant_wallet_updated | A wallet address on the merchant account was updated |
Network Inbox
Section titled “Network Inbox”| Action | Description |
|---|---|
network.sender_accepted | A merchant accepted a sender in their bills inbox, so that sender’s bills (including earlier ones) now appear there. Written when the member presses Accept on a pending sender, presses Unblock on a blocked sender in Manage senders, and when a signed-in member saves a bill to their inbox from its page. details includes {sender_merchant_id, previous_status, status}. status is accepted; previous_status is what the decision replaced: null for a first decision, blocked when the member had blocked this sender and an unblock (from Manage senders, or saving a bill) lifted the block, or accepted on a repeat. A repeat writes another entry but does not change the stored decision date. |
network.sender_blocked | A merchant blocked a sender in their bills inbox, from a pending card or from Manage senders. The sender’s bills stay hidden and the sender is not told. details includes {sender_merchant_id, previous_status, status}, where status is blocked and previous_status is what the decision replaced (null, accepted, or blocked on a repeat). |
merchant.signed_up_from_bill | A merchant account was created from the sign-up link emailed to a bill’s address (from the bill page’s Save to a free Plirin inbox). The account is marked email-verified at once and the bill’s sender is accepted, as part of the same step. No separate network.sender_accepted entry is written for that sender. details includes {jti, invoice_id, sender_merchant_id}: the jti identifies the single-use sign-up token that was redeemed, invoice_id the bill it was issued for. The token itself is never recorded. |
Viewing the inbox, opening a bill and the new-bill check are not audit events: they write nothing.
Payments
Section titled “Payments”| Action | Description |
|---|---|
payment_created | A new payment record was created |
payment_confirmed | A payment was confirmed on-chain |
payment_expired | A payment expired without being completed |
payment_reverted | A payment that had been confirmed was demoted because the chain contradicted it — the on-chain re-check reported that the money never moved. details includes {transaction_id, reference, previous_status, new_status, evidence, revenue_corrections_written}. Recorded with actorType: system: no human triggers this, and it is the only record of why a settled payment stopped being settled. Fires only on a positive contradiction from the chain, never on an unreachable RPC |
payment_transaction_served | A customer’s wallet was handed a signable transaction for this payment. details includes {payer_wallet, amount, merchant_amount, fee_amount, fee_recipient}. Recorded with actorType: system, since the payer is not a Plirin account |
payment_fee_waived | A payment was served with no platform fee collected despite a non-zero pricing tier. Currently reachable only on EVM, where a wallet that reports it cannot execute an atomic call batch gets a single, full-amount transfer instead of a two-call batch that could land half-executed. details includes {payer_wallet, reason, atomic_capability, capability_source} — reason matches the transaction’s feeWaiverReason, and capability_source: "client_asserted" marks atomic_capability as a claim reported by the customer’s wallet, not something Plirin verified. Recorded with actorType: system, since the payer is not a Plirin account |
test_payment.simulated | A test-mode payment was settled by the simulateTestPayment mutation, with no money moving. Every simulated payment is audited: a first-class event, not a backdoor. details includes {reference, amount, transaction_id}, plus the invoice fields listed under Record fields in details when the payment settled an invoice. Only reachable in test mode, against a testnet transaction the merchant owns that is still pending; the settled transaction is permanently marked simulated: true |
refund.requested | A refund was requested for a payment. When it was requested from an invoice, details.rail is stablecoin or card (a recorded refund is complete as it is recorded, so it is audited as invoice.refund_recorded instead) and details.path is credit_note (it returns invoice value, with a credit note) or overpayment (it returns money beyond the invoice total, with none) |
refund.approved | A refund request was approved |
refund.rejected | A refund request was rejected |
refund.completed | A refund was completed: on-chain, at Stripe for a card refund, or recorded. A card refund’s details carry rail and provider_refund_id (Stripe’s refund ID) and its credit_note_id |
refund.failed | A card refund failed at Stripe, at once or after first succeeding, or the sweep found a refund with an unknown outcome was never made while the same money had since been refunded in Stripe (CREDIT_OVER_COMMITTED), or an operator gave up on one Stripe never made after repeated attempts (STRIPE_NEVER_CONFIRMED, source admin): the refund is failed, its credit note void and the balance restored (failure_code in the details) |
refund.recorded_from_stripe | A refund made in the merchant’s Stripe dashboard was recorded from Stripe’s webhook, with its credit note (origin refund_in_stripe) when it returned invoice value |
refund.stripe_credit_shortfall | A refund made in Stripe did not fit the invoice’s credit notes. It is recorded in full and credited up to what settled credit leaves, so the invoice’s balance is unchanged by it (a paid invoice still owes nothing). The details give credited, over_committed (unsettled credit notes now exceed what is left to credit; the merchant is emailed to void or reject the one describing the same money) and uncredited (the part beyond what the invoice still claimed, with no credit note) |
refund.receipt_sent | A refund receipt was emailed to the customer |
transaction.issue.reported | A merchant reported a problem with one of their own transactions from the dashboard. details includes {reference, category, description, transaction_status, amount}, where category is one of payment_not_received, wrong_amount, duplicate_charge, customer_dispute, other. This row is written before the notification email to support is attempted, and is committed regardless of whether that email succeeds — so a report survives an SMTP outage or a spam filter, and support can still find it here. Seeing this action with no corresponding support thread means the email was lost, not the report |
support.message.sent | A merchant sent a message from the dashboard’s Support page. details includes {subject, message, reply_to, stated_contact}. Same durability contract as transaction.issue.reported: the row is committed before the email is attempted, so it is the record of record. reply_to is always the authenticated actor’s address, never a value from the request body, so support cannot be directed to reply to an address the sender does not control. Staff get their own address here rather than the account owner’s. stated_contact holds an alternative address supplied on the form, if any: it is recorded and shown to support marked unverified, and never replaces reply_to. Note that details retains the full message text, so this row falls under the same handling as any other customer content |
Prices & Products
Section titled “Prices & Products”| Action | Description |
|---|---|
price_created | A new price was created |
price_updated | A price was updated |
price_deleted | A price was deleted |
pricing_synced | Pricing was synced across products |
product_created | A new product was created |
product_updated | A product was updated |
product_deleted | A product was deleted |
product.created_from_invoice | A product was automatically created from an invoice line item |
product.csv_exported | Product data was exported as a CSV |
product.csv_imported | Products were imported from a CSV file |
product.inventory_enabled | Inventory tracking was enabled for a product |
product.inventory_disabled | Inventory tracking was disabled for a product |
product.stock_adjusted | A product’s stock level was manually adjusted |
product_image.uploaded | An image was uploaded for a product |
product_image.deleted | An image was deleted from a product |
product_image.reordered | Product images were reordered |
Platform Billing
Section titled “Platform Billing”| Action | Description |
|---|---|
platform.subscription.created | A Plirin platform subscription was created |
platform.subscription.updated | A Plirin platform subscription was updated |
platform.subscription.canceled | A Plirin platform subscription was canceled |
platform.subscription.downgraded | A Plirin platform subscription was downgraded |
platform.invoice.created | A Plirin platform billing invoice was created |
platform.invoice.paid | A Plirin platform billing invoice was paid |
platform.payment.duplicate_received | A verified second stablecoin payment arrived for a Plirin platform billing invoice that was already paid. It is not credited automatically; support refunds or credits it. Details carry the transaction hash, verified amount and paying wallet |
platform.payment.rejected | A stablecoin payment for a Plirin platform billing invoice was refused (not found on-chain, wrong recipient, token or amount, credited to a token account other than Plirin’s payment account, transaction already used, or invoice already paid). The invoice stays unpaid; details carry the reason and the submitted transaction hash |
platform.payment.after_void | A successful card payment arrived for a Plirin platform billing invoice that is void or uncollectible. Nothing is settled; support refunds it by hand. details hold the merchant, the invoice and its status, the amount and currency, the provider and its payment reference, never card data (Plirin staff only) |
platform.invoice.voided | A Plirin platform billing invoice was voided by a Plirin owner (Plirin staff only, never shown to merchants). details hold the reason, the previous_status (open or paid) and, when a paid invoice’s revenue was reversed, reversed_entry_id and revenue_reversal_entry_id. open_card_checkout is true when an open invoice had a card checkout started (it may still be payable at the provider), and credit_applied is the credit the invoice spent (its whole amount when it was settled from credit) or unknown. The merchant’s plan and credit are not changed by a void |
platform.invoice.uncollectible | A Plirin platform billing invoice was marked uncollectible |
platform.billing.checkout.created | A card checkout was opened for a platform billing invoice |
platform.billing.webhook.invalid_sig | A payment webhook arrived with an invalid signature and was rejected |
platform.billing.unattributed | A card payment succeeded but could not be matched to an invoice — needs manual review |
platform.plan.overridden | A Plirin platform plan was manually overridden |
platform.credit.applied | Credits were applied to a Plirin platform account |
platform.grace.extended | The grace period was extended for a Plirin platform account |
Subscriptions
Section titled “Subscriptions”| Action | Description |
|---|---|
subscription_created | A merchant subscription was created, either billing immediately (createSubscription) or as a checkout link a customer completes themselves (createSubscriptionLink, details carry checkout_slug) |
subscription_updated | A merchant subscription was updated |
subscription_canceled | A merchant subscription was canceled |
subscription_paused | A merchant subscription was paused |
subscription_resumed | A paused or on-hold subscription was resumed |
subscription_renewed | A subscription was renewed for a new billing period |
subscription_plan_changed | A subscription moved to a different price mid-cycle. Details carry the previous and new price, the proration behaviour, and the credit, charge and net the change produced |
subscription_past_due | A subscription entered past-due status |
subscription_activated | A subscription moved to active: either a trialing subscription’s trial ended and its first invoice was collected, or a checkout link’s connecting wallet cleared screening and the subscription went live as trialing or active |
subscription_on_hold | A subscription exhausted its dunning retries and stopped collecting |
subscription_charge_failed | A subscription’s billing attempt for a period failed, whatever the cause: the prior period’s invoice went unpaid past its due date, minting or sending failed, or an on-chain collection attempt failed. The resulting status (past_due or on_hold) is recorded in the entry’s own details rather than as a separate action |
subscription_collection_stuck | An automatic collection attempt’s outcome could not be determined (no broadcast signature was ever recorded, or the chain lookup itself kept failing) and requires operator reconciliation; the subscription is not dunned for this |
subscription_keeper_gas_low | The subscription keeper’s native gas balance fell below its configured warning floor on a chain |
subscription_keeper_unavailable | The subscription keeper could not load a signing identity (absent or malformed key), so every automatic subscription in that mode degraded to send_invoice for the tick |
subscription_mandate_insufficient | A mandate could not collect a period automatically: either its real charge (converted into the mandate’s collection token) exceeded the mandate’s authorized per-period cap, or the merchant’s receiving wallet changed since the mandate’s last confirmed collection. Either way that period is degraded to a sent invoice instead of failing or cancelling; details.reason names which |
subscription_mandate_authorized | A pending mandate was confirmed on chain and promoted to active, either from the customer’s initial authorization or a re-authorization that supersedes an existing one |
subscription_mandate_revoked | A mandate can no longer collect: the customer revoked it on chain (or another application overwrote the Solana delegate), or a merchant staff member revoked it directly. The subscription falls back to sending invoices rather than being cancelled. A merchant-initiated revocation only stops Plirin from attempting to collect; on Solana, Plirin holds the SPL delegate and cannot revoke itself, so the customer’s wallet keeps a live delegated allowance to Plirin’s keeper until the customer revokes it themselves on chain. On a shared Solana token account this fires once per affected mandate: a merchant-side revoke reaches that merchant’s own mandates only, while a customer-side revocation really does end the delegation for everyone on the account and can therefore span more than one merchant. A mandate revoked as collateral either way is attributed to no actor with a system source, since nobody clicked revoke on that subscription. A merchant revocation through the dashboard carries the acting staff member and a dashboard source; the same call through the REST API or an SDK carries no actor and an sdk source, matching cancel/pause/resume. Revoking an already-revoked mandate is an idempotent no-op and writes no second entry |
subscription_mandate_exhausted | A mandate’s periods_consumed reached its authorized periods_authorized; it can no longer collect until the customer re-authorizes |
subscription_mandate_superseded | An active mandate was replaced by a new one from a re-authorization, rather than mutated in place, so the prior mandate’s history survives for a dispute |
Card payments (Stripe)
Section titled “Card payments (Stripe)”| Action | Description |
|---|---|
stripe.connected | The merchant connected their own Stripe account to accept card payments |
stripe.reconnected | A connection that needed attention was re-authorised |
stripe.disconnected | The Stripe connection ended: the merchant disconnected it, it was revoked from the Stripe dashboard, or it was replaced by another account. details names the reason |
stripe.needs_reconnect | Plirin moved to a new Stripe platform account, so a connection the previous one approved can no longer take card payments. Card payments pause for that merchant only and the owner is emailed once to reconnect; details carry the stripe_account_id, the old_platform_account_id and the new platform_account_id. Source system |
stripe.account_restricted | Stripe reported that the connected account can no longer accept charges, so card payments stop being offered until it is resolved |
stripe.webhook_invalid_signature | A request to the Stripe webhook endpoint failed signature verification and was discarded |
merchant_card_payments_beta_updated | Plirin staff turned early access to card payments on or off for the merchant. Only meaningful while the platform switch is set to beta |
merchant_beta_feature_updated | Plirin staff added the merchant to, or removed it from, the beta of a feature (such as customer statements). details names the flag, whether it is now enabled, and the previous value. Only meaningful while that feature’s platform switch is set to beta |
invoice.paid_by_card | A card payment succeeded against an invoice. details carries the amount, currency, Stripe PaymentIntent id, whether it was live or test mode, and Plirin’s fee |
invoice.card_payment_needs_refund | A card payment succeeded that the invoice did not need: the invoice was void, or the payment took it past its total. The money is in the merchant’s Stripe account and should be refunded. details.reason is void_invoice or overpaid |
invoice.card_payment_disputed | A client disputed a card payment, or their bank sent an inquiry about it (details.dispute_status is the status when first seen: warning for an inquiry, inquiry_closed for an inquiry first seen already closed, which was never a chargeback and needs no response, or needs_response or under_review for a dispute, or won or lost for a dispute first seen already decided). Recorded once per dispute. While it is still open (warning, needs_response or under_review) the merchant is emailed, with the deadline to respond in Stripe; a closed inquiry or an already decided dispute sends no email. The invoice stays paid while the dispute is open. details carries the dispute id, amount, Stripe’s reason code and whether it was live or test mode |
invoice.card_payment_dispute_lost | The merchant lost a card dispute: the client kept the disputed amount. A settled chargeback credit note credits it (details.credit_note_id, and credited, its amount), so the invoice stays whole. Recorded once per dispute |
invoice.card_payment_dispute_won | The merchant won a card dispute. Nothing on the invoice changes |
invoice.card_payment_dispute_credit_shortfall | A lost card dispute did not fit the invoice’s credit notes. It is credited up to what settled credit leaves, so the invoice’s balance stays whole. The details give credited, uncredited (the part already credited before, usually by a refund of the same payment, which Stripe has now taken again) and over_committed (unsettled credit notes now exceed what is left to credit); the merchant is emailed |
stripe.payment_unknown_status | Stripe reported a payment status Plirin does not recognise, so the payment was left unchanged and will be read again |
stripe.payments_unreachable | The merchant’s Stripe account was disconnected (by the merchant, or from Stripe’s dashboard) while card payments on it were still open. They are left as they are, because Plirin can no longer read or cancel them and money may still move; check the Stripe dashboard and record any payment that lands by hand. details.payment_ids lists them |
stripe.payment_succeeded_unreachable | Stripe reported a card payment as succeeded on an account Plirin can no longer reach. It was not recorded, because it cannot be verified; reconcile it in the Stripe dashboard and record it on the invoice by hand. details names the invoice and the Stripe PaymentIntent |
invoice.card_payments_changed | Card payment was turned on, off or back to the merchant’s default for one invoice. Turning it off cancels the invoice’s open card payment. details carries card_payments_allowed (true, false or null for the default) and previous |
merchant.card_payments_default_changed | The owner turned the merchant-wide card payment default on or off. Invoices with their own setting keep it. details carries card_payments_default and previous |
invoice.double_payment_suspected | An on-chain payment settled an invoice while a card payment for it was still processing at Stripe. Both may land, so check the invoice and refund the card payment if it succeeds. details names the transaction and the Stripe PaymentIntent |
| Action | Description |
|---|---|
staff_created | A new staff member was created |
staff_updated | A staff member’s details were updated |
staff_deactivated | A staff member was deactivated |
staff_role_assigned | A role was assigned to a staff member |
staff_role_removed | A role was removed from a staff member |
Webhooks
Section titled “Webhooks”| Action | Description |
|---|---|
webhook_endpoint_created | A webhook endpoint was created |
webhook_endpoint_updated | A webhook endpoint was updated |
webhook_endpoint_deleted | A webhook endpoint was deleted |
webhook_secret_rotated | A webhook signing secret was rotated |
webhook_replayed | A webhook delivery was manually replayed |
webhook_exhausted | A webhook endpoint exhausted all retry attempts |
Wallet Screening
Section titled “Wallet Screening”| Action | Description |
|---|---|
wallet.newly_flagged | A wallet was newly flagged during screening |
wallet.rescreening.tick | A scheduled wallet rescreening check ran |
Sanctions & Compliance Cases
Section titled “Sanctions & Compliance Cases”These are the audit trail for sanctions screening. They are written whether or not screening results in a hit, because the absence of a check is itself a fact a reviewer needs.
| Action | Description |
|---|---|
payer_wallet_sanctioned_hit | A paying wallet matched the sanctions list. details includes {wallet, chain, case_id}. The payment is held, the wallet is denylisted, and a compliance case is opened in the same step |
payer_unscreened | A payment could not be screened because the paying wallet was not known. details includes {context, reason}. Recorded rather than passed over silently, so an unscreened payment is never mistaken for a cleared one |
compliance_case_opened | A compliance case was opened for a sanctions hit. details includes {context}, the point in the flow where the hit was detected |
compliance_case_confirmed | An administrator confirmed the case as a true match |
compliance_case_dismissed | An administrator dismissed the case as a false positive. details includes {reason} |
compliance_case_reported | The case was reported to the relevant authority. details includes {reference}, the filing reference |
sanctioned_wallet_added | A wallet was added to the denylist, either by an administrator or automatically on a confirmed hit. details includes {address, chain} |
sanctioned_wallet_removed | A wallet was removed from the denylist by an administrator. details includes {address, chain} |
sdn_list_refreshed | The sanctions list was refreshed from its source. details includes {upserted}, the number of entries written |