Better Payment

Payment Events

React to payment outcomes with one typed listener, whichever provider or flow produced them.

Added in 0.5.0

Every operation that changes a payment emits an event: a 3D Secure callback, a PayTR notification, a refund made from your admin panel. Listen to the events instead of checking each result, and update your orders in one place.

payment.on('payment.succeeded', async (event) => {
  await orders.markPaid(event.conversationId, event.paymentId);
});

payment.on('refund.succeeded', async (event) => {
  await orders.addRefund(event.paymentId, event.amount);
});

// Every event
const off = payment.on('*', (event) => console.log(event.type, event.provider));
off(); // removes the listener

Events are emitted for every provider, and for calls made on the payment object, on a provider, and through the HTTP handler.

Events

EventEmitted when
payment.succeededcreatePayment, completeThreeDSPayment or capture succeeds
payment.authorizedauthorize succeeds: the amount is blocked on the card
payment.pendingcreatePayment, completeThreeDSPayment, authorize or capture returns pending, for example with NETWORK_ERROR
payment.faileda payment operation, initThreeDSPayment or initThreeDSAuthorize fails
payment.cancelledcancel or voidAuthorization succeeds
refund.succeededrefund succeeds
refund.failedrefund fails

A 3D Secure pre-authorization is completed with completeThreeDSPayment, so it emits payment.succeeded when the amount is blocked. Status queries (getPayment) and card operations emit no events.

Events are emitted only for results the provider verified. A forged or tampered callback (errorCode: 'INVALID_HASH') emits nothing.

The event

interface PaymentEvent {
  type: PaymentEventType;       // 'payment.succeeded', ...
  provider: string;             // the provider id, e.g. 'iyzico'
  operation: PaymentOperation;  // 'createPayment', 'completeThreeDSPayment', ...
  paymentId?: string;           // the provider's payment id, when known
  conversationId?: string;      // your order id, when known
  amount?: string;              // charged or refunded amount, from the request
  currency?: string;
  code?: PaymentErrorCode;      // normalized error code of failures
  result: unknown;              // the result returned to the caller
  request: unknown;             // the request (the callback data for 3D Secure completions)
}

The listener of a specific event gets the narrowed type: in payment.on('payment.failed', (event) => ...), event.type is 'payment.failed'.

amount and currency come from the request. The callback of a 3D Secure payment carries no amount, so they are missing there: look the order up by conversationId.

When a listener fails

Listeners run in order, after the operation, and are awaited. If one throws, the operation throws an EventListenerError. The payment itself went through, so the error carries the result:

import { EventListenerError } from 'better-payment';

try {
  await payment.createPayment(request);
} catch (error) {
  if (error instanceof EventListenerError) {
    error.result; // the payment result
    error.event;  // the event whose listener failed
    error.cause;  // what the listener threw
  }
}

The HTTP handler handles it for you:

  • Provider callbacks (payment/complete-3ds, callback) answer 500. The provider re-sends the callback (PayTR retries its notification), and the listeners run again with the stored result, without calling the provider again. Events are emitted right before onCallback, so both are retried together.
  • Other routes (payment, refund, ...) answer with the result as usual, and the error is logged with your logger. The payment happened: an error response could make the client pay twice.

Write listeners so that running them twice is harmless, for example by checking the order's state before updating it.

Events in plugins

A plugin declares its listeners in events. For each event, the listeners of that event run first, then the * listeners. In both groups, plugin listeners run in the order of the plugins, before the listeners added with payment.on():

import { definePlugin } from 'better-payment';

export const slackAlerts = (webhookUrl: string) =>
  definePlugin({
    id: 'slack-alerts',
    events: {
      'payment.failed': async (event) => {
        await fetch(webhookUrl, {
          method: 'POST',
          body: JSON.stringify({ text: `Payment failed on ${event.provider}: ${event.code}` }),
        });
      },
    },
  });

Duplicate deliveries

Providers can send the same callback more than once. The HTTP handler deduplicates callbacks, so a repeated delivery emits no new event. When you call completeThreeDSPayment() yourself, deduplicate in your listener.

On this page