Payload Format
Request shape
Section titled “Request shape”POST <your endpoint URL>Content-Type: application/jsonX-Plirin-Signature: t=<unix_ts>,v1=<hmac_hex>X-Plirin-Timestamp: <unix_ts>X-Plirin-Delivery-ID: <integer>X-Plirin-Event: <event_type>
{"event": "<event_type>", "data": { ... }}Headers
Section titled “Headers”| Header | Description |
|---|---|
X-Plirin-Signature | HMAC-SHA256 signature; value is t=<unix_ts>,v1=<hmac_hex> |
X-Plirin-Timestamp | Unix timestamp of when the event was sent (equals t in the signature) |
X-Plirin-Delivery-ID | Unique integer ID for this delivery attempt — use for deduplication |
X-Plirin-Event | The event type (e.g., payment.confirmed) |
The body is always compact JSON (no whitespace between separators) with two top-level fields:
{ "event": "payment.confirmed", "data": { "transaction_id": 4821, "reference": "PAY-20260510-0042", "amount": "15.33", "tip_amount": "1.50", "net_amount": "14.87", "platform_fee": "0.46", "fee_percentage": "3.0", "token_symbol": "USDC", "transaction_hash": "5xK9mZ3pL8vQwR2tY7nE1aF4cD6bJ0sH", "status": "confirmed" }}Currency amounts are serialised as decimal strings. Currency codes (where present) are uppercase ISO-4217 (e.g. "USD"). The data object shape varies by event type — see Event Types for the full per-event field reference.
Tax fields on invoice events
Section titled “Tax fields on invoice events”Every invoice.* event (created, sent, overdue, partial_payment, paid, voided)
carries two fields alongside the amounts:
| Field | Type | Meaning |
|---|---|---|
tax_label | string | What the invoice calls its tax: "GST", "MwSt.", "Sales tax", or "Tax" when the merchant has no business country |
tax_id | string or null | The merchant’s tax registration number printed on the invoice. Always present; null when there is none |
Both are the values as issued. Once an invoice has been sent, they are frozen on it, so an
event fired later (a payment months afterwards) reports what the customer was billed under
even if the merchant has since changed country or number. For a draft (invoice.created),
they reflect the merchant’s current settings. tax_amount is unchanged and remains the
machine-readable figure. See Tax Configuration.
source on imported invoices
Section titled “source on imported invoices”invoice.created carries "source": "imported" when the invoice was imported (issued in another tool and brought into Plirin), and so does the invoice.payment_recorded for a payment recorded at import. Other events and other invoices have no source field, so treat it as optional. An imported invoice’s created_at is its original issue date, and its status is sent or overdue from the start: no invoice.sent fires for it, and no invoice.overdue fires for one imported already overdue.
{ "event": "invoice.created", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "invoice_number": "ACME-2023/42", "collection_method": "send_invoice", "status": "overdue", "amount": "240.00", "tax_amount": "24.00", "tax_label": "Tax", "tax_id": null, "tip_amount": "0", "currency": "USD", "line_count": 2, "customer_id": 12, "created_at": "2026-08-03T12:00:00", "source": "imported" }}Example payloads for new events
Section titled “Example payloads for new events”invoice.sent
Section titled “invoice.sent”{ "event": "invoice.sent", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "invoice_number": "INV-0042", "status": "sent", "amount": "250.00", "customer_id": 17, "sent_at": "2026-05-10T09:00:00Z", "tax_label": "GST", "tax_id": "53004085616" }}invoice.overdue
Section titled “invoice.overdue”{ "event": "invoice.overdue", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "invoice_number": "INV-0042", "status": "overdue", "amount": "250.00", "due_date": "2026-05-31", "tax_label": "GST", "tax_id": "53004085616" }}invoice.partial_payment
Section titled “invoice.partial_payment”{ "event": "invoice.partial_payment", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "invoice_number": "INV-0042", "amount_paid": "100.00", "amount_remaining": "150.00", "status": "sent", "tax_label": "GST", "tax_id": "53004085616" }}invoice.paid
Section titled “invoice.paid”Fires once, regardless of how the invoice was settled: on-chain, offline, by card, or a mix. The route is carried as data in payments[], not as a different event. transaction_id, total_amount, and tip_amount are the legacy top-level fields kept for backward compatibility; payments[] is the complete, route-aware breakdown.
{ "event": "invoice.paid", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "invoice_number": "INV-0042", "amount": "250.00", "tax_amount": "25.00", "tax_label": "GST", "tax_id": "53004085616", "tip_amount": "0.00", "currency": "USD", "total_amount": "275.00", "transaction_id": 4821, "collection_method": "send_invoice", "paid_at": "2026-05-10T09:00:00Z", "payments": [ { "type": "onchain", "rail": "stablecoin", "method": null, "amount": "150.00", "reference": null, "payment_date": "2026-05-08T14:02:11Z" }, { "type": "offline", "rail": "offline", "method": "bank_transfer", "amount": "100.00", "reference": "WIRE-4821", "payment_date": "2026-05-10T09:00:00Z" }, { "type": "card", "rail": "card", "method": null, "amount": "25.00", "reference": "pi_3Q0abc123", "payment_date": "2026-05-10T08:15:40Z" } ] }}Each entry in payments[] is {type: "onchain" | "offline" | "card", rail: "stablecoin" | "offline" | "card", method, amount, reference, payment_date}. Every entry carries rail, how the customer paid; read it rather than type, which is kept for existing integrations (it is onchain where rail is stablecoin). Entries are ordered on-chain first, then offline payments in the order they were recorded, then card payments in the order they succeeded.
methodandreferencearenullforonchainentries (those live on the linked transaction).- A
cardentry is a card payment through the merchant’s own Stripe account. Only succeeded card payments appear.methodisnullandreferenceis the Stripe PaymentIntent id (pi_...), which you can look up in the merchant’s Stripe dashboard.
simulated is true only when this invoice was settled by the simulateTestPayment mutation — no money moved. Absent or false for every real payment, on-chain or offline.
invoice.payment_recorded
Section titled “invoice.payment_recorded”Fires when an offline payment is recorded, and again when one is restored (restored: true, with the restore’s reason). Does not imply the invoice reached paid: check the invoice status or listen for invoice.paid for that.
{ "event": "invoice.payment_recorded", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "payment_id": "9f1c2e40-....", "amount": "100.00", "method": "bank_transfer", "reference": "WIRE-4821", "amount_remaining": "150.00", "restored": false }}For a payment recorded as part of an import, the payload also carries "source": "imported" (the payment’s method is other).
invoice.payment_removed
Section titled “invoice.payment_removed”{ "event": "invoice.payment_removed", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "payment_id": "9f1c2e40-....", "amount": "100.00", "method": "bank_transfer", "reason": "Recorded against the wrong invoice", "amount_remaining": "250.00", "status": "pending" }}status reflects where the invoice landed after removal. A removal that reopens a paid invoice sends it back to sent, or to overdue if its due day had already ended on the merchant’s calendar; an in-person sale goes back to pending.
Late fee events
Section titled “Late fee events”invoice.late_fee_applied and invoice.late_fee_removed describe a late fee charged against an overdue invoice. The fee itself is a separate invoice (fee_invoice_id), and sequence is its position on the original invoice (1 for the first fee), never reused even after a removal. invoice.late_fee_applied fires in the same transaction that creates the fee invoice, so there is no event for a fee that did not commit.
{ "event": "invoice.late_fee_applied", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "invoice_number": "INV-0042", "fee_invoice_id": "7c1d9a52-....", "fee_invoice_number": "INV-0057", "sequence": 1, "amount": "15.00", "currency": "USD", "applied_at": "2026-10-14T13:00:00+00:00" }}{ "event": "invoice.late_fee_removed", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "invoice_number": "INV-0042", "fee_invoice_id": "7c1d9a52-....", "fee_invoice_number": "INV-0057", "sequence": 1, "amount": "15.00", "currency": "USD", "removed_at": "2026-10-16T09:30:00+00:00", "reason": "Customer called and paid the original" }}reason is the merchant’s own note, or null. Removing a fee also voids the fee invoice, which fires invoice.voided for it. A fee that has been paid cannot be removed, so this event never describes a paid fee.
Estimate events
Section titled “Estimate events”estimate.sent, estimate.accepted, estimate.declined, estimate.expired, estimate.converted and estimate.voided share one payload. The fields describe the document, not the live customer record. total is a decimal string, expires_on is a date on the merchant’s calendar or null, and revision says which version of the estimate the event is about.
{ "event": "estimate.sent", "data": { "estimate_id": "3b0f6c4e-....", "estimate_number": "EST-0042", "revision": 2, "status": "sent", "currency": "USD", "total": "300.00", "expires_on": "2026-11-15" }}Two events add a field:
estimate.declinedaddsdecline_reason,nullwhen the customer gave none.estimate.convertedaddsinvoice_id, the draft invoice the estimate became.
When a customer accepts on the hosted page, estimate.accepted fires and then estimate.converted. When a merchant uses Convert now, only estimate.converted fires.
status is the estimate’s status when the event is sent. Accepting and converting happen in one step, so the estimate is already converted when estimate.accepted is sent: its payload carries "status": "converted", not "accepted". Use the event name to tell the two apart.
refund.requested / refund.approved / refund.rejected / refund.completed / refund.failed / refund.voided
Section titled “refund.requested / refund.approved / refund.rejected / refund.completed / refund.failed / refund.voided”All six refund.* events share the same payload shape. rail says how the money goes back (stablecoin, card or offline), and invoice_id and credit_note_id link the refund to the invoice and the credit note it settles. transaction_id and transaction_hash are null for a card or offline refund, and transaction_hash is null for an on-chain refund until it completes. refund.failed adds failure_code.
{ "event": "refund.completed", "data": { "refund_id": 12, "refund_reference": "REF_3F9A1C7D52B0", "amount": "25.00", "currency": "USD", "status": "completed", "transaction_id": 4821, "transaction_hash": "3vP7kN2mW9qX5yA1bC8dE4fG6hI0jR", "rail": "stablecoin", "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "credit_note_id": "7a3f1b20-5c4e-4d8a-9f2b-1e6c0a9d3e87" }}The field table, a card refund’s failure_code values and an example of each event are under Refund and dispute events.
payment.dispute_closed
Section titled “payment.dispute_closed”A card payment’s dispute was decided at Stripe. outcome is won or lost, and a lost one carries the credit_note_id of its chargeback credit note. The full field table is under payment.dispute_closed.
{ "event": "payment.dispute_closed", "data": { "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "provider_payment_id": "pi_3Px9aQ2eZvKYlo2C0f1d4a7b", "dispute_id": "dp_1Px9bR2eZvKYlo2CpL5m8n0q", "outcome": "won", "amount": "120.00", "currency": "USD", "rail": "card", "livemode": true, "credit_note_id": null }}customer.created
Section titled “customer.created”{ "event": "customer.created", "data": { "customer_id": 99, "wallet_address": "0xAbCd1234EfGh5678IjKl9012MnOp3456QrSt7890", "email": "alice@example.com", "name": "Alice" }}subscription.created
Section titled “subscription.created”Fires when a merchant issues a subscription checkout link (createSubscriptionLink on GraphQL, POST /api/v1/subscription-links on REST). status is always "incomplete" at this point: nothing has billed yet, and nothing will until a customer opens the link and connects a wallet. customer_id is null for an open link and set only when the merchant preselected a customer, which restricts the link to that customer’s own wallet.
{ "event": "subscription.created", "data": { "subscription_id": 501, "customer_id": null, "price_id": 7, "status": "incomplete", "collection_method": "send_invoice", "checkout_slug": "aZ3xQ9mK2pL7vR4tYbN8wJ" }}subscription.activated
Section titled “subscription.activated”Fires once a checkout link’s connecting wallet clears screening, resolves to a customer, and the subscription moves out of incomplete into trialing or active. This is also the point where the subscription’s first invoice is minted and sent, if there is no trial. trial_end is null when the subscription activated straight into active with no trial.
{ "event": "subscription.activated", "data": { "subscription_id": 501, "customer_id": 42, "price_id": 7, "status": "trialing", "current_period_start": "2026-05-10T09:00:00Z", "current_period_end": "2026-05-24T09:00:00Z", "trial_end": "2026-05-24T09:00:00Z" }}subscription.renewed
Section titled “subscription.renewed”Fires once the invoice for a subscription’s period is paid, not when it is generated or sent. A merchant learns that a new period started, and its invoice went out, from invoice.sent; this event confirms that period was actually collected. By the time it fires, the subscription’s period has already advanced to the next one, which is why current_period_start/current_period_end below describe the period that follows the one invoice_id paid for.
{ "event": "subscription.renewed", "data": { "subscription_id": 501, "customer_id": 42, "price_id": 7, "status": "active", "current_period_start": "2026-05-10T09:00:00Z", "current_period_end": "2026-06-10T09:00:00Z", "invoice_id": "550e8400-e29b-41d4-a716-446655440000", "invoice_number": "INV-0099", "amount": "29.000000" }}subscription.payment_failed
Section titled “subscription.payment_failed”Fires for every failed charge attempt against a subscription, whether or not that attempt moved its status. reason is always present, use it to distinguish a retryable decline from a permanent one.
{ "event": "subscription.payment_failed", "data": { "subscription_id": 501, "customer_id": 42, "price_id": 7, "status": "past_due", "failed_charge_count": 1, "reason": "insufficient_allowance" }}subscription.past_due
Section titled “subscription.past_due”Fires when a failed charge still has a retry left on the dunning schedule. next_retry_at is when the next attempt runs.
{ "event": "subscription.past_due", "data": { "subscription_id": 501, "customer_id": 42, "price_id": 7, "status": "past_due", "failed_charge_count": 1, "next_retry_at": "2026-05-13T09:00:00Z" }}subscription.on_hold
Section titled “subscription.on_hold”Fires when a subscription exhausts every scheduled retry. This is the event to gate customer access on: the payload carries everything needed to act without a follow-up API call.
{ "event": "subscription.on_hold", "data": { "subscription_id": 501, "customer_id": 42, "price_id": 7, "status": "on_hold", "current_period_end": "2026-06-10T09:00:00Z", "failed_charge_count": 5 }}failed_charge_count here is 5, not 4: SUBSCRIPTION_DUNNING_OFFSETS_DAYS schedules 4 retries ([1, 3, 5, 7]), and record_failure increments the counter before checking whether the schedule is exhausted, so the failure that actually lands on on_hold is the 5th, one past the last scheduled retry.
subscription.canceled
Section titled “subscription.canceled”Fires once a subscription’s status flips to canceled, whether by immediate cancellation or by reaching cancel_at_period_end.
{ "event": "subscription.canceled", "data": { "subscription_id": 501, "customer_id": 42, "price_id": 7, "status": "canceled", "cancel_at_period_end": false, "canceled_at": "2026-05-10T09:00:00Z", "ended_at": "2026-05-10T09:00:00Z" }}subscription.mandate_insufficient
Section titled “subscription.mandate_insufficient”Fires when a subscription mandate cannot collect a period automatically, for a reason that is Plirin’s or the merchant’s own bookkeeping, never the customer’s. That period is invoiced instead (invoice.sent fires separately), not failed or cancelled. reason names which of the two causes applies, and only that cause’s own fields are present.
cap_exceeded: the period’s real charge, converted into the mandate’s collection token, exceeded per_period_cap.
{ "event": "subscription.mandate_insufficient", "data": { "subscription_id": 501, "customer_id": 42, "price_id": 7, "invoice_id": "8f14e3b0-...", "invoice_number": "INV-0042", "reason": "cap_exceeded", "attempted_amount": "117.180000", "per_period_cap": "100" }}recipient_changed: the merchant’s receiving wallet changed since this mandate’s last confirmed collection.
{ "event": "subscription.mandate_insufficient", "data": { "subscription_id": 501, "customer_id": 42, "price_id": 7, "invoice_id": "8f14e3b0-...", "invoice_number": "INV-0042", "reason": "recipient_changed", "previous_recipient": "7CsafD3ibJ3P4D1vmaGxey87V8JoHrmX9LDCF3CgyDV4", "current_recipient": "9zNQqu9YsuLZ2Y6Y5f3kV8mYQXjKq1H1cVJb9c8fXBpP" }}subscription.mandate_revoked
Section titled “subscription.mandate_revoked”Fires when a mandate can no longer collect: the customer revoked it on chain (or, on Solana, another application overwrote the token account’s delegate), or the merchant revoked it directly, either as a staff member with subscription.manage via revokeSubscriptionMandate on GraphQL, or with an API key via POST /api/v1/subscriptions/{id}/mandate/revoke. Either way the subscription falls back to sending invoices rather than being cancelled, since a revoked mandate is not a cancellation request. revoked_by is "customer" or "merchant".
revoked_by: "merchant" is Plirin-side only: it marks the mandate so Plirin stops attempting to collect against it, nothing more. On Solana, Plirin holds the SPL delegate, not the token account’s owner, and a delegate 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 (an SPL Revoke instruction, or approving a new delegate for zero). revoked_by: "customer" means that has already happened, directly or via another application overwriting the delegate. A merchant or integrator that expects this event to mean “the authorization is gone” for a "merchant"-initiated revocation should tell the customer to revoke on chain themselves.
On a shared Solana token account, revoking one mandate revokes every other ACTIVE mandate on that same account too, so this can fire once per affected subscription from a single revocation. A merchant-side revoke fans out across that merchant’s own subscriptions only; a customer-side revocation, which really does end the delegation on chain, fans out across every subscription on the account and so may reach a different merchant. Each event is delivered to the endpoints of the merchant that owns the subscription it names, never the initiating merchant’s.
Revoking is idempotent, and an idempotent call is silent: a revoke against a mandate that is already revoked, exhausted or superseded succeeds, changes nothing, and fires no event.
{ "event": "subscription.mandate_revoked", "data": { "subscription_id": 501, "customer_id": 42, "price_id": 7, "status": "active", "mandate_id": 12, "chain": "solana", "revoked_by": "customer", "revoked_at": "2026-06-10T09:00:00Z" }}subscription.mandate_expiring
Section titled “subscription.mandate_expiring”Fires on either of two triggers, checked after every successful automatic collection:
- Periods running out. The mandate has two or fewer authorized periods remaining (
periods_authorized - periods_consumed <= 2), so the customer can re-authorize before it actually runs out. On Solana this fires routinely: the SPL delegated amount decrements on every collection, so it is expected near the end of every mandate’s horizon, not a one-time signal. - Shared allowance running low. This mandate shares its Solana token account with another active mandate (the same customer paying two subscriptions from one wallet and token), and that sibling’s own charges have drawn the shared on-chain delegated amount down below what this mandate’s very next charge needs.
periods_remainingin the payload below can be well above two when this trigger fires, because the account-level allowance, not this mandate’s own period count, is what ran short. Do not filter this event onperiods_remaining <= 2alone, or integrations silently drop exactly the case this trigger exists to warn about.
{ "event": "subscription.mandate_expiring", "data": { "subscription_id": 501, "customer_id": 42, "price_id": 7, "status": "active", "mandate_id": 12, "chain": "solana", "periods_authorized": 12, "periods_consumed": 11, "periods_remaining": 1 }}Signature verification
Section titled “Signature verification”The X-Plirin-Signature header proves the webhook came from Plirin. Always verify before processing. Pass the raw request body bytes — do not re-serialize parsed JSON (key ordering will differ and the HMAC will fail).
import hashlib, hmac, time
def verify(secret, sig_header, body, max_skew=300): parts = dict(p.split("=", 1) for p in sig_header.split(",")) ts, v1 = int(parts["t"]), parts["v1"] if abs(int(time.time()) - ts) > max_skew: return False expected = hmac.new( secret.encode(), f"{ts}.".encode() + body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, v1)const crypto = require("crypto");
function verify(secret, sigHeader, bodyBuffer, maxSkew = 300) { const parts = Object.fromEntries( sigHeader.split(",").map((p) => p.split("=", 2)) ); const ts = parseInt(parts.t, 10); if (Math.abs(Math.floor(Date.now() / 1000) - ts) > maxSkew) return false; const expected = crypto .createHmac("sha256", secret) .update(Buffer.concat([Buffer.from(`${ts}.`), bodyBuffer])) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(parts.v1) );}