# Payments API

POST /api/v1/payments records one charge, refund or chargeback with its customer and optional subscription. Every field, rule and validation message.

`POST https://api.productlense.app/api/v1/payments` stores one payment and attributes it to the customer's first-touch channel. It needs an API key with the `payments` scope ([Authentication and API keys](/docs/api/authentication)). For when and why to use it, see [Payment API](/docs/revenue/payments-api).

## Request

```http
POST /api/v1/payments HTTP/1.1
Host: api.productlense.app
Authorization: Bearer mk_live_...
Content-Type: application/json
```

```json title="Body" {2,4-8}
{
  "payment_id": "inv_1",
  "customer": { "id": "cus_1", "email": "ada@example.com", "user_id": "user_123", "name": "Ada", "country": "DE" },
  "amount": 29.00,
  "currency": "USD",
  "fee": 1.15,
  "kind": "charge",
  "paid_at": "2026-10-11T10:00:00Z",
  "subscription": { "id": "sub_1", "plan": "Pro", "interval": "month", "status": "active", "amount": 29.00 }
}
```

The body is JSON of at most 64 KB. Unknown fields are rejected, so a typo such as `paidAt` fails instead of being ignored.

## Fields

| Field | Required | Rules |
|---|---|---|
| `payment_id` | yes | 1 to 128 characters of letters, digits and `_ - : .`. Your id for the payment, and the idempotency key. |
| `customer.id` | yes | 1 to 128 characters, same set. Your id for the paying customer. |
| `customer.email` | no | Must contain `@`. Stored in lower case. |
| `customer.user_id` | no | The user id you pass to `identify`. The strongest attribution link. |
| `customer.name` | no | Display name |
| `customer.country` | no | ISO 3166-1 alpha-2, such as `DE` |
| `customer.visitor_id` | no | A ProductLense visitor id, stored as `pl_visitor_id` |
| `customer.metadata` | no | Object of string values, kept on the customer |
| `amount` | yes | Greater than 0, in major units: `29.00`, not `2900`. Refunds are positive too. |
| `currency` | yes | Three-letter ISO 4217 code, such as `USD`, `EUR` or `INR` |
| `fee` | no | 0 or more, in the payment currency. What your processor kept. |
| `kind` | yes | `charge`, `refund` or `chargeback` |
| `paid_at` | yes | RFC 3339 time, such as `2026-10-11T10:00:00Z` |
| `status` | no | `succeeded` (default), `pending` or `failed`. Only `succeeded` counts toward revenue. |
| `metadata` | no | Object of string values, kept with the payment |
| `subscription.id` | when `subscription` is set | Same character rules as `payment_id` |
| `subscription.plan` | no | Plan name |
| `subscription.interval` | no | `day`, `week`, `month` or `year` |
| `subscription.status` | no | `active` (default), `trialing`, `past_due`, `paused`, `canceled` or `ended` |
| `subscription.amount` | no | 0 or more. The recurring amount per interval, used for MRR. |
| `subscription.started_at` | no | RFC 3339. Defaults to `paid_at`. |
| `subscription.current_period_end`, `canceled_at`, `ended_at` | no | RFC 3339 |

## Response

```json
{ "ok": true, "data": { "payment_id": "inv_1" } }
```

Status `200`. Sending the same `payment_id` again updates the stored payment, so retries are safe.

## Validation errors

Every invalid field is reported at once, sorted by field name, in one `invalid_input` error with status `400`:

```json
{ "ok": false, "error": { "code": "invalid_input", "message": "invalid payment input: amount: must be greater than 0; currency: must be a 3-letter ISO 4217 code" } }
```

| Field | Message |
|---|---|
| `payment_id` | `required, 1-128 chars of [A-Za-z0-9_-:.]` |
| `customer.id` | `required, 1-128 chars of [A-Za-z0-9_-:.]` |
| `customer.email` | `must be an email address` |
| `customer.country` | `must be ISO 3166-1 alpha-2` |
| `amount` | `must be greater than 0` |
| `fee` | `must not be negative` |
| `currency` | `must be a 3-letter ISO 4217 code` |
| `kind` | `must be one of charge, refund, chargeback` |
| `paid_at` | `must be RFC 3339 (e.g. 2026-10-11T10:00:00Z)` |
| `status` | `must be one of succeeded, pending, failed` |
| `subscription.id` | `required when subscription is set` |
| `subscription.interval` | `must be one of day, week, month, year` |
| `subscription.status` | `must be one of active, trialing, past_due, paused, canceled, ended` |
| `subscription.amount` | `must not be negative` |
| `subscription.started_at` and the other times | `must be RFC 3339` |

Malformed JSON and unknown fields answer `invalid_input` with a message starting `invalid JSON:`. Other errors are on [Errors](/docs/api/errors).

## Examples

```bash tab="cURL"
curl -X POST https://api.productlense.app/api/v1/payments \
  -H "Authorization: Bearer $PL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"payment_id":"re_1","customer":{"id":"cus_1"},"amount":29.00,"currency":"USD","kind":"refund","paid_at":"2026-10-20T09:30:00Z"}'
```

```js tab="Node"
const refund = {
  payment_id: 're_1',
  customer: { id: 'cus_1' },
  amount: 29.0,
  currency: 'USD',
  kind: 'refund',
  paid_at: '2026-10-20T09:30:00Z',
};
const res = await fetch('https://api.productlense.app/api/v1/payments', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.PL_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify(refund),
});
const body = await res.json();
if (!body.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
```

```python tab="Python"
import os
import requests

refund = {
    "payment_id": "re_1",
    "customer": {"id": "cus_1"},
    "amount": 29.00,
    "currency": "USD",
    "kind": "refund",
    "paid_at": "2026-10-20T09:30:00Z",
}
res = requests.post(
    "https://api.productlense.app/api/v1/payments",
    json=refund,
    headers={"Authorization": f"Bearer {os.environ['PL_API_KEY']}"},
    timeout=10,
)
body = res.json()
if not body["ok"]:
    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
```

```go tab="Go"
refund := map[string]any{
	"payment_id": "re_1",
	"customer":   map[string]string{"id": "cus_1"},
	"amount":     29.00,
	"currency":   "USD",
	"kind":       "refund",
	"paid_at":    "2026-10-20T09:30:00Z",
}
body, _ := json.Marshal(refund)
req, _ := http.NewRequest(http.MethodPost, "https://api.productlense.app/api/v1/payments", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("PL_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()
var out struct {
	OK    bool `json:"ok"`
	Error struct {
		Code, Message string
	} `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
	log.Fatal(err)
}
if !out.OK {
	log.Fatalf("%s: %s", out.Error.Code, out.Error.Message)
}
```
