Webhook Events
Plirin sends webhook events to your configured endpoints when significant actions occur. This lets you automate workflows without polling the API.
Event types
Section titled “Event types”| Event | Trigger |
|---|---|
payment.confirmed | An on-chain transaction is confirmed |
payment.expired | A payment link or transaction window expires |
payment.reverted | A confirmed payment was contradicted by the chain and demoted. Act on this one. See below |
payment.dispute_closed | A card dispute was decided at Stripe. outcome is won or lost; a lost one settles a chargeback credit note for the money the client kept (credit_note_id, null when the invoice had nothing left to credit, and credit_note.issued and credit_note.settled fire for it). The payload also carries invoice_id, provider_payment_id, dispute_id, amount, currency, rail and livemode |
invoice.created | A new invoice is created (an imported invoice carries "source": "imported") |
invoice.sent | An invoice is marked sent (email send is attempted; fires even if delivery fails) |
invoice.paid | An invoice’s balance reaches zero: on-chain, offline, by card, or a mix (each item in payments[] carries its rail: stablecoin, offline or card) |
invoice.overdue | An invoice passes its due date without full payment |
invoice.partial_payment | A partial payment is received against an invoice |
invoice.payment_recorded | An offline payment is recorded or restored against an invoice |
invoice.payment_removed | An offline payment is removed (soft-deleted) from an invoice |
invoice.voided | An invoice is cancelled |
invoice.late_fee_applied | An automatic late fee was charged against an overdue invoice as a new linked invoice (fee_invoice_id, sequence, amount in the payload); the original invoice is unchanged |
invoice.late_fee_removed | A merchant removed a late fee that had been charged: the fee invoice (fee_invoice_id) is now void and its sequence is never reused (invoice_id, sequence, amount, currency, removed_at and the merchant’s reason, or null, in the payload). Fires only for a fee nobody has paid; a paid fee is refunded or credited instead |
estimate.sent | An estimate is sent to a customer (also fires when a revised estimate is sent again; revision in the payload says which) |
estimate.accepted | A customer accepts an estimate on its hosted page (a merchant’s “Convert now” fires only estimate.converted). The payload’s status is already converted, because accepting creates the invoice in the same step |
estimate.declined | A customer declines an estimate on its hosted page (decline_reason is null when none was given) |
estimate.expired | A sent estimate passes its expires_on date, counted on the merchant’s calendar, without a decision |
estimate.converted | An estimate becomes a draft invoice, by customer acceptance or the merchant’s “Convert now” (invoice_id in the payload) |
estimate.voided | An estimate is voided by the merchant |
credit_note.created | A credit note is created in draft state |
credit_note.issued | A credit note is issued |
credit_note.settled | A credit note reaches settled state |
refund.requested | A refund request is submitted |
refund.approved | A refund request is approved |
refund.rejected | A refund request is rejected |
refund.completed | A refund is completed: on-chain, at Stripe for a card refund (including one made in the Stripe dashboard), or recorded (rail and credit_note_id in the payload) |
refund.failed | A card refund failed at Stripe, at once or after first succeeding, or Plirin 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 found Stripe never made a refund whose outcome stayed unknown after repeated attempts (STRIPE_NEVER_CONFIRMED); its credit note is void and the balance restored (rail, credit_note_id and Plirin’s failure_code in the payload) |
refund.voided | A recorded refund (an offline refund made outside Plirin) was voided as entered by mistake; its credit note is void with it and the balance restored. Undo the refund.completed you booked for it (rail, invoice_id and credit_note_id in the payload) |
customer.created | A new customer record is created |
subscription.created | A merchant issues a subscription checkout link |
subscription.activated | A checkout link’s wallet clears screening and the subscription goes live (trialing or active) |
subscription.renewed | An invoice for a subscription’s period is paid (not when it is sent, see invoice.sent for that) |
subscription.payment_failed | A subscription charge attempt fails |
subscription.past_due | A subscription failed a charge and has a retry scheduled |
subscription.on_hold | A subscription exhausted its retry schedule; act on this one |
subscription.canceled | A subscription is canceled |
subscription.mandate_insufficient | A period’s charge exceeded the subscription mandate’s authorized ceiling, or the merchant’s receiving wallet changed since the mandate’s last confirmed collection; either way that period is invoiced instead of collected automatically (see reason in the payload) |
subscription.mandate_revoked | A subscription mandate can no longer collect, either the customer revoked it on chain or a merchant staff member revoked it; the subscription falls back to sending invoices rather than being cancelled |
subscription.mandate_expiring | A mandate has two or fewer authorized periods left; re-authorize before it runs out |
payment.reverted
Section titled “payment.reverted”The one event that can contradict something Plirin already told you.
Every other payment event moves a payment forward. This one takes it back: a payment that Plirin confirmed has been demoted because an independent on-chain re-check found that the money never actually moved. The usual cause is a chain reorganisation that displaced the block carrying the transaction.
{ "event": "payment.reverted", "data": { "transaction_id": 1234, "reference": "TX_ABC123", "previous_status": "confirmed", "status": "reverted", "reason": "chain_attestation_failed" }}What to do. Treat it as you would a chargeback on a card rail: the goods may
already have shipped. Reverse whatever payment.confirmed triggered — unfulfil
the order, revoke the licence, suspend the account — and reconcile with your
own records.
It is rare, and deliberately conservative. It fires only when the chain positively contradicts the payment. An unreachable RPC or a temporarily degraded node produces no event at all: those are retried, never treated as evidence that a payment failed. You will not receive this because of an outage.
It is terminal. A reverted payment is never re-confirmed. If the same customer pays again, that is a new payment with a new reference.
You are subscribed by default. An endpoint that does not name a specific
event list receives every event, so existing integrations will start receiving
payment.reverted without any change on their side. This is intentional: an
event that says money you were told had arrived did not arrive is not one to
require opting into. If your handler rejects unknown event types, add a case for
it.
Card payments
Section titled “Card payments”A client’s card payment is not an on-chain transaction, so it does not send payment.confirmed. Listen for these instead:
| What happened | Event |
|---|---|
| A card payment settled the invoice | invoice.paid, with a payments[] entry whose rail is card and whose reference is the Stripe PaymentIntent ID. Read rail rather than type (see Payload Format) |
| A card payment was refunded | refund.completed (refund.requested, refund.approved and refund.failed on the way), with rail set to card |
| A client disputed a card payment and the dispute was decided | payment.dispute_closed |
Three things follow from how card payments work. A card payment that is only started or still processing sends nothing: act on invoice.paid. A card payment that arrives after the invoice was voided sends no invoice.paid, because the invoice stays void; it is kept on the invoice with needs_refund_reason (see the SDKs), as is one that takes the invoice past its total. And unlike a stablecoin payment, a card payment can be charged back, so treat a card invoice.paid as money that can still be reversed by a dispute.
Refund and dispute events
Section titled “Refund and dispute events”A refund goes back the way the money came, so every refund.* payload says which way that was. A card payment can be disputed and charged back; a stablecoin payment cannot, so payment.dispute_closed only ever describes a card payment.
Refund payload
Section titled “Refund payload”refund.requested, refund.approved, refund.rejected, refund.completed, refund.failed and refund.voided share one payload. refund.failed adds failure_code.
| Field | Type | Description |
|---|---|---|
refund_id | integer | Plirin’s refund ID. A recorded refund is voided by this ID |
refund_reference | string | The refund’s public reference |
amount | decimal string | The refund amount, in currency |
currency | string | Uppercase ISO-4217 code of the refund |
status | string | The refund’s status when the event fired: pending, approved, rejected, completed, failed or voided |
rail | string | How the money goes back: stablecoin (on-chain), card (through Stripe) or offline (a refund you paid yourself and recorded, so no money moved through Plirin) |
invoice_id | string or null | The invoice refunded. Null for a refund that is not linked to an invoice |
credit_note_id | string or null | The credit note this refund settles, on every event for the refund including refund.requested, on-chain or card. Null for an overpayment refund (money returned beyond the invoice total, which creates no credit note) |
transaction_id | integer or null | The on-chain payment refunded. Null for card and offline refunds |
transaction_hash | string or null | The on-chain refund transfer. Null until an on-chain refund completes, and always null for card and offline |
credit_note.created, credit_note.issued and credit_note.settled carry the same rail (null for a credit note with no refund).
For a refund made in Plirin the events arrive in this order: refund.requested (awaiting approval), refund.approved (for a card refund, Plirin sent it to Stripe; the payload’s status is approved), then refund.completed once Stripe confirms it, or refund.failed. If Stripe refuses a card refund at once, you receive refund.approved and then refund.failed. If Plirin cannot tell whether Stripe made the refund (for example, the request timed out), you receive refund.approved, and refund.completed or refund.failed follows once Plirin confirms the outcome with Stripe. Rarely, when Stripe reports the result in the same moment Plirin records the approval, refund.approved is not sent and refund.completed or refund.failed arrives on its own. Treat refund.approved as informational and act on refund.completed and refund.failed. A recorded refund completes as it is recorded and sends refund.completed alone. A card refund made directly in the Stripe dashboard is picked up from Stripe: it emits refund.completed (or refund.failed) when Stripe settles it, and no refund.requested or refund.approved.
refund.completed
Section titled “refund.completed”A refund completed. On-chain, the transfer is confirmed. For a card refund, Stripe confirmed it, which includes a refund made in the Stripe dashboard. A recorded refund completes at the moment it is recorded.
{ "event": "refund.completed", "data": { "refund_id": 31, "refund_reference": "REF_3F9A1C7D52B0", "amount": "40.00", "currency": "USD", "status": "completed", "transaction_id": null, "transaction_hash": null, "rail": "card", "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "credit_note_id": "7a3f1b20-5c4e-4d8a-9f2b-1e6c0a9d3e87" }}refund.failed
Section titled “refund.failed”A card refund failed at Stripe. It can fail at once (Stripe refused it) or after first succeeding. The refund’s credit note is void and the invoice’s balance is restored, so undo anything you booked for it. failure_code is Plirin’s code, never Stripe’s text:
failure_code | Meaning |
|---|---|
CARD_LOST_OR_STOLEN, CARD_EXPIRED_OR_CANCELED | The card can no longer receive the money |
REFUND_DECLINED | The card issuer declined it |
CHARGE_DISPUTED | The payment is under dispute |
INSUFFICIENT_FUNDS | The Stripe balance could not cover it |
ALREADY_REFUNDED | The payment had already been refunded in Stripe |
REFUND_EXCEEDS_REFUNDABLE | Stripe holds less of the payment than the refund |
CANCELED_BY_MERCHANT, REFUND_CANCELED | The refund was canceled in Stripe |
CREDIT_OVER_COMMITTED | A refund whose outcome was unknown was never made, because the same money had since been refunded in Stripe. Nothing more was sent |
STRIPE_REFUSED, REFUND_FAILED | Stripe refused it, or could not complete it, for another reason |
{ "event": "refund.failed", "data": { "refund_id": 31, "refund_reference": "REF_3F9A1C7D52B0", "amount": "40.00", "currency": "USD", "status": "failed", "transaction_id": null, "transaction_hash": null, "rail": "card", "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "credit_note_id": "7a3f1b20-5c4e-4d8a-9f2b-1e6c0a9d3e87", "failure_code": "CARD_EXPIRED_OR_CANCELED" }}refund.rejected
Section titled “refund.rejected”A refund request was rejected by a manager or the owner. Nothing was sent, the invoice stays paid, and the refund’s credit note is void. The payload is the standard refund payload with status set to rejected.
{ "event": "refund.rejected", "data": { "refund_id": 32, "refund_reference": "REF_B07E44D91A3C", "amount": "250.00", "currency": "USD", "status": "rejected", "transaction_id": null, "transaction_hash": null, "rail": "card", "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "credit_note_id": "9c0d27e4-3b1a-4f68-8a2e-5d7b61f40a12" }}refund.voided
Section titled “refund.voided”A recorded refund was voided: you recorded that you had paid a customer back outside Plirin, and it was entered by mistake. Its credit note is void with it and the invoice’s balance is restored. Undo whatever you booked for the refund.completed of the same refund_id. Only a recorded refund (rail is offline) can be voided: a card refund that went to Stripe, and an on-chain transfer, cannot be taken back.
{ "event": "refund.voided", "data": { "refund_id": 33, "refund_reference": "REF_5D21E8A0C7F4", "amount": "45.00", "currency": "USD", "status": "voided", "transaction_id": null, "transaction_hash": null, "rail": "offline", "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "credit_note_id": "4e8a91c3-6d2f-4b05-b7a9-0c1f3e5d8a26" }}payment.dispute_closed
Section titled “payment.dispute_closed”A customer’s dispute of a card payment was decided at Stripe. It fires once per dispute, however many times Stripe reports the result.
| Field | Type | Description |
|---|---|---|
outcome | string | won or lost |
invoice_id | string or null | The invoice the card payment was for |
provider_payment_id | string | The Stripe PaymentIntent ID of the card payment |
dispute_id | string | The Stripe dispute ID |
amount | decimal string | The disputed amount, in currency |
currency | string | Uppercase ISO-4217 code |
rail | string | Always card today |
livemode | boolean | Whether the payment was live or a test-mode payment |
credit_note_id | string or null | For lost, the chargeback credit note that settles the money the customer kept. Null for won, and null for a lost dispute when the invoice had nothing left to credit |
A won dispute changes nothing on the invoice: it stayed paid while the dispute was open. A lost dispute creates a settled chargeback credit note for the disputed amount, so the invoice stays paid and the books record that the money went back to the customer. credit_note.issued and credit_note.settled fire for that note, with rail null, because a chargeback is not a refund. There is no webhook for a dispute opening: Plirin emails the merchant when it opens, with the deadline to respond in Stripe.
{ "event": "payment.dispute_closed", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "provider_payment_id": "pi_3Px9aQ2eZvKYlo2C0f1d4a7b", "dispute_id": "dp_1Px9bR2eZvKYlo2CpL5m8n0q", "outcome": "lost", "amount": "120.00", "currency": "USD", "rail": "card", "livemode": true, "credit_note_id": "6b2d3f90-1a4c-4e78-9d05-8f7e2c1b4a93" }}Setting up webhooks
Section titled “Setting up webhooks”Configure webhook endpoints in Settings > Webhooks or via the API. See the Set Up Webhooks guide for a step-by-step walkthrough.