# Payment API

> Send payments to Statsy from your own server when your billing does not come from a supported provider webhook.

Section: Revenue attribution  
Canonical page: https://statsy.co/docs/revenue/payment-api  
Last updated: 2026-09-15

If you bill through something Statsy has no webhook for — your own billing code, an invoicing tool, a marketplace, a bank transfer you reconcile nightly — send the payment yourself. It lands in exactly the same place as a webhook payment.

## The endpoint

```text
POST https://statsy.co/api/v1/sites/<site>/payments
```

`<site>` is the site's public id or its `ws_` key. Authenticate with a bearer token created under **Site settings › API tokens**: a site token (`st_`) limited to one site is the right choice here. Account tokens (`sta_`) work too.

```bash
curl https://statsy.co/api/v1/sites/ws_XXXXXXXX/payments \
  -H "Authorization: Bearer st_XXXXXXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "inv_20260915_881",
    "amount": 49.00,
    "currency": "USD",
    "kind": "one_time",
    "email": "buyer@example.com",
    "occurred_at": "2026-09-15T10:04:00Z"
  }'
```

## Body fields

| Field | Required | Notes |
| --- | --- | --- |
| `external_id` | Yes | Your own unique id for this payment, up to 255 characters. This is what makes the call idempotent. |
| `amount` | Yes | In **major units** — `49.00` means forty-nine dollars, not forty-nine cents. |
| `currency` | Yes | A three-letter code. Lower case is accepted and upper-cased for you. |
| `kind` | No | `one_time` (the default), `renewal` or `refund`. A refund is stored as a negative amount whichever sign you send. |
| `visitor_id` | No | From `statsy.visitorId()`. The most reliable way to get the sale credited. |
| `email` | No | Matched against identities you linked with `statsy.identify()`, and against known customers. |
| `customer_id` | No | Your id for the customer. Groups payments into one customer record and a lifetime value. |
| `subscription_id` | No | Ties renewals together and lets a later cancellation mark the customer churned. |
| `occurred_at` | No | ISO timestamp or epoch. Defaults to now. Use the real time, or attribution looks at the wrong touches. |
| `provider` | No | A label for where it came from. Defaults to `api`. |
| `raw` | No | Any JSON object you want kept alongside the payment. |

## Idempotency

A payment is unique per site and `external_id`. Send the same one twice and the second call changes nothing and reports `duplicate: true`; the first insert answers `201`, a repeat answers `200`. That makes the endpoint safe to retry from a job queue, and safe to replay over a backfill.

The response carries the `externalId`, whether it was a duplicate, the visitor Statsy matched it to (or `null`), and the USD amount it was converted to.

## Reading payments back

`GET /api/v1/sites/<site>/payments` lists them, newest first, with `from`, `to`, `limit` (up to 200) and a `cursor` for paging. `GET /api/v1/sites/<site>/customers` returns customers with their lifetime value, first touch and status.

> **Tip.** One-off payments do not need code at all. The Revenue page has a form: [Record payments by hand](https://statsy.co/docs/revenue/manual-payments).

Previous: [Razorpay](https://statsy.co/docs/revenue/razorpay)  
Next: [Record payments by hand](https://statsy.co/docs/revenue/manual-payments)