# 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.created` | An invoice was created | Mirror it; nothing is collected yet. |
| `invoice.sent` | The invoice was sent to the customer | Track dunning state in your app. |
| `invoice.finalized` | The invoice is locked and collectible | Safe to render as an official document. |
| `invoice.paid` / `invoice.payment_succeeded` | The invoice was collected | Grant entitlements. |
| `invoice.payment_failed` | Collection failed; dunning continues | Warn the customer — don't cut access on the first failure. |
| `invoice.payment_action_required` | The customer must authenticate (3-D Secure) | Ask the customer to finish the payment. |
| `invoice.upcoming` | A renewal invoice is coming up | Hook for pre-billing notices. |
| `invoice.marked_uncollectible` / `invoice.voided` | Written off or voided | Stop dunning. |
| `subscription.created` / `.updated` | Subscription started or changed | Sync plan, quantity, period end. |
| `customer.subscription.trial_will_end` | Trial ends soon | Nudge for a payment method. |
| `customer.subscription.paused` / `.resumed` | Billing paused or resumed | Suspend/restore entitlements. |
| `customer.subscription.deleted` | A subscription ended | Revoke entitlements. |
| `customer.created` / `.updated` / `.deleted` | Customer record changed | Keep your mirror in sync. |

---

## Refunds, disputes and payouts

These arrive as standalone events — no polling required.

| Event | What it means |
| --- | --- |
| `charge.refunded` | The refunded total of a charge changed (full or partial). |
| `refund.created` / `.updated` / `.failed` | Lifecycle of a single refund. |
| `charge.dispute.created` | A chargeback was opened; `evidence_due_by` is the deadline. |
| `charge.dispute.updated` | Status or evidence state changed. |
| `charge.dispute.closed` | Final outcome (`won` / `lost`). |
| `charge.dispute.funds_withdrawn` / `.funds_reinstated` | Disputed funds debited or returned. |
| `checkout.session.dispute.created` / `.closed` | The same chargeback, expressed on the checkout session. |
| `payout.created` / `.updated` / `.paid` | Payout lifecycle to the merchant's bank account. |
| `payout.failed` / `.canceled` | The payout did not arrive — see `failure_code`. |
| `payout.reconciliation_completed` | The payout is fully reconciled and safe to book. |

Payout events require the restricted `payouts:read` scope.

---

## Southbill Store (orders & catalogue)

Store events are **separate from `checkout.session.*` on purpose**: `checkout.session.completed` means "money arrived", `order.paid` means "a Southbill Store order exists and must be fulfilled". Invoice money never appears here — it uses `invoice.*`.

| Event | What it means | What you should do |
| --- | --- | --- |
| `order.created` | A store order row was created (also via the Store API) | Mirror the order; it may still be unpaid. |
| `order.paid` | The store order was paid | **Fulfil here.** `product`, `variant`, `order_number` are set. |
| `order.updated` | Status or tracking changed (e.g. shipped) | Sync status, `tracking_carrier` / `tracking_number` / `tracking_url`. |
| `order.refunded` | The order was refunded | `amount_refunded`, `fully_refunded`. |
| `order.canceled` | The order was cancelled | Release stock, close your record. |
| `product.created` / `product.updated` | Catalogue changed (dashboard or Store API) | Re-fetch the product incl. variants and prices. |

The order payload is `object: "store.order"` with `source: "store"`; `id` and `payment` both hold the checkout session ID. Requires the `store:read` scope.

---

## Lifecycle

| Event | What it means |
| --- | --- |
| `app.installed` | A merchant installed your app (first OAuth grant). |
| `app.reinstalled` | An existing installation was re-authorized. |
| `app.uninstalled` | The merchant removed your app — tokens are already revoked. Delete their data within 30 days. |

---

## 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).
