Payments API
Record payments from any other billing system with the custom provider: create or update, refund and delete.
On Paddle, Lemon Squeezy, Gumroad, crypto or a billing system of your own, your server records each payment through
the API. A recorded payment (provider custom) is linked to a visit in the same order as a synced one, credited by the
site's credit model, and counts in every revenue report, the affiliates and
the buyer's journey.
| Endpoint | What it does | Reference |
|---|---|---|
POST /api/v1/sites/{siteId}/payments | Records a payment, or updates the one with the same payment_id | POST /payments |
POST /api/v1/sites/{siteId}/payments/{paymentId}/refund | Sets the refunded total of a recorded payment | POST /payments/…/refund |
DELETE /api/v1/sites/{siteId}/payments/{paymentId} | Removes a recorded payment from every report | DELETE /payments/… |
The API key
The three endpoints accept API keys only, with the revenue:write permission. A signed-in browser session, an MCP
OAuth token or a share link gets 401 whatever it holds, so no browser can record a payment; a key without
revenue:write gets 403.
revenue:write is in no organization role: only organization admins and owners can put it on a key. Limit the key
to the site, and keep it on your server. Site settings → Revenue → Custom / API links admins to the API key form
with that permission and the site already chosen; see Authentication for keys in
general.
Record a payment
When a payment succeeds (in your billing system's webhook handler, or after your own checkout), send it:
curl -X POST "https://pilotfox.app/api/v1/sites/SITE_ID/payments" \
-H "Authorization: Bearer $PILOTFOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"payment_id": "order_1042", "amount": 4900, "currency": "usd", "ref": "3f2c9b1e-8f4d-4a8e-9c1b-2d7e5a6f0b13", "email": "buyer@example.com"}'{
"data": {
"payment_id": "order_1042",
"provider": "custom",
"ts": "2026-10-27T20:21:42.291Z",
"amount": 4900,
"currency": "usd",
"refunded_amount": 0,
"status": "paid",
"attribution": "checkout_ref"
}
}The answer is 201 for a new payment and 200 for an update. attribution says how the payment was linked:
checkout_ref, user_id, email, customer or none (how a payment is linked).
Fields
The body is strict: a field PilotFox does not know is refused with 400.
| Field | Type | What it is |
|---|---|---|
payment_id (required) | string | Your id of the payment (order, transaction or invoice id): 1 to 255 printable ASCII characters, no spaces. A site has one payment per id. |
amount (required) | integer | What the customer paid, tax included, in the currency's smallest unit: 4900 is 49.00 USD, 1200 is 1,200 JPY (a zero-decimal currency). 1 to 10¹². No decimal point. |
currency (required) | string | ISO 4217 code in any case, such as usd or EUR; stored lower-case. |
ts | string | When it was paid: ISO 8601 with a time zone (2026-10-26T09:30:00Z), from 2000-01-01T00:00:00Z to 5 minutes past the server's clock. Default: now, or the time already recorded for this payment_id. |
refunded_amount | integer | The total refunded so far, from 0 to amount. Default: 0, or what was recorded before. |
status | string | Instead of refunded_amount: paid (nothing refunded) or refunded (all of it). With refunded_amount, it must agree; partially_refunded needs refunded_amount. |
ref | string | The ref your page sent with its checkout event (option 1), in any format, up to 512 characters. |
user_id | string | The id you passed to identify for the buyer (option 2), up to 255 characters. |
customer_id | string | Your billing system's customer id, up to 255 characters. A later payment of the same customer that nothing else links (a renewal, a second order) is linked like this one, whichever of the two you record first. It is also tried as a user id after user_id, for sites that identify users by it. |
email | string | The customer's email, up to 254 characters, matched ignoring case (option 3). Kept with the payment only when the site's Store customer emails setting is on. |
ref, user_id and customer_id must not be blank or contain control characters.
Updates and retries
Sending the same payment_id again updates that payment, so a retried webhook changes nothing.
- A request that sends none of
ref,user_id,customer_idandemailkeeps the payment's link to its visit (and its credits), so a refund or a status change never loses it. One that sends any of them links the payment again. - An
emailorcustomer_idnot sent keeps the recorded one. Arefunded_amount(orstatus) not sent keeps the recorded refund; loweringamountbelow it is refused until you sendrefunded_amount. - A different
tsorcurrencyreplaces the recorded payment.
Refund a payment
# Refund the whole amount:
curl -X POST "https://pilotfox.app/api/v1/sites/SITE_ID/payments/order_1042/refund" \
-H "Authorization: Bearer $PILOTFOX_API_KEY"
# Set the total refunded so far (sending it twice changes nothing):
curl -X POST "https://pilotfox.app/api/v1/sites/SITE_ID/payments/order_1042/refund" \
-H "Authorization: Bearer $PILOTFOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"refunded_amount": 1000}'The answer is 200 with the payment (status partially_refunded or refunded); its link to the visit is kept. A
refunded_amount above the payment's amount is 400, a payment id never recorded is 404.
Delete a payment
curl -X DELETE "https://pilotfox.app/api/v1/sites/SITE_ID/payments/order_1042" \
-H "Authorization: Bearer $PILOTFOX_API_KEY"204 once every version of the payment is removed from every report; 404 when there is none. Use it for test
payments and mistakes.
Refund and delete work only on payments recorded through this API, never on a synced provider's. In their paths,
URL-encode the payment id (encodeURIComponent) when it has characters such as / or #.
From Node.js
// Node.js 18+ (global fetch). Call it from your billing system's webhook handler.
const PILOTFOX_URL = process.env.PILOTFOX_URL ?? "https://pilotfox.app";
const SITE_ID = process.env.PILOTFOX_SITE_ID;
const KEY = process.env.PILOTFOX_API_KEY; // revenue:write, limited to this site
async function recordPayment(payment, attempt = 1) {
const res = await fetch(`${PILOTFOX_URL}/api/v1/sites/${SITE_ID}/payments`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(payment),
});
// 409: another request for the same payment is in progress; 429: over 300 requests a minute.
if ((res.status === 409 || res.status === 429) && attempt < 5) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
return recordPayment(payment, attempt + 1);
}
const body = await res.json();
if (!res.ok) throw new Error(`PilotFox ${res.status}: ${body.error?.message}`);
return body.data; // { payment_id, provider: "custom", ts, amount, currency, refunded_amount, status, attribution }
}
const recorded = await recordPayment({
payment_id: "order_1060",
amount: 4900, // 49.00 USD
currency: "usd",
ts: new Date().toISOString(),
ref: "3f2c9b1e-8f4d-4a8e-9c1b-2d7e5a6f0b13", // the checkout event's ref, if your page sent one
user_id: "usr_8f3k2", // the id you pass to identify, if the buyer is signed in
customer_id: "cus_42",
});
console.log(recorded.attribution);Errors and limits
| Status | When |
|---|---|
400 | An unknown field, an amount with decimals, an unknown currency, a ts without a time zone or outside its range, a refund above the amount, a status that contradicts refunded_amount, a lower amount than the recorded refund without refunded_amount. A malformed body gets an error.details list naming each field; the other refusals explain themselves in error.message (ts must not be before 2000-01-01T00:00:00Z). |
401 | No API key (a browser session, an OAuth token or a share link counts as none here). |
403 | The key lacks revenue:write. |
404 | A site the key cannot reach (Site not found), or a refund or delete of a payment id never recorded through this API. |
409 | Another request for the same payment is in progress: retry it. |
429 | Over the rate limit. |
- Rate limit. Each of the three endpoints allows 300 requests a minute per API key, counted separately from the
API's general limits. The
x-ratelimit-limit,x-ratelimit-remainingandx-ratelimit-resetheaders show where you are. A backfill of 10,000 payments takes about half an hour. - Reports. The payment is stored when the API answers: the payments list shows it at once, the cached revenue reports within the instance's query cache time (30 seconds by default).
- New-sale notifications. The first time a payment id is recorded, the site's notification channels announce it when it was paid in the last 24 hours and is not refunded in full. An update never sends one.
Linking a recorded payment to a visit
The payment is linked in the usual order, with these fields:
refequals therefof acheckoutevent your page sent in the 7 days beforets: the earliest such event's visit.user_id, thencustomer_id, is an identified user: that user's latest identified visit beforets.emailequals the email your server sent for an identified user through the server-side identify.customer_idpaid earlier and that payment is linked: the same visit.
A newly linked payment with a customer_id also links that customer's later payments recorded unlinked before, so
importing your history in any order ends up linked as if recorded in order. A ts in the past (from
2000-01-01) imports history; a checkout reference only matches checkout events from the 7 days before ts.