> ## Documentation Index
> Fetch the complete documentation index at: https://docs.9pic.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Purchase Webhook

> Payload reference for the checkout purchase webhook, written for building a tax invoice

**9Pic Checkout** can POST every completed purchase to an endpoint you control. This page is the wire-format reference for that request, written for the case that motivates most integrations: turning a purchase into a **tax invoice** in your own accounting system.

Unlike the rest of this reference, this is not an endpoint you call. 9Pic calls **you**, so the shared [Conventions](/api-reference/conventions) — base URL, `X-API-Key`, and the unified `responseType` / `message` / `data` envelope — do **not** apply. The body documented below is the entire request body.

## Enabling It

<Steps>
  <Step title="Add your endpoint">
    In the dashboard, open **Payment Configuration → Purchase webhook**. Save your HTTPS URL, and optionally an authorization header name and token. The URL and token are stored once for the whole organisation.
  </Step>

  <Step title="Turn it on per event">
    Open the event's **Checkout** configuration. The purchase webhook is on by default once the organisation URL is saved. Turn the switch off if you do not want that event to send purchases.
  </Step>
</Steps>

<Note>
  Your endpoint must be reachable over **HTTPS** on a public host. URLs that use `http://`, embed credentials, or resolve to a private, loopback, or link-local address are rejected when you save them and again before every delivery.
</Note>

## The Request

```http theme={null}
POST https://your-endpoint.example.com/hooks/9pic
Content-Type: application/json
User-Agent: 9Pic-Checkout-Webhook/1.0
X-Webhook-Event-Id: 09897b96-5ad4-43ca-9434-9c89a0b5cc4d
X-Webhook-Event-Type: checkout.payment.completed
```

If you configured an authorization header, it is sent verbatim with the value you saved — for example `Authorization: Bearer sk_live_...`. Check it on every request; treat a mismatch as a `401`.

| Behaviour | Detail                                                                                                                 |
| --------- | ---------------------------------------------------------------------------------------------------------------------- |
| Method    | `POST`, JSON body, always UTF-8                                                                                        |
| Success   | Any **2xx**. `200`, `201`, and `204` are all treated as delivered                                                      |
| Failure   | Any other status, a connection error, or a timeout                                                                     |
| Redirects | Not followed. A `3xx` counts as a failure                                                                              |
| Timeout   | The first attempt is bounded at roughly 3 seconds; later attempts allow longer                                         |
| Retries   | Failed deliveries are retried automatically a few times with backoff, then stop. The purchase itself is never affected |

<Tip>
  Return `2xx` as soon as you have durably stored the body, and do your invoice work afterwards. Slow endpoints get retried, which means duplicates you then have to collapse.
</Tip>

## Identifiers, and Which One to Key On

This is the part worth reading twice, because the two ids answer different questions.

| Field               | Scope                    | Changes when                                                             |
| ------------------- | ------------------------ | ------------------------------------------------------------------------ |
| `webhook_event_id`  | One **delivery attempt** | A retry keeps it; a manual **Resend** from the dashboard mints a new one |
| `data.reference_id` | One **purchase**         | Never                                                                    |

`data.reference_id` is derived from the order and payment records and has the form `9pic-<order_id>-<payment_id>`. Every delivery of the same purchase carries the same value — automatic retries and dashboard resends included.

<Warning>
  Key your invoice on `data.reference_id`, not on `webhook_event_id`. An organiser who clicks **Resend webhook** after fixing an outage sends the same purchase with a *new* `webhook_event_id`; keying on that would issue a second invoice for money you were only paid once.
</Warning>

Use `webhook_event_id` for what it is good at: correlating your logs with 9Pic's delivery history when you are debugging a specific attempt.

## Payload

```json theme={null}
{
  "event_type": "checkout.payment.completed",
  "webhook_event_id": "09897b96-5ad4-43ca-9434-9c89a0b5cc4d",
  "version": "1.0",
  "sent_at": "2026-08-30T08:34:01.377134+00:00",
  "data": {
    "reference_id": "9pic-132-69",
    "order": {
      "id": 132,
      "state": "completed",
      "sale_phase": "post_event",
      "created_at": "2026-08-30T08:24:55.517991+00:00"
    },
    "event": { "id": 11, "name": "Demo NewPaymentFlow" },
    "organiser": { "id": 2, "name": "Demo Pulse" },
    "buyer": {
      "email": "buyer@example.com",
      "name": "Ganesh Gole",
      "user_id": "user_3BZSLxPwQdPRXN3N69TijOyPPE4"
    },
    "payment": {
      "id": 69,
      "state": "completed",
      "gateway": "afs",
      "gateway_order_id": "afs_132_1e9d1d29",
      "gateway_payment_id": "trans-355",
      "amount_minor": 21120000,
      "currency_code": "bhd",
      "currency_exponent": 3,
      "amount": "21120.000",
      "paid_at": "2026-08-30T08:25:29.253365+00:00"
    },
    "amounts": {
      "currency_code": "bhd",
      "currency_exponent": 3,
      "subtotal_minor": 24000000,
      "subtotal": "24000.000",
      "discount_minor": 4800000,
      "discount": "4800.000",
      "taxable_amount_minor": 19200000,
      "taxable_amount": "19200.000",
      "tax_amount_minor": 1920000,
      "tax_amount": "1920.000",
      "total_minor": 21120000,
      "total": "21120.000"
    },
    "tax": {
      "name": "VAT",
      "registration_id": "XYZ1234",
      "percentage": "10.00",
      "mode": "exclusive",
      "base_amount_minor": 19200000,
      "base_amount": "19200.000",
      "tax_amount_minor": 1920000,
      "tax_amount": "1920.000",
      "total_amount_minor": 21120000,
      "total_amount": "21120.000"
    },
    "discounts": [
      {
        "type": "volume_tier",
        "description": "20% off 20 or more items",
        "amount_minor": 4800000,
        "amount": "4800.000"
      }
    ],
    "items": [
      {
        "product_type": "photo",
        "item_count": 29,
        "is_pass": false,
        "priced_count": 24,
        "unit_price_minor": 1000000,
        "unit_price": "1000.000",
        "pricing_model": "per_photo"
      }
    ],
    "coupon": null
  }
}
```

### Envelope

| Field              | Type                   | Description                                                                                    |
| ------------------ | ---------------------- | ---------------------------------------------------------------------------------------------- |
| `event_type`       | string                 | Always `checkout.payment.completed` today. Branch on it so future event types do not break you |
| `webhook_event_id` | string (UUID)          | Identifies this delivery attempt. Also sent as the `X-Webhook-Event-Id` header                 |
| `version`          | string                 | Payload version. Bumped only for breaking changes — new fields can appear at any time          |
| `sent_at`          | string (ISO 8601, UTC) | When this attempt was built. Not the payment time — use `data.payment.paid_at` for that        |
| `data`             | object                 | Everything below                                                                               |

### `data.order`, `data.event`, `data.organiser`, `data.buyer`

| Field                             | Type            | Description                                                          |
| --------------------------------- | --------------- | -------------------------------------------------------------------- |
| `order.id`                        | number          | 9Pic order id                                                        |
| `order.state`                     | string          | `completed` for this event type                                      |
| `order.sale_phase`                | string \| null  | `post_event` or `pre_event`, from the plan the buyer purchased under |
| `order.created_at`                | string \| null  | When the order was created                                           |
| `event.id` / `event.name`         | number / string | The event the photos belong to                                       |
| `organiser.id` / `organiser.name` | number / string | The selling organisation — the **supplier** on the invoice           |
| `buyer.email`                     | string          | Buyer email. The billing contact                                     |
| `buyer.name`                      | string          | Buyer full name. Empty string when the buyer never supplied one      |
| `buyer.user_id`                   | string          | Stable 9Pic account id for the buyer                                 |

<Note>
  `organiser` carries the trading name only. The supplier tax registration number for the invoice comes from `data.tax.registration_id`.
</Note>

### `data.payment`

| Field                        | Type           | Description                                                                               |
| ---------------------------- | -------------- | ----------------------------------------------------------------------------------------- |
| `payment.id`                 | number         | 9Pic payment record id                                                                    |
| `payment.state`              | string         | `completed`                                                                               |
| `payment.gateway`            | string         | Which gateway captured the money — `stripe`, `afs`, `xendit`, `paymongo`, `razorpay`      |
| `payment.gateway_order_id`   | string         | The gateway's order / session / intent reference                                          |
| `payment.gateway_payment_id` | string         | The gateway's transaction reference. Use this to reconcile against your settlement report |
| `payment.amount_minor`       | number         | Amount actually captured, in minor units                                                  |
| `payment.currency_code`      | string         | ISO 4217 code. Case is not normalised — compare case-insensitively                        |
| `payment.currency_exponent`  | number         | Decimal places for this currency. See [Money and Currency](#money-and-currency)           |
| `payment.amount`             | string         | `amount_minor` rendered in major units, as a decimal string                               |
| `payment.paid_at`            | string \| null | When the gateway confirmed payment. **This is the invoice date**                          |

## Money and Currency

Every monetary value appears twice: `*_minor` as an integer, and the same value as a decimal **string** in major units.

**Always compute from the `_minor` integers.** They are exact. The string form is a convenience for display and for feeding a decimal type; never parse it into a binary float, or you will lose fractions of a currency unit on large orders.

### What `currency_exponent` Means

`currency_exponent` is how many decimal places the currency has — the power of ten between the minor unit and the major unit:

```
major = amount_minor / 10 ** currency_exponent
```

Currencies do not all use two decimals, which is exactly why the exponent travels with the amount instead of being assumed:

| Currency            | `currency_exponent` | `amount_minor` | Major value | Minor unit                       |
| ------------------- | ------------------- | -------------- | ----------- | -------------------------------- |
| `usd`, `inr`, `php` | 2                   | `49900`        | `499.00`    | cent / paisa / centavo           |
| `bhd`, `kwd`, `omr` | 3                   | `21120000`     | `21120.000` | fils                             |
| `jpy`, `krw`        | 0                   | `5000`         | `5000`      | none — the yen is the minor unit |

The example above is Bahraini dinar with exponent `3`, so `21120000` fils is **21120.000 BHD** — not 211,200.00. Hardcoding two decimals would misprice it by a factor of ten.

<Warning>
  Do not assume `currency_exponent` is `2`, and do not derive it from a lookup table of your own. Read it from the payload — it is stored per payment, so it stays correct even if a gateway or currency is added later.
</Warning>

<CodeGroup>
  ```python Python theme={null}
  from decimal import Decimal

  def major(amount_minor: int, exponent: int) -> Decimal:
      return Decimal(amount_minor).scaleb(-exponent)

  major(21120000, 3)   # Decimal('21120.000')
  major(5000, 0)       # Decimal('5000')
  ```

  ```javascript JavaScript theme={null}
  // Keep the integer minor units as the source of truth; format only at the edge.
  function formatMajor(amountMinor, exponent, currencyCode) {
    return new Intl.NumberFormat(undefined, {
      style: "currency",
      currency: currencyCode.toUpperCase(),
      minimumFractionDigits: exponent,
      maximumFractionDigits: exponent,
    }).format(amountMinor / 10 ** exponent);
  }

  formatMajor(21120000, 3, "bhd"); // "BHD 21,120.000"
  ```
</CodeGroup>

## `data.amounts` — the Invoice Breakdown

This block exists so you never have to re-derive the maths. Every field is in the currency named by `amounts.currency_code` at `amounts.currency_exponent`.

| Field                  | Description                                                                |
| ---------------------- | -------------------------------------------------------------------------- |
| `subtotal_minor`       | List price before any discount                                             |
| `discount_minor`       | Every discount combined — the sum of `data.discounts[].amount_minor`       |
| `taxable_amount_minor` | The amount tax was calculated on                                           |
| `tax_amount_minor`     | Tax charged. `0` when the event has no tax configured                      |
| `total_minor`          | What the gateway actually captured. Always equal to `payment.amount_minor` |

### Reconciliation Rules

Two relationships let you assert your invoice balances before you issue it:

1. **Always true:**

   ```
   total_minor == taxable_amount_minor + tax_amount_minor
   ```

2. **`subtotal_minor - discount_minor` is the amount charged before the tax adjustment.** Which field that equals depends on `tax.mode`:

   | `tax.mode`             | `subtotal - discount` equals                               |
   | ---------------------- | ---------------------------------------------------------- |
   | `exclusive`, or no tax | `taxable_amount_minor` — tax is then added on top          |
   | `inclusive`            | `total_minor` — the tax is already inside the listed price |

Worked through the example above (exclusive, exponent 3):

```
subtotal          24000.000
- discount         4800.000   (20% volume tier)
= taxable         19200.000
+ tax (10%)        1920.000
= total           21120.000   == payment.amount
```

<Note>
  `total_minor` is taken from the payment record, never recomputed. If your own arithmetic disagrees with it, trust `total_minor` — that is the money that moved — and investigate the difference.
</Note>

## `data.tax`

`null` when the event has no tax configured, or when the gateway is Stripe — Stripe issues its own tax documents, so 9Pic does not add a second tax layer on top.

| Field                | Type   | Description                                                                            |
| -------------------- | ------ | -------------------------------------------------------------------------------------- |
| `name`               | string | Tax label, e.g. `VAT`, `GST`                                                           |
| `registration_id`    | string | The **seller's** tax registration number. Prints on the invoice as the supplier tax id |
| `percentage`         | string | Rate applied, as a decimal string, e.g. `"10.00"`                                      |
| `mode`               | string | `exclusive` (added on top of the price) or `inclusive` (already inside the price)      |
| `base_amount_minor`  | number | Amount the tax was computed on. Same as `amounts.taxable_amount_minor`                 |
| `tax_amount_minor`   | number | Tax charged                                                                            |
| `total_amount_minor` | number | Base plus tax                                                                          |

### Exclusive vs Inclusive

The distinction changes which number you print as the net line, so it must not be guessed:

<Tabs>
  <Tab title="exclusive">
    Tax is added on top of the listed price. The buyer paid **more** than the listed amount.

    ```
    taxable   19200.000     <- the net / pre-tax line on the invoice
    tax        1920.000     <- 10% of taxable
    total     21120.000     <- charged
    ```
  </Tab>

  <Tab title="inclusive">
    Tax is already contained in the listed price and is split back out. The buyer paid **exactly** the listed amount.

    ```
    taxable     453.64      <- reverse-computed net line
    tax          45.36      <- the portion of the listed price that is tax
    total       499.00      <- charged, unchanged by the tax
    ```
  </Tab>
</Tabs>

<Note>
  The whole tax block is **frozen on the order at purchase time**. Changing the organisation's tax rate later never rewrites the numbers behind an invoice you already issued.
</Note>

## `data.discounts`

An ordered list of every reduction between `subtotal` and `taxable_amount`. Empty when the buyer paid list price. Their `amount_minor` values sum to `amounts.discount_minor`.

| Field          | Type   | Description                                        |
| -------------- | ------ | -------------------------------------------------- |
| `type`         | string | `volume_tier` or `coupon`                          |
| `description`  | string | Human-readable label, safe to print on the invoice |
| `code`         | string | Coupon code. Present on `coupon` lines only        |
| `amount_minor` | number | Amount taken off                                   |

<Warning>
  Do not read discounts from `data.coupon` alone. Volume-tier discounts — "20% off when you buy 20 or more" — are applied by the pricing engine before any coupon and never appear in the coupon block. An invoice built from `data.coupon` only will not reconcile against `total_minor` on tiered orders.
</Warning>

`data.coupon` remains available and describes just the coupon, or is `null` when none was used: `code`, `discount_type`, `discount_minor`, `discount`, `original_amount_minor`, `final_amount_minor`.

## `data.items`

| Field              | Type           | Description                                                                                    |
| ------------------ | -------------- | ---------------------------------------------------------------------------------------------- |
| `product_type`     | string         | `photo` today                                                                                  |
| `item_count`       | number         | How many photos the buyer received                                                             |
| `priced_count`     | number \| null | How many were actually charged for. Lower than `item_count` when the plan includes extras free |
| `unit_price_minor` | number \| null | Price per unit at purchase time                                                                |
| `unit_price`       | string \| null | The same, in major units                                                                       |
| `pricing_model`    | string \| null | How the price was reached, e.g. `per_photo`, `bundle_10`                                       |
| `is_pass`          | boolean        | Whether this was a pass rather than a photo selection                                          |

<Note>
  Bill the invoice line at `priced_count`, not `item_count`, when the two differ — `priced_count × unit_price_minor` is what produced `subtotal_minor`. Individual photo ids are deliberately not included; orders regularly run to thousands of photos.
</Note>

## Building the Invoice

| Invoice field      | Source                                                    |
| ------------------ | --------------------------------------------------------- |
| Invoice reference  | `data.reference_id`                                       |
| Invoice date       | `data.payment.paid_at`                                    |
| Supplier name      | `data.organiser.name`                                     |
| Supplier tax id    | `data.tax.registration_id`                                |
| Customer           | `data.buyer.name`, `data.buyer.email`                     |
| Description        | `data.event.name` plus the `data.items` line              |
| Quantity           | `data.items[].priced_count`, falling back to `item_count` |
| Unit price         | `data.items[].unit_price_minor`                           |
| Gross              | `data.amounts.subtotal_minor`                             |
| Discounts          | `data.discounts[]`                                        |
| Net / taxable      | `data.amounts.taxable_amount_minor`                       |
| Tax label and rate | `data.tax.name`, `data.tax.percentage`                    |
| Tax amount         | `data.amounts.tax_amount_minor`                           |
| Total              | `data.amounts.total_minor`                                |
| Payment reference  | `data.payment.gateway_payment_id`                         |

```python Python theme={null}
from decimal import Decimal

def handle(body: dict) -> None:
    if body.get("event_type") != "checkout.payment.completed":
        return

    data = body["data"]
    amounts = data["amounts"]
    exponent = amounts["currency_exponent"]

    # Same purchase across retries and dashboard resends, so this upsert is
    # what stops a resend from issuing a second invoice.
    if invoice_exists(data["reference_id"]):
        return

    assert amounts["total_minor"] == amounts["taxable_amount_minor"] + amounts["tax_amount_minor"]

    tax = data.get("tax")
    create_invoice(
        reference=data["reference_id"],
        issued_at=data["payment"]["paid_at"],
        currency=amounts["currency_code"].upper(),
        net=Decimal(amounts["taxable_amount_minor"]).scaleb(-exponent),
        tax_amount=Decimal(amounts["tax_amount_minor"]).scaleb(-exponent),
        total=Decimal(amounts["total_minor"]).scaleb(-exponent),
        tax_label=(tax or {}).get("name"),
        tax_rate=(tax or {}).get("percentage"),
        supplier_tax_id=(tax or {}).get("registration_id"),
        payment_reference=data["payment"]["gateway_payment_id"],
    )
```

## Forward Compatibility

* **Ignore unknown fields.** New keys are added without bumping `version`.
* **`version` changes only for breaking changes** — a removed field, a renamed field, or a changed meaning.
* **Treat nullable fields as nullable.** `data.tax`, `data.coupon`, `order.sale_phase`, `payment.paid_at`, and the `unit_price` family can all be `null`.
* **Do not pin to key order.** JSON object order is not part of the contract.

## Troubleshooting

| What you see                            | What it means                                                                                                                   |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Nothing arrives                         | The organisation webhook is off, the event toggle is off, or the URL failed validation. Both switches must be on                |
| Deliveries stop after a while           | Your endpoint kept failing and the retries were exhausted. The transaction page shows the failure and offers **Resend webhook** |
| The same purchase arrives twice         | A retry raced your slow `2xx`, or someone clicked **Resend**. Deduplicate on `data.reference_id`                                |
| `tax` is `null` but you expected a rate | No active tax profile for the organisation, tax not enabled on the event, or the gateway is Stripe                              |
| The amount looks 10x or 100x wrong      | `currency_exponent` was assumed instead of read. See [Money and Currency](#money-and-currency)                                  |

The delivery status of every purchase — sent, queued for retry, or failed, with the last response code — is visible on the transaction detail page in the dashboard, along with a **Resend webhook** action.
