Webhooks

Receive real-time event notifications.

Receive real-time notifications when events happen in your account. Configure webhook endpoints in your workspace settings.

Event types

EventDescription
payment.completedA payment was successfully processed
payment.refundedA transaction was refunded (full or partial)
payment.voidedA pending transaction was voided
payment.failedA pending payment was rejected by the network (Debit & Payouts)
payment.disputedA settled payment was disputed — chargeback or ACH return (Debit & Payouts)
dispute.createdA dispute or pre-chargeback alert was received
dispute.updatedA dispute changed status
dispute.evidence_submittedA chargeback response was submitted to the network
dispute.wonA fought chargeback was decided in your favor
dispute.lostA chargeback was decided for the cardholder
payout.createdA payout was created and accepted for processing
payout.updatedA payout was approved and is in transit
payout.paidA payout settled — the recipient has the funds
payout.failedA payout was rejected by the network before settlement
payout.returnedA settled payout was sent back by the receiving bank (ACH return, e.g. R03)
subscription.createdA subscription was created via the API
subscription.renewedA subscription was automatically renewed
subscription.cancel_scheduledA subscription is scheduled to cancel at period end
subscription.cancelledA subscription was cancelled
subscription.pausedA subscription was paused
subscription.resumedA paused subscription was resumed
subscription.scheduledA subscription was created with a future start date
subscription.activatedA scheduled subscription has activated and started billing

Payload structure

All webhook payloads follow this envelope structure. The data object varies by event type.

JSON
{
  "id": "event-uuid",
  "type": "payment.completed",
  "created": 1705312200,
  "data": { ... }
}

payment.completed

Fired when a payment is successfully processed, from all three ways to take one: a payment link, a checkout session, or a direct POST /charges. Includes the originating payment link, checkout session info (if created via API), metadata, and any subscription IDs created. A direct charge sends source: "api" with no payment_link_id or checkout_session_id, since it has neither. On Paysio Debit & Payouts workspaces this event fires at settlement — possibly minutes to days after the charge was accepted. Everywhere else it fires as soon as the charge is approved.

JSON
{
  "id": "evt_abc123",
  "type": "payment.completed",
  "created": 1705312200,
  "data": {
    "transaction_id": "txn_def456",
    "amount": 2700,
    "currency": "USD",
    "status": "pending_settlement",
    "customer_email": "[email protected]",
    "order_number": 1042,
    "payment_link_id": "pl_ghi789",
    "source": "api",
    "checkout_session_id": "pl_ghi789",
    "metadata": { "workspace_id": "ws_123", "plan": "basic" },
    "subscription_ids": ["sub_jkl012"]
  }
}
transaction_idstring
The transaction ID
amountinteger
Amount in cents
currencystring
Currency code (e.g. "USD")
statusstring
Transaction status
customer_emailstring
Customer's email
order_numberinteger
Order number
customer_idstring
Customer ID, when the charge is linked to one
payment_link_idstring
The payment link ID (checkout only)
sourcestring
"dashboard", or "api" for a checkout session or a direct charge
checkout_session_idstring
Checkout session ID (checkout sessions only)
processorstring
Processor that took the payment (direct charges)
railstring
"card" or "ach" (direct charges)
metadataobject
Metadata from the checkout session or charge
subscription_idsstring[]
Created subscription IDs (if recurring)

payment.refunded

Fired when a transaction is refunded (full or partial).

JSON
{
  "id": "evt_abc123",
  "type": "payment.refunded",
  "created": 1705312200,
  "data": {
    "transaction_id": "txn_def456",
    "amount": 2700,
    "currency": "USD",
    "refunded_amount": 1000,
    "total_refunded": 1000,
    "payment_link_id": "pl_ghi789"
  }
}

payment.voided

Fired when a pending (unsettled) transaction is voided.

JSON
{
  "id": "evt_abc123",
  "type": "payment.voided",
  "created": 1705312200,
  "data": {
    "transaction_id": "txn_def456",
    "amount": 2700,
    "currency": "USD",
    "payment_link_id": "pl_ghi789"
  }
}

payment.failed

Fired on Debit & Payouts workspaces when a pending payment (card pull or ACH debit) is rejected by the network. On these workspaces payment.completed fires at settlement, so a charge that returned pending_settlement always ends in exactly one of the two events.

JSON
{
  "id": "evt_abc123",
  "type": "payment.failed",
  "created": 1705312200,
  "data": {
    "transaction_id": "txn_def456",
    "amount": 2999,
    "currency": "USD",
    "status": "failed",
    "rail": "card",
    "error_code": "T002",
    "error_message": "The card was declined by the issuing bank.",
    "processor": "aptpay"
  }
}

payment.disputed

Fired on Debit & Payouts workspaces when a settled payment is disputed — a card chargeback or an ACH return. dispute_kind is "chargeback" or "ach_return".

JSON
{
  "id": "evt_abc123",
  "type": "payment.disputed",
  "created": 1705312200,
  "data": {
    "transaction_id": "txn_def456",
    "amount": 2999,
    "currency": "USD",
    "status": "disputed",
    "rail": "ach",
    "dispute_kind": "ach_return",
    "dispute_code": "R01",
    "dispute_reason": "Insufficient funds",
    "processor": "aptpay"
  }
}

dispute.created / updated / evidence_submitted / won / lost

The full dispute lifecycle, across every kind of case: pre-chargeback alerts (Ethoca, CDRN, Visa RDR), chargebacks, TC40 fraud reports, ACH returns, and reversals. dispute.created fires when a case is received, dispute.updated on any status change, dispute.evidence_submitted when a chargeback response goes to the network, and dispute.won / dispute.lost when the issuer decides. All five share the same data payload. Retrieve the full case with GET /disputes/:id. The legacy payment.disputed event is unchanged and still fires alongside these for processor-reported chargebacks and ACH returns.

JSON
{
  "id": "evt_abc123",
  "type": "dispute.created",
  "created": 1705312200,
  "data": {
    "dispute_id": "dsp_def456",
    "transaction_id": "txn_ghi789",
    "kind": "chargeback",
    "status": "new",
    "network": "visa",
    "reason": "Fraud — card absent environment",
    "reason_code": "10.4",
    "amount": 2999,
    "currency": "USD",
    "card_last4": "4242",
    "due_at": "2026-02-01T00:00:00.000Z",
    "sandbox": false
  }
}
dispute_idstring
The dispute ID — retrieve it with GET /disputes/:id
transaction_idstring
The matched transaction ID (null while unmatched)
kindstring
"alert_ethoca", "alert_cdrn", "alert_rdr", "chargeback", "tc40", "ach_return", or "reversal"
statusstring
"new", "matched", "unmatched", "refunded", "accepted", "fighting", "response_submitted", "won", "lost", or "expired"
networkstring
Card network (e.g. "visa"), when known
reasonstring
Human-readable dispute reason
reason_codestring
Network reason code (e.g. "10.4")
amountinteger
Disputed amount in cents
currencystring
Currency code
card_last4string
Last four digits of the disputed card
due_atstring
Response deadline (ISO 8601), when the network set one
sandboxboolean
Whether this event is from test mode

payout.created / updated / paid / failed / returned

The payout lifecycle on Debit & Payouts workspaces. payout.created fires when POST /payouts accepts the payout, payout.updated when it is approved and in transit, then exactly one of payout.paid or payout.failed. A settled ACH payout can later be sent back by the receiving bank, which fires payout.returned. All five share the same data payload — the payout object from the Payouts API.

JSON
{
  "id": "evt_abc123",
  "type": "payout.paid",
  "created": 1705312200,
  "data": {
    "id": "po_uuid",
    "object": "payout",
    "payout_number": 17,
    "amount": 5000,
    "currency": "USD",
    "status": "paid",
    "rail": "card",
    "rtp_network": null,
    "card_last_four": "5702",
    "card_brand": "mastercard",
    "descriptor": "ACME PAY",
    "customer_id": "cus_uuid",
    "purpose": "payout",
    "refund_for_transaction_id": null,
    "reference_id": "payout-2026-001",
    "error_code": null,
    "error_message": null,
    "returned": false,
    "returned_at": null,
    "return_code": null,
    "return_reason": null,
    "metadata": { "invoice": "inv_123" },
    "livemode": true,
    "created_at": "2026-01-15T10:30:00.000Z",
    "updated_at": "2026-01-15T10:31:12.000Z"
  }
}

payout.failed carries the failure details:

JSON
{
  "id": "evt_abc123",
  "type": "payout.failed",
  "created": 1705312200,
  "data": {
    "id": "po_uuid",
    "object": "payout",
    "amount": 5000,
    "currency": "USD",
    "status": "failed",
    "rail": "card",
    "error_code": "M009",
    "error_message": "This card cannot receive push-to-card payments.",
    "returned": false,
    "returned_at": null,
    "return_code": null,
    "return_reason": null,
    "customer_id": "cus_uuid",
    "purpose": "payout",
    "metadata": {},
    "livemode": true
  }
}

payout.failed and payout.returned are not the same event

payout.failed means the money never left: the network rejected the payout before settlement and the reserved amount is back on your balance. payout.returned means it left, settled, and the receiving bank sent it back days later. Treating them alike is how a recipient gets credited twice.

payout.returned fires once, when the return is recorded. Note that status stays "paid" and error_code stays null — the return is reported in returned, returned_at, return_code and return_reason:

JSON
{
  "id": "evt_abc123",
  "type": "payout.returned",
  "created": 1705312200,
  "data": {
    "id": "po_uuid",
    "object": "payout",
    "payout_number": 19,
    "amount": 19600,
    "currency": "USD",
    "status": "paid",
    "rail": "ach",
    "error_code": null,
    "error_message": null,
    "returned": true,
    "returned_at": "2026-08-13T22:52:00.000Z",
    "return_code": "R03",
    "return_reason": "NO ACCOUNT FOUND",
    "customer_id": "cus_uuid",
    "purpose": "payout",
    "metadata": {},
    "livemode": true
  }
}

subscription.created

Fired when a subscription is created via the POST /subscriptions API endpoint.

JSON
{
  "id": "evt_abc123",
  "type": "subscription.created",
  "created": 1705312200,
  "data": {
    "subscription_id": "sub_new123",
    "customer_id": "cus_abc456",
    "product_id": "prod_def789",
    "transaction_id": "txn_ghi012",
    "amount": 2700,
    "currency": "USD",
    "metadata": { "plan": "basic" }
  }
}

subscription.renewed

Fired when a subscription is automatically renewed and the renewal charge succeeds.

JSON
{
  "id": "evt_abc123",
  "type": "subscription.renewed",
  "created": 1705312200,
  "data": {
    "subscription_id": "sub_jkl012",
    "transaction_id": "txn_mno345",
    "amount": 2700,
    "currency": "USD"
  }
}

subscription.cancel_scheduled

Fired when a subscription is scheduled to cancel at the end of the current billing period.

JSON
{
  "id": "evt_abc123",
  "type": "subscription.cancel_scheduled",
  "created": 1705312200,
  "data": {
    "subscription_id": "sub_jkl012",
    "reason": "Switching to basic plan",
    "cancel_at": "2026-05-11T00:00:00.000Z"
  }
}

subscription.cancelled

Fired when a subscription is cancelled (immediately or at end of scheduled period).

JSON
{
  "id": "evt_abc123",
  "type": "subscription.cancelled",
  "created": 1705312200,
  "data": {
    "subscription_id": "sub_jkl012",
    "reason": "Customer requested cancellation"
  }
}

subscription.paused

Fired when a subscription is paused.

JSON
{
  "id": "evt_abc123",
  "type": "subscription.paused",
  "created": 1705312200,
  "data": {
    "subscription_id": "sub_jkl012"
  }
}

subscription.resumed

Fired when a paused subscription is resumed.

JSON
{
  "id": "evt_abc123",
  "type": "subscription.resumed",
  "created": 1705312200,
  "data": {
    "subscription_id": "sub_jkl012"
  }
}

subscription.scheduled

Fired when a subscription is created with a future start_date. The subscription will not be charged until the start date arrives.

JSON
{
  "id": "evt_abc123",
  "type": "subscription.scheduled",
  "created": 1705312200,
  "data": {
    "subscription_id": "sub_new123",
    "customer_id": "cus_abc456",
    "product_id": "prod_def789",
    "start_date": "2026-06-11T00:00:00.000Z",
    "metadata": { "plan": "advanced" }
  }
}

subscription.activated

Fired when a scheduled subscription reaches its start date, is charged for the first time, and becomes active.

JSON
{
  "id": "evt_abc123",
  "type": "subscription.activated",
  "created": 1705312200,
  "data": {
    "subscription_id": "sub_new123",
    "customer_id": "cus_abc456",
    "product_id": "prod_def789",
    "transaction_id": "txn_first456",
    "amount": 8700,
    "currency": "USD",
    "metadata": { "plan": "advanced" }
  }
}

Verifying signatures

Every delivery includes a Paysio-Signature header (and a Paysio-Event header). Verify it with HMAC-SHA256 over `${t}.${rawBody}` using your endpoint's signing secret (the whsec_… value).

Use the raw request body

Sign the bytes exactly as received — do not JSON.parse then re-stringify first, or the signature will never match. In Express, express.json() consumes the raw body before your handler runs, so capture it with express.raw() on the webhook route. A 401 from your endpoint is almost always this.

JavaScript
const crypto = require('crypto')

// Capture the RAW body ONLY on the webhook route (not app-wide express.json()).
app.post('/webhooks/paysio',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.header('Paysio-Signature') || ''
    const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
    const raw = req.body // a Buffer — the exact bytes Paysio signed

    const expected = crypto
      .createHmac('sha256', process.env.PAYSIO_WEBHOOK_SECRET)
      .update(`${parts.t}.${raw}`)
      .digest('hex')

    const ok = parts.v1 &&
      crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
    if (!ok) return res.status(401).send('invalid signature')

    const event = JSON.parse(raw.toString('utf8'))
    // ... handle event.type / event.data ...

    res.sendStatus(200) // ACK fast — only 2xx counts as delivered
  })

Retry policy

Failed deliveries (non-2xx response or timeout) are retried up to 3 times with exponential backoff:

  • Attempt 1: immediate
  • Attempt 2: after 5 minutes
  • Attempt 3: after 30 minutes

Deliveries time out after 10 seconds. Your endpoint should return a 2xx status quickly and process the event asynchronously if needed.