API reference
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). For when and why to use it, see Payment API.
Request
POST /api/v1/payments HTTP/1.1
Host: api.productlense.app
Authorization: Bearer mk_live_...
Content-Type: application/json{
"payment_id": "inv_1",
"customer": { "id": "cus_1", "email": "[email protected]", "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
{ "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:
{ "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.
Examples
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"}'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}`);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']}")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)
}