Skip to main content

Event reference

The package's own events, all under Pushery\Billing\Events. Listen to these and your app never reads a provider payload.

A driver's webhook mapper translates each provider event — a signed Stripe event, a bare-id ping, an HMAC batch — into one of the domain events below, and everything downstream listens on those. That is what makes a side effect survive a driver change: the mapper is provider-specific, the event is not.

The marker interface is BillingDomainEvent. It carries no methods; it exists so the effect bus can only be handed an event that is part of this contract.

Listening

Every domain event goes through Laravel's dispatcher, so listen the ordinary way:

use Illuminate\Support\Facades\Event;
use Pushery\Billing\Events\PaymentFailed;

Event::listen(PaymentFailed::class, function (PaymentFailed $event): void {
// $event->customerReference, $event->amount, $event->reference
});

They are equally ordinary to assert on:

use Illuminate\Support\Facades\Event;
use Pushery\Billing\Events\SubscriptionStateChanged;

Event::fake();

// ... exercise the code under test ...

Event::assertDispatched(SubscriptionStateChanged::class);

See Testing for Billing::fake(), which fakes the driver rather than the events, and for the cross-engine suites.

The events

customerReference is always the provider's customer id, resolved back to your billable model through billing.customer.column. Money is always a Money value object — an integer minor amount plus a currency, never a float.

EventWhen it firesPayload
SubscriptionStateChangedA subscription moved to a new canonical statecustomerReference, state (a SubscriptionState), subscriptionReference?, tierKey?, occurredAt?, periodStart?, periodEnd?, trialEnd?
TrialEndingA trial is about to end, a few days outcustomerReference, subscriptionReference, trialEndsAt
PaymentSucceededA payment completedcustomerReference, amount, reference
PaymentFailedA payment attempt failedcustomerReference, amount, reference
PaymentActionRequiredThe bank asked the cardholder to confirm (3-D Secure)customerReference, reference
PaymentReminderDueA marketplace subscription is in arrears and still inside its cure window — today's reminder is duesubscription, daysLeft
SubscriptionExpiredA marketplace subscription ran out its cure window and expired for goodsubscription, accessEndsAt
WebhookDeliveryRefusedA delivery reached a billing webhook endpoint and its verifier refused itprovider, surface, path, userAgent
WriteOffRecoveredMoney arrived against a receivable written off as uncollectible — the write-off was a judgement the future disagreed withcorrection, received, paymentReference
ChargebackReceivedA chargeback was decided against a settled paymentcustomerReference, reference, amount, merchantReference, transferReference, feeAmount, cause, reason?, disputeReference?
MandateRevokedA stored mandate is no longer usablecustomerReference, mandateId
MandateEstablishedA payment method was granted and can now be charged off-sessioncustomerReference, mandateId, provider, method?, paymentReference?
AddonPurchasedA one-time add-on was bought and paidcustomerReference, addonKey, amount, reference, paymentReference?
AddonRefundedA charge was refundedpaymentReference, cumulativeRefunded, reason?
InvoiceFinalizedThe provider finalized an invoice — it now legally existsinvoice (an InvoiceSnapshot)
InvoiceCorrectedA correcting document was issued against a finalized invoicecorrection (an InvoiceCorrectionSnapshot)
InvoiceCreditedDeprecated — the former name of InvoiceCorrectedcorrection
MerchantAccountUpdatedThe provider reported what a merchant's account can doaccount (a MerchantAccountReference)
MerchantTransferReversedMoney came back from a merchant — a refund or a lost disputemerchant, provider, chargeReference, amount, feeReturned, cause, disputeFee?
MerchantAccountDeauthorizedA merchant disconnected their account from the platformprovider, accountReference
MerchantTransferReversedByProviderThe provider reversed a transfer this platform did not ask it to reverseprovider, transferReference, amountReversedMinor
MerchantPayoutFailedThe provider could not move a merchant's money from their connected balance to their bank. A different question from a transfer, and it fails on its own terms — a wrong IBAN, a closed account, a bank that refuses it — with nothing wrong about the transfer that fed it. Only the FAILURE is carried: the success is the ordinary case and the provider's dashboard already shows it. Attributed to the merchant, never to a charge, because a payout bundles many transfers and has no 1:1 relation to a recorded saleprovider, accountReference, payoutReference, amountMinor, currency, failureCode, failureMessage
BuyerProtectionHoldReleasedThe protection period ended in the seller's favor and the money is on its way to themchargeReference, merchantType, merchantId, amount, state
BuyerProtectionHoldRefundedThe protection period ended in the buyer's favor and the money goes back to themchargeReference, merchantType, merchantId, amount, state
BuyerProtectionResolutionRequiredNobody decided in time and this package will not decide for them — a disputed hold past its deadlinechargeReference, merchantType, merchantId, amount, state
MerchantRoutingSuspendedThe platform stopped routing new money to a merchantprovider, accountReference, reason
MerchantRoutingReinstatedA suspended merchant may receive againprovider, accountReference
MerchantTerminatedThe relationship ended and cannot be resumed from hereprovider, accountReference, reason
RoutedChargeConfirmedA routed payment that could not settle at once has cleared. Two cannot: a card demanding 3-D Secure, and a bank debit that clears days later — both return successfully having moved no money, so the merchant's row is written pending until this arrives.provider, paymentReference
RoutedChargeAbandonedA pending routed payment will not complete. Only ever from pending: a charge that settled and later goes wrong is a refund or a dispute, never a failure, and calling it one would erase the fact that the money was really there.provider, paymentReference
CreatorTaxStatusChangedA merchant's tax standing movedmerchant, previous, current, effectiveFrom, source
CreatorPlacedOnTaxHoldA merchant can no longer sell or be paid out. Dispatched for both routes into a hold — somebody recording a blocking standing, and an attestation quietly expiring. The second writes nothing at all, so a scheduled sweep finds it; without both, the event's silence would read as "no hold". Fires once per hold.merchant, reasonKey (a translation key, never a sentence — why a standing blocks is a jurisdiction's rule)
FilingObligationApproachingA filing obligation falls due inside the notice window, and this is the one warning it gets. Dispatched per obligation, never per day: the last period's return and the annual seller report share the end-of-January deadline, and a single "something is due" would let whoever handles the one they thought of consider the day dealt with. Two events for one date is the intended shape.obligation, dueOn, period (null for the annual report, which covers a year), daysRemaining
VoucherVolumeThresholdApproachingVoucher volume is close to the figure at which a supervisory filing is expected. A warning exists because a threshold you learn about on the day you cross it leaves no time to act, and registering with a supervisor has a lead time only the operator can start. The package announces; it notifies no authority and holds credentials for none.volume, threshold, observedAt
VoucherVolumeThresholdBreachedVoucher volume has passed that figure. Deliberately a separate event from the warning above rather than a level on one: a recipient treating them as one message would let the early notice stand for the late one, and only the late one has a deadline attached. The figure travels with the event because the window is rolling — what triggered it is not what a recipient would compute later.volume, threshold, observedAt
RoutedSubscriptionInvoicePaidA subscription cycle was invoiced and paid — the moment a routed subscription's commission becomes a fact. Its own event beside PaymentSucceeded on purpose: the handler reaches the provider, so hanging it on the payment would mean provider calls for every payment on every install, including the great majority that route nothing. The subscription reference is what lets the handler ask the local row first and stop there.customerReference, invoiceReference, subscriptionReference
InvoiceUpcomingThe provider is about to finalize the next invoicecustomerReference
SeatQuantityChangedA team's billed seat quantity actually movedowner, from, to
UsageBacklogStalledUsage has sat unreported longer than billing.metering.stall_hourspendingRollups, pendingUnits, oldestRecordedAt, stalledHours
UsageReconciliationDriftOur ledger and the provider's meter disagreeowner, meterKey, period, reported, recorded
ProviderJournalDriftThe merchant journal and the provider disagree about money that already moved to a merchantmerchant, chargeReference, transferReference, reason, ours, theirs
BillableAccountDeletingAn account is about to be deletedowner
AccountBillingUpdatedAn owner's billing state changed — a broadcast, for live refreshowner
AccountToastNotifiedA transient message for the owner — a broadcastowner, message, level

A few of these repay a closer look.

ProviderJournalDrift compares one transfer, never a balance. The obvious check — the package's figure against the connected account's balance at the provider — fires on every legitimate event, because that balance also moves when the merchant is paid out to their bank, when they take money through another integration, and when the provider debits a fee. An alarm that fires on all of those is switched off within a week, and on a money path a muted alarm is worse than none: it is also an alibi. So the comparison is per transfer, the one object both sides name and only this package creates.

Which side is right is not symmetric, and the event's job is only the first half of the question. The provider is authoritative for what MOVED; the package is authoritative for what was OWED. So a drift is repaired by correcting the local row — never by transferring again to match the journal, which would move real money to settle a bookkeeping disagreement. billing:merchants:reconcile reports and exits non-zero; it repairs nothing, deliberately.

SubscriptionStateChanged carries more than the state. occurredAt is the provider event's own timestamp, which is how a retried or out-of-order delivery is ignored instead of regressing a newer state. periodStart and periodEnd ride along because metered usage is accounted into the subscription's cycle, not a calendar month — an owner who renews on the 31st has no calendar month to bill into. Each is null when the provider conveys no such value, and a null tierKey means the change conveys no tier.

AddonRefunded carries the cumulative total, not the delta. The add-on ledger claws back only the part it has not already reversed, so two partial refunds and a redelivery each do the right thing. It is keyed on paymentReference — the payment id, not the checkout session — because that is what a refund event carries. A refund of anything that is not a tracked add-on matches no purchase and reverses nothing.

BillableAccountDeleting is present-continuous on purpose. The listener runs while the owner still exists, so the live subscription can be canceled at the provider before the row is gone. The package's own eraser dispatches it; an app with its own delete flow dispatches it itself, after re-confirming identity and before deleting the model. Skip it and a deleted account lingers as an active, still-charging subscription.

WebhookDeliveryRefused is a security signal, and its payload is deliberately thin. It carries the request's facts and never the body: the delivery did not verify, so the body is attacker-controlled input, and putting it on an event invites it into logs, queues and audit tables sized for trusted data. It also carries no reason — not which part of the signature failed, because that is a probing oracle, and not a coarse reason field either, since WebhookVerifier answers yes or no and a field with one possible value describes nothing while looking like it describes something.

And it carries no network address. This package hands an address to exactly one place — the argument of the IpCountryResolver you bind — and reads one nowhere else, because a second path is where an address leaks. An event is the worst of those paths: it travels into queued payloads, audit tables and exception context. If you want the caller's address in your own audit trail, take it from the request inside your listener; keeping it is then your decision, in your privacy notice, on your storage.

The dispatch is guarded, so a listener that throws cannot turn the endpoint's deliberate 400 into a 5xx that tells the sender to retry a request which can never become valid. The package also writes a warning to the log, so an install that wires no listener still sees the attempt. Nothing is recorded in the delivery ledger: that table holds provider payloads for replay, and filling it from unauthenticated traffic would let anybody who can reach the URL write rows into it.

UsageBacklogStalled is the alarm a successful exit code cannot raise. The flusher exits successfully during a provider outage, because a growing backlog is not a crash — but a backlog that never drains is lost revenue, since past the provider's acceptance window the usage is not retro-billed at all.

Deprecation aliases

InvoiceCredited was renamed to InvoiceCorrected: the old name conflated a correcting document with a self-billing credit note, which is a different document with a different type code.

For one deprecation window the old class still fires. InvoiceCorrected implements HasDeprecatedAlias, and the effect bus dispatches the alias through Laravel's dispatcher alongside the event — so an existing Event::listen(InvoiceCredited::class) keeps being called instead of going quiet, which is the worst outcome of a rename. The alias reaches host listeners only and is never re-run through the package's own effects, so nothing is persisted twice.

Migrate to InvoiceCorrected and read $event->correction. The old class and the alias firing go in a later release.

The shipped effects

An effect is an invokable class registered against a domain event, and each runs in its own queued job — so a slow or failing effect can neither hold the provider's request open nor take its siblings down with it. Idempotency is per effect, not per delivery, so replaying a delivery whose third effect failed re-runs only that one.

EventEffectWhat it does
PaymentSucceededReopenWriteOffOnLateReceiptRaises WriteOffRecovered when a payment matches exactly one reopenable write-off — never on an ambiguous match
SubscriptionStateChangedSyncPlanFromSubscriptionWrites the local subscription row and the owner's tier column
SubscriptionStateChangedSendSubscriptionActivatedNoticeTells the owner the subscription is live, once per subscription
SubscriptionStateChangedSendSubscriptionCanceledNoticeTells the owner when access ends, keyed on the grace state
PaymentSucceededSendPaymentReceiptTells the owner their money moved
PaymentSucceededSettleCycleOnPaymentCloses a billing cycle whose charge settled after the run that started it had finished, and advances the period. A bank debit is accepted at once and clears days later, so the cycle is held open until this arrives. Registered only where the package runs the billing cycle itself; a provider that runs its own answers through its own events
PaymentFailedFailCycleOnPaymentThe other direction of the same hold: a debit that was accepted and then bounced becomes a real failure, and dunning starts then rather than when the payment was created. Without it a bounced debit would leave the cycle held with no charge, no ladder and no notice
PaymentFailedSendDunningNoticeStarts or advances the dunning conversation
PaymentActionRequiredSendPaymentActionRequiredNoticeNudges the owner to confirm at their bank
AddonPurchasedCreditAddonPurchaseApplies the credit or the prepaid units, exactly once per purchase
AddonPurchasedGrantPurchasedContentWrites the ownership row when the thing bought was a work; the shipped content map answers "not a work" to everything, so it writes nothing until you say otherwise
RoutedSubscriptionInvoicePaidRecordRoutedSubscriptionChargeWrites the merchant-charge row for the cycle, one per invoice. A routed subscription is priced with a rate, so its commission exists once per cycle and only at the provider — this is the one effect that reads from one. An unrouted subscription stops at the local row and reaches nothing
AddonRefundedReverseAddonPurchaseClaws back the unspent part of the purchase
AddonRefundedRevokeAccessOnRefundEnds access to the refunded work, if your install says a refund should end access — on by default and genuinely switchable, because both answers are somebody's policy
AddonRefundedIssueLocalCreditNoteIssues the credit note for an invoice this package raised itself. A provider that issues its own documents announces the correction too, and that path is handled by PersistInvoiceCorrection; a local engine has nobody to announce it, so without this a refunded cycle leaves the books overstating turnover. It acts only where the refunded payment leads to a locally raised invoice, so it is silent on a provider-issued one. Amounts are positive — a correcting document's type inverts the accounting direction, not a minus sign — and the credited total is capped at the invoice it corrects.
InvoiceFinalizedPersistInvoiceWrites the immutable invoice record the e-invoice and DATEV exports render from
InvoiceCorrectedPersistInvoiceCorrectionWrites the correction row, linked to the invoice it corrects
InvoiceUpcomingFlushUpcomingUsageForce-flushes the usage outbox so a closing cycle is billed in that cycle
MandateRevokedRevokeMandateDrops the stored mandate so nothing is charged against it
MandateEstablishedStoreMandateStores the granted mandate so the engine can charge it; idempotent, and it claims default only when the owner holds none
MandateEstablishedStartSubscriptionOnMandateTurns a pending subscription intent into the subscription it was for. Registered only by a driver whose mandates arrive this way, and matched on paymentReference — a customer merely adding a second card grants a mandate too, and matching on the customer would hand them a subscription they never asked for
TrialEndingSendTrialEndingNoticeReminds the owner before the first charge
MerchantAccountUpdatedRefreshMerchantCapabilitiesStores the reported capabilities — the only path by which one ever moves
MerchantAccountDeauthorizedMarkMerchantDeauthorizedStamps when the platform lost reach, keeping the first date
MerchantTransferReversedByProviderRecordProviderTransferReversalRecords the provider's cumulative reversal onto the sale, never lowering a figure already booked
MerchantPayoutFailedRecordFailedMerchantPayoutWrites a merchant.payout_failed entry on the merchant's own audit trail — the answer to "I have not been paid". Idempotent by the payout id, so a redelivery adds nothing, while a second failed payout is recorded on its own. An account with no merchant on file is left alone
RoutedChargeConfirmedSettleRoutedChargeOnConfirmationSettles the matching pending routed charge; an event naming a payment this package did not route does nothing
RoutedChargeAbandonedSettleRoutedChargeOnConfirmationFails it instead, and only from pending
ChargebackReceivedRecordProviderFeeWrites the provider's dispute fee as its own row, once per dispute — it is an inbound supply, never a deduction
ChargebackReceivedRevokeAccessOnChargebackTakes back the access the disputed payment bought, on your configured coupling
ChargebackReceivedCorrectChainOnChargebackIssues the correcting documents the lost dispute owes — both legs where the supply was undone, the buyer's leg only where the creator delivered and the payment was disputed as fraud
ChargebackReceivedClaimChargebackClawbackClaims the merchant's share back and hands the provider call to a queued job — an effect cannot reach a provider from inside the transaction it runs in

Register your own the same way the shipped ones are registered, against the event rather than against a provider string:

use Pushery\Billing\Events\PaymentSucceeded;
use Pushery\Billing\Webhooks\WebhookEffectRegistry;

app(WebhookEffectRegistry::class)->on(PaymentSucceeded::class, NotifyAccounting::class);

Events with no shipped effect — SeatQuantityChanged, UsageBacklogStalled, UsageReconciliationDrift, ProviderJournalDrift, BuyerProtectionHoldReleased, BuyerProtectionHoldRefunded, BuyerProtectionResolutionRequired — are dispatched for your app to act on. A reconciliation drift in particular is a decision the package will not make for you.

ChargebackReceived used to be listed here and no longer belongs: four effects are registered against it, in the table above — and the money side is now among them. ClaimChargebackClawback takes the merchant's share back, so an app that reverses its own payout entry should do it from MerchantTransferReversed, which says what actually moved, rather than from the chargeback itself, which only says one was decided.

Two things are worth knowing before adding your own effect to this event. Registering a second fee recorder double-books the dispute fee. And a second effect that reverses the merchant's share reverses it twice — the provider idempotency key belongs to the attempt row this package writes, and a second claim gets a second key the provider has never seen.

Broadcast events

AccountBillingUpdated and AccountToastNotified are broadcast on the owner's private channel so the account-hub screens refresh live. Both are a no-op unless billing.realtime.enabled is on and a broadcaster is configured; without that the screens fall back to a bounded poll.

AccountBillingUpdated deliberately carries no payload — the client re-fetches — so nothing sensitive is on the wire.

The package dispatches both of them itself, from the webhook spine:

BroadcastRaised whenLevel
AccountBillingUpdatedthe plan sync applied a provider event that actually moved something — a state, a tier, a period
AccountToastNotifieda payment faileddanger
AccountToastNotifieda subscription went livesuccess

Two things follow from that, and both are deliberate.

A redelivery that changes nothing broadcasts nothing. Providers retry freely, and an event that moved no row must not make every open screen re-fetch.

The toast message arrives as a finished sentence in the owner's own language, resolved at dispatch against the owner's stored locale rather than the ambient one. A broadcast is raised from a webhook, where there is no request and therefore no locale but the application default — so unlike a notification, which Laravel localizes for you, a toast has to be translated where it is raised. The strings live under billing::account.toast.* and are publishable like the rest.

The severity changes key on its way to the browser

The broadcast payload is { message, level }. The headless bridge in the account hub receives it and re-dispatches it to the browser as a wirekit-toast event whose payload is { message, variant } — the same severity, under the name a toast region reads.

That rename is the one thing to know if you write your own listener: read event.detail.variant, not detail.level. An unrecognized key here does not fail loudly. It falls through to the default severity, so a failed payment renders in the neutral style and is announced to a screen reader without urgency — wrong in both channels, and visibly wrong in neither.

Something has to be listening, and by default that is not this package

The bridge DISPATCHES the event. It does not render toasts, and neither does anything else here unless you ask for it. Where the message actually appears is one of three answers, and choosing none of them is a silent no-op: the broadcast goes out, the event fires, and nothing is drawn.

  1. A WireKit host. WireKit renders its own toast region and reads exactly this event. Nothing to do.

  2. This package's minimal region. Set billing.realtime.render_toast_region to true (BILLING_REALTIME_TOAST_REGION=true). You get two aria-live containers — polite for info and success, assertive for warning and danger — and a small inline listener that appends the message and dismisses it after a few seconds. No UI kit, no build step. Leave it OFF on a WireKit host, or every toast appears twice.

  3. Your own listener, which is one line:

    window.addEventListener('wirekit-toast', (event) => {
    yourToaster(event.detail.message, event.detail.variant);
    });

event.detail.variant is always one of info, success, warning, danger — the bridge clamps an unknown or missing severity back to info rather than trusting the wire. event.detail.message is whatever your application broadcast, so render it as TEXT.


← Back to the documentation index