MenuPayments API

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

HTTP
POST /api/v1/payments HTTP/1.1
Host: api.productlense.app
Authorization: Bearer mk_live_...
Content-Type: application/json
Body
{
  "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

FieldRequiredRules
payment_idyes1 to 128 characters of letters, digits and _ - : .. Your id for the payment, and the idempotency key.
customer.idyes1 to 128 characters, same set. Your id for the paying customer.
customer.emailnoMust contain @. Stored in lower case.
customer.user_idnoThe user id you pass to identify. The strongest attribution link.
customer.namenoDisplay name
customer.countrynoISO 3166-1 alpha-2, such as DE
customer.visitor_idnoA ProductLense visitor id, stored as pl_visitor_id
customer.metadatanoObject of string values, kept on the customer
amountyesGreater than 0, in major units: 29.00, not 2900. Refunds are positive too.
currencyyesThree-letter ISO 4217 code, such as USD, EUR or INR
feeno0 or more, in the payment currency. What your processor kept.
kindyescharge, refund or chargeback
paid_atyesRFC 3339 time, such as 2026-10-11T10:00:00Z
statusnosucceeded (default), pending or failed. Only succeeded counts toward revenue.
metadatanoObject of string values, kept with the payment
subscription.idwhen subscription is setSame character rules as payment_id
subscription.plannoPlan name
subscription.intervalnoday, week, month or year
subscription.statusnoactive (default), trialing, past_due, paused, canceled or ended
subscription.amountno0 or more. The recurring amount per interval, used for MRR.
subscription.started_atnoRFC 3339. Defaults to paid_at.
subscription.current_period_end, canceled_at, ended_atnoRFC 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" } }
FieldMessage
payment_idrequired, 1-128 chars of [A-Za-z0-9_-:.]
customer.idrequired, 1-128 chars of [A-Za-z0-9_-:.]
customer.emailmust be an email address
customer.countrymust be ISO 3166-1 alpha-2
amountmust be greater than 0
feemust not be negative
currencymust be a 3-letter ISO 4217 code
kindmust be one of charge, refund, chargeback
paid_atmust be RFC 3339 (e.g. 2026-10-11T10:00:00Z)
statusmust be one of succeeded, pending, failed
subscription.idrequired when subscription is set
subscription.intervalmust be one of day, week, month, year
subscription.statusmust be one of active, trialing, past_due, paused, canceled, ended
subscription.amountmust not be negative
subscription.started_at and the other timesmust 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)
}

    ↑ ↓ to move, Enter to open