# Event catalog

Everything we can send you.

These are the exact events Southbill delivers to app webhook endpoints today. Anything not listed here is never sent — subscribe only to what exists.

## How to think about events

| Concept | Explanation |
| --- | --- |
| Event = past-tense fact | `payment_intent.succeeded` means it already happened. You react, you never approve. |
| One action can fire several events | A completed checkout fires `payment_intent.succeeded` **and** `checkout.session.completed`. Use the checkout event as your fulfilment trigger. |
| At-least-once delivery | The same event can arrive twice. Deduplicate on the event `id`. |
| No ordering guarantee | Events can arrive out of order. If order matters, re-fetch the object from the API. |
| Unknown types are safe to ignore | We add events without a breaking change. Never crash on an unfamiliar `type`. |
| Subscription is per endpoint | Unsubscribed events are not delivered and not stored. `*` and `checkout.*` wildcards are supported. |

## The envelope

Every delivery has the same outer shape; only `type` and `data.object` differ.

```json
{
  "id": "evt_9f1c2ab34d5e6f70",
  "object": "event",
  "type": "checkout.session.completed",
  "environment": "live",
  "installation": "3f0c…-uuid",
  "created": 1755423672,
  "data": { "object": { "…": "resource payload" } }
}
```

| Field | What it is | Why it matters |
| --- | --- | --- |
| `id` | Unique event ID, prefix `evt_` | Your idempotency key. Store it and skip duplicates. |
| `object` | Always `"event"` | Lets you store raw payloads generically. |
| `type` | What happened | The only field you should branch on. |
| `environment` | `test` or `live` | Never let a sandbox event touch production data. |
| `installation` | The installation this event belongs to | Routes the event to the right tenant in your app. |
| `created` | **Unix timestamp in seconds** (not RFC 3339) | Use it to resolve out-of-order arrivals. |
| `data.object` | The resource payload at the time of the event | Shape depends on `type` — see [Event payloads](/docs/dev/dev-webhooks/payloads). |

Delivery headers: `Southbill-Signature`, `Southbill-Event-Id`, `Southbill-Event-Type`, `Southbill-Environment`, `Southbill-Delivery-Attempt`.

---

## Checkout

The checkout session is the object your app should treat as the order.

| Event | What it means | What you should do |
| --- | --- | --- |
| `checkout.session.completed` | The shopper paid and the session flipped to `complete` | **Fulfil the order here.** Fired exactly once per session. |
| `checkout.session.expired` | The session timed out unpaid | Release reserved stock, optionally send a recovery mail. |
| `checkout.session.canceled` | The shopper or the merchant cancelled before payment | Close the order; `cancellation_reason` may be set. |
| `checkout.session.async_payment_pending` | A delayed method (e.g. bank debit) is processing | Show "payment pending"; do not ship yet. |
| `checkout.session.async_payment_failed` | The delayed payment failed | `failure_code` / `failure_message` explain why. |
| `checkout.session.refunded` | The related charge was refunded | `amount_refunded` and `fully_refunded` tell you how much. |

---

## Payments (payment intents)

Lower-level than checkout — use these only if you need the payment view.

| Event | What it means | What you should do |
| --- | --- | --- |
| `payment_intent.succeeded` | Funds captured | Mark paid. Prefer `checkout.session.completed` for order fulfilment. |
| `payment_intent.payment_failed` | Declined; `failure_code`, `decline_code`, `failure_message` explain it | Offer a retry; never retry silently. |
| `payment_intent.requires_action` | Waiting on the shopper (3-D Secure, app confirmation) | Nothing to do server-side; the shopper continues in checkout. |
| `payment_intent.canceled` | The payment was cancelled or abandoned | Free reserved inventory. |

---

## Billing

| Event | What it means | What you should do |
| --- | --- | --- |
| `invoice.payment_failed` | A subscription invoice could not be collected; dunning continues | Warn the customer — don't cut access on the first failure. |
| `customer.subscription.deleted` | A subscription ended | Revoke entitlements. |

There are currently no `invoice.created` / `invoice.paid` / `subscription.created` app events. Poll `/v1/invoices` and `/v1/subscriptions` if you need that state.

---

## Not available yet

Standalone `refund.*`, `dispute.*`, `payout.*` and `customer.*` events are **not** delivered to apps. Read that data through the App API instead:

| You want | Use |
| --- | --- |
| Refund state | `checkout.session.refunded` event + `GET /v1/refunds` |
| Dispute state | `GET /v1/disputes` (no dispute events are pushed to apps) |
| Payouts | `GET /v1/payouts` (restricted scope) |
| Customers | fields on the payment/invoice objects |

---

## Minimal handler

```js
export async function POST(req) {
  const raw = await req.text();
  if (!verifySignature(raw, req.headers.get("southbill-signature"), SECRET)) {
    return new Response("bad signature", { status: 400 });
  }
  const event = JSON.parse(raw);
  if (await seen(event.id)) return new Response("ok");   // dedupe
  await store(event.id);
  switch (event.type) {
    case "checkout.session.completed": await fulfil(event.data.object); break;
    case "checkout.session.refunded":  await refund(event.data.object); break;
    default: break;                                      // ignore unknown types
  }
  return new Response("ok");                             // 2xx within 10s
}
```

See [Signatures & retries](/docs/dev/dev-webhooks/signatures).
