PilotFoxDocs

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.

EndpointWhat it doesReference
POST /api/v1/sites/{siteId}/paymentsRecords a payment, or updates the one with the same payment_idPOST /payments
POST /api/v1/sites/{siteId}/payments/{paymentId}/refundSets the refunded total of a recorded paymentPOST /payments/…/refund
DELETE /api/v1/sites/{siteId}/payments/{paymentId}Removes a recorded payment from every reportDELETE /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"}'
201 Created
{
  "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.

FieldTypeWhat it is
payment_id (required)stringYour 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)integerWhat 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)stringISO 4217 code in any case, such as usd or EUR; stored lower-case.
tsstringWhen 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_amountintegerThe total refunded so far, from 0 to amount. Default: 0, or what was recorded before.
statusstringInstead of refunded_amount: paid (nothing refunded) or refunded (all of it). With refunded_amount, it must agree; partially_refunded needs refunded_amount.
refstringThe ref your page sent with its checkout event (option 1), in any format, up to 512 characters.
user_idstringThe id you passed to identify for the buyer (option 2), up to 255 characters.
customer_idstringYour 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.
emailstringThe 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_id and email keeps 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 email or customer_id not sent keeps the recorded one. A refunded_amount (or status) not sent keeps the recorded refund; lowering amount below it is refused until you send refunded_amount.
  • A different ts or currency replaces 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

record-payment.mjs
// 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

StatusWhen
400An 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).
401No API key (a browser session, an OAuth token or a share link counts as none here).
403The key lacks revenue:write.
404A site the key cannot reach (Site not found), or a refund or delete of a payment id never recorded through this API.
409Another request for the same payment is in progress: retry it.
429Over 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-remaining and x-ratelimit-reset headers 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:

  1. ref equals the ref of a checkout event your page sent in the 7 days before ts: the earliest such event's visit.
  2. user_id, then customer_id, is an identified user: that user's latest identified visit before ts.
  3. email equals the email your server sent for an identified user through the server-side identify.
  4. customer_id paid 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.

On this page