API v1

Build on FloatPay

Tokenize the card in the browser, charge it from your server, and reconcile everything from one dashboard. Two dependencies-free packages and a REST API that speaks plain JSON.

Quickstart

A working charge in four steps. Everything below runs against test mode until you swap the key.

1. Create a key. In the dashboard, Settings → Developer API keys. Choose test and a role that can charge — a developer key is read-only by design. The key is shown once.

2. Install.

bash
npm install @floatpay/node

3. Collect the card in the browser. Card details go into gateway-hosted iframes and never touch your page or your server. The fp_pk_test_… publishable key is on the same Settings page; it is safe in client code, because it can only create payment tokens.

html
<form id="checkout">
  <div id="ccnumber"></div>
  <div id="ccexp"></div>
  <div id="cvv"></div>
  <button type="submit">Pay $10.50</button>
</form>

<script type="module">
  import { mountCardFields } from "@floatpay/node/browser";

  const fields = await mountCardFields({ publishableKey: "fp_pk_test_..." });

  document.getElementById("checkout").onsubmit = async (event) => {
    event.preventDefault();
    const { token } = await fields.tokenize();
    await fetch("/checkout", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ token }),
    });
  };
</script>

4. Charge it from your server.

typescript
import { FloatPay } from "@floatpay/node";

const floatpay = new FloatPay(process.env.FLOATPAY_API_KEY!);

const charge = await floatpay.transactions.create(
  {
    amount: 1050,            // minor units — $10.50
    payment_token: token,    // from the browser, single use
    order_id: "order_8412",
  },
  { idempotencyKey: `order_8412` },
);

console.log(charge.id, charge.status); // "…", "pending_settlement"

Authentication

One credential, one organization, one mode.
bash
curl https://float-pay.com/api/v1/transactions \
  -H "Authorization: Bearer fp_test_..."

The key decides test or live

There is no mode parameter, on purpose. A parameter that can disagree with the credential is a way to charge a real card while believing you are testing. Test keys reach test data; live keys reach live data; nothing in a request can change that.

A key carries scopes — the exact resources and actions it may use. A key can never do more than an Owner can do in the dashboard, and it can be revoked at any time without touching your other keys. Never put a secret key in browser code.

Scopes

Pick the smallest set that works. Two presets cover most integrations: read_only (reads, no money movement) and server_integration (charge, refund, customers, checkout, payment links). The Advanced grid in Developers → API keys sets them one at a time.

transactions:read

scope

List and fetch transactions.
transactions:write

scope

Charge, capture, refund, void.
customers:read · customers:write

scope

Read and write stored customers.
catalog:read · catalog:write

scope

Products and prices.
checkout:write

scope

Create Checkout Sessions.
payment_links:write

scope

Create payment links.
invoices:write

scope

Create and send invoices.
subscriptions:write

scope

Create and change subscriptions.
events:read

scope

Read the event log.
webhooks:write

scope

Manage webhook endpoints.

A call the key has no scope for is refused 403 insufficient_scope, and the message names the scope you need.

Expiry and rotation

A key can be given an end date at creation, or expired on the spot. An expired key answers 401 api_key_expired — deliberately different from a revoked key, so you can tell "the date passed" from "someone turned this off".

Rotate without an outage

Rotating mints the replacement and hands it back once, then puts the old key on a clock: 0, 24, 72 or 168 hours. Both keys work during the window, so you deploy the new one, watch it, and let the old one lapse. A rotation that takes an integration down is a rotation nobody does twice — which leaves the leaked key live, the worse outcome.

IP allowlist

A key can be pinned to addresses or CIDR ranges — 203.0.113.9, 198.51.100.0/24, IPv4 or IPv6. A request from anywhere else is refused 403 ip_not_allowed and recorded in your audit log. An empty allowlist means anywhere; a key WITH an allowlist is refused when we cannot establish the caller's address at all.

Collecting a card

FloatPay never accepts card numbers over the API. This is not a preference — it is what keeps both of us out of PCI scope.

Send a card number to the API and it is rejected 400 card_data_not_accepted before the value is read, logged, or echoed back. Use one of two payment sources instead:

payment_token

string

Single-use token from FloatPay.js. Expires 24 hours after creation and is destroyed once submitted — a declined charge needs a fresh one.
customer_vault_id

string

An existing stored profile. List them with floatpay.customers.list().

Content-Security-Policy

The tokenizer is served by the gateway. If your checkout page sets a CSP, allow the gateway host in script-src, frame-src and connect-src. The SDK runs the tokenizer in strict mode, so you do not need unsafe-eval.

Charging, and the money actions

Authorize now and capture later, or charge outright. Refund and void the same way.
typescript
// Authorize now, capture on shipment
const auth = await floatpay.transactions.create(
  { amount: 4200, payment_token: token, capture: false },
  { idempotencyKey: "order_9001" },
);

await floatpay.transactions.capture(auth.id, undefined, {
  idempotencyKey: "capture_9001",
});

// Partial refund — minor units, so 500 is $5.00
await floatpay.transactions.refund(charge.id, 500, {
  idempotencyKey: "refund_9001_partial",
});

// Cancel before settlement
await floatpay.transactions.void(auth.id, { idempotencyKey: "void_9001" });

Money is always in minor units

1050 is $10.50. Never send a decimal — integers are the only representation that does not accumulate rounding error.

Checkout Sessions

No card form to build. Create a session on your server, send the customer to the page we host, and read the answer back.

Four steps: create the session, redirect, the customer pays on a page branded as your business, and they come back to your success_url. The amount is fixed when the session is created and read from our database when the card is charged, so nothing in the customer’s browser can change what they pay.

typescript
const session = await floatpay.checkout.sessions.create(
  {
    line_items: [
      { name: "Consultation", unit_amount: 15000, quantity: 1 },
    ],
    customer_email: customer.email,
    success_url: "https://example.com/thanks?cs={CHECKOUT_SESSION_ID}",
    cancel_url: "https://example.com/cart",
    metadata: { order_id: order.id },
  },
  { idempotencyKey: `order_${order.id}` },
);

redirect(session.url); // https://float-pay.com/pay/c/cs_...

In your success_url handler, retrieve the session and check payment_status. Then fulfil the order.

typescript
const session = await floatpay.checkout.sessions.retrieve(sessionId);

if (session.payment_status === "paid") {
  await fulfil(session.metadata.order_id, session.transaction_id);
}

A customer landing on your success_url has not proved anything

That URL is a URL. Anyone can type it. The retrieve above is what says the card was charged, and transaction_id is what you refund later. Soon you will be able to subscribe to checkout.session.completed instead of waiting for the browser to come back at all.

Sessions last 24 hours at most. Ask for longer and you get 24 hours, reported back in expires_at. An expired session never charges — the page says so and the card form is gone. You can close one early with expire(), and a second submit on a session already paid returns the same transaction rather than charging twice.

Wallets

Apple Pay and Google Pay are on the hosted page already. There is nothing to add to your integration and nothing new to handle: a wallet mints the same single-use token a card does, the same session is charged, and payment_status reads the same afterwards. The buttons appear above the card form when the customer’s browser can use them, and are simply absent when it cannot.

Google Pay needs no setup. Apple Pay needs one step you cannot do yourself: Apple requires the payment domain to be registered with the gateway before it will open a sheet. We register it for your Merchant Account — check Settings → Apple Pay for the status, and the button stays hidden until it says active.

Whatever billing address the session carries is sent with the charge, and the issuer’s address and security-code verdicts come back as checks — a pair of response codes on checkout.session.completed, for your own fraud rules. No card detail is stored, ever.

Invoices

Bill someone who is not sitting at a checkout. Create a draft, finalize it, and send it — the customer pays on a page branded as your business and the invoice marks itself paid.

An invoice moves through four states, and the order matters. create makes a draft you can keep editing. finalize mints the number and locks the lines. send emails the customer the document, the PDF, and a link to /pay/i/{id}.

typescript
const invoice = await floatpay.invoices.create(
  {
    customer_email: "ada@example.com",
    customer_name: "Ada Lovelace",
    items: [
      { description: "Consulting, March", quantity: 12, unit_amount: 20000 },
      { price: "price_...", quantity: 1 }, // or straight from your catalog
    ],
    days_until_due: 14,
    memo: "Thanks for your business.",
  },
  { idempotencyKey: `invoice_${jobId}` },
);

await floatpay.invoices.finalize(invoice.id);
const sent = await floatpay.invoices.send(invoice.id);

sent.number;             // "INV-0001"
sent.hosted_invoice_url; // https://float-pay.com/pay/i/in_...
sent.invoice_pdf;        // the same document, as a file

Totals are computed by us, never read from your request: subtotal is the sum of the lines, and total is that minus discount_amount plus tax_amount. A line that names a price takes its amount from your catalog and snapshots it, so editing that price next month does not rewrite an invoice already sent.

Finalize is the point of no return, on purpose

A draft can be edited and deleted. Past finalize it cannot: the number is minted, the PDF is real, and the customer has the document. PATCH and DELETE answer 409 invalid_state. Use void to cancel one, and duplicate in the dashboard to start again from the same lines.

Getting paid. The customer clicks Pay, we create a Checkout Session bound to the invoice, and when the card is approved the invoice becomes paid with transaction_id set — the charge you would refund. Paid another way? Call markPaid and paid_out_of_band records that there is no charge to look for.

Past due is not a status. It is open plus a calendar, so every invoice carries a past_due boolean and the list accepts ?status=past_due. Reminders go out three days before the due date, on the day, and three and seven days after — change that per invoice with reminder_schedule, or switch them off with empty arrays and on_due: false.

typescript
// Events beat polling. Four types, all on the invoice:
//   invoice.sent · invoice.paid · invoice.past_due · invoice.voided
const overdue = await floatpay.invoices.list({ status: "past_due" });

Idempotency

Required on every POST, not optional.

A timed-out request is indistinguishable from a declined one. Send an Idempotency-Key you choose per operation and you can retry safely: the same key with the same body returns the original response, with Idempotent-Replay: true. Keys last 24 hours.

typescript
// Right: the key identifies the OPERATION, so a retry is safe
await floatpay.transactions.create(params, { idempotencyKey: `order_${orderId}` });

// Wrong: a fresh key per attempt turns a retry into a second charge
await floatpay.transactions.create(params, { idempotencyKey: crypto.randomUUID() });

Reusing a key with a different body returns 409 idempotency_key_reused rather than the old response — that mismatch is a bug worth seeing, not one worth hiding.

Webhooks

Stop polling. Register a URL, verify one header, and act on events as they happen.

Add an endpoint in the dashboard under Developers → Webhooks, or over the API. FloatPay POSTs a JSON body to it for every event you subscribe to:

json
{
  "id": "evt_9tK2mQ7bZx4Lp1Rn8sYc",
  "object": "event",
  "type": "transaction.succeeded",
  "created": 1788000000,
  "livemode": true,
  "data": { "object": { "transaction_id": "12434100332", "amount": 1050, "currency": "USD" } },
  "request": { "id": null, "idempotency_key": null }
}

Verify every request. The signature is the only thing separating a real event from a stranger claiming an order was paid for.

typescript
import express from "express";
import { webhooks, FloatPaySignatureVerificationError } from "@floatpay/node";

const app = express();

// RAW body — not express.json(). The signature covers the exact bytes we sent.
app.post("/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await webhooks.constructEvent(
      req.body.toString("utf8"),
      req.header("FloatPay-Signature")!,
      process.env.FLOATPAY_WEBHOOK_SECRET!,   // fpwh_...
    );
  } catch (e) {
    if (e instanceof FloatPaySignatureVerificationError) return res.sendStatus(400);
    throw e;
  }

  res.sendStatus(200);                        // acknowledge FIRST, within 10s
  await handle(event);                        // then do the slow work
});

Verify the raw body, byte for byte

A body that has been parsed and re-serialized produces a different signature — key order and whitespace are part of what was signed. This is the single most common reason verification “mysteriously” fails.

Doing it by hand is four lines. The header is FloatPay-Signature: t=<unix seconds>,v1=<hex> and v1 is HMAC-SHA256(secret, "<t>.<raw body>"). Reject anything whose t is more than 300 seconds old — the timestamp is inside the signed payload, which is what makes a captured request unreplayable.

Handle every event as if it may arrive twice

Retries, a manual resend, and a network hiccup all produce the same event again. Key your fulfilment on event.id (also sent as the FloatPay-Event-Id header) and make the second delivery a no-op. Order is not guaranteed either.

Retries. Any response that is not 2xx — and any timeout past 10 seconds — is retried on a widening schedule: 15 s, then a minute three times, 5 m, 15 m, 30 m, hourly for ten hours, twice at 12 h, then a day. After roughly three days we stop, turn the endpoint off, and email the account owners. Nothing is lost: every event stays readable for 30 days, and you can resend any delivery from the last 15 days once the endpoint is healthy.

Rolling the secret. A roll issues a new secret and, for the next 24 hours, signs each event with BOTH — the header carries two v1 entries and either verifies. Deploy the new value inside that window and no event goes unverified. Roll with a zero-hour window only when the old secret has leaked.

POST/v1/webhook_endpoints
Create an endpoint. Body: url, enabled_events (catalog types or ["*"]), description. The response is the only place secret ever appears. Maximum 16 per mode.
GET/v1/webhook_endpoints
List the endpoints for this key’s mode.
GET/v1/webhook_endpoints/{id}
Retrieve one endpoint.
PATCH/v1/webhook_endpoints/{id}
Change the URL, description, subscribed events, or status (enabled / disabled).
POST/v1/webhook_endpoints/{id}/secret/roll
Issue a new signing secret. grace_hours is 24 (default) or 0.
DELETE/v1/webhook_endpoints/{id}
Delete an endpoint. Queued deliveries go with it.

Events

The same log a webhook endpoint is fed from, readable directly — for a backfill, a missed delivery, or a dashboard of your own.

Every event FloatPay stores — real or manufactured with a test event — is kept for 30 days and readable here. The JSON is identical, field for field, to what your webhook endpoint receives at data.object — one envelope, two ways to read it.

GET/v1/events
List events, newest first. Query: type (comma-separated) or types[] (repeated), created[gte] / created[lte] (Unix seconds), page, limit (max 100).
GET/v1/events/{id}
Retrieve one event.

Ordering is not a guarantee

A redelivered upstream event can land after ones that happened later. Page by created, and dedupe by id — the same rule webhook handlers follow.
typescript
const page = await floatpay.events.list({ type: "transaction.succeeded", limit: 10 });
const event = await floatpay.events.retrieve(page.data[0].id);

Testing

Trigger any outcome in the sandbox on purpose, so your integration sees it before a customer does.

Named triggers. The sandbox reads specific values off the request and returns a specific, repeatable outcome — no separate “test mode” flag to flip, just the value you send.

Decline

amount

Any amount under $1.00 (< 100 minor units).
AVS match

address

address1: 888, zip: 77777.
CVV match

cvv

999
3DS — frictionless

card

4100000000000100 — authenticates with no challenge.
3DS — challenge

card

4100000000005000 — challenge code 12345 passes.
ACH

account / routing

24413815 / 490000018
Test cards

card

4111111111111111 (Visa), 5431111111111111 (Mastercard), 341111111111111 (Amex), 6011000991300009 (Discover) — any future expiry, e.g. 10/29.

Sandbox only

None of these values do anything against a live key — they are read by the test gateway behind Test Mode, not by FloatPay.

Test events. Fire any catalog event with realistic sample data, so your webhook endpoint reacts without you needing to actually trigger a decline or a settlement first. It fans out to every subscribed endpoint, same as a real event — unlike the single-endpoint “send test event” button on a webhook endpoint’s detail page, which sends an unsubscribable ping to that one endpoint alone.

POST/v1/test/events
Test Mode keys only — a live key gets 403 test_mode_only. Requires Idempotency-Key.
type

string · required

A catalog type — see the Webhooks section for the list.
data

object

Merged over a realistic sample for the type. Omit it to send the sample as-is.
typescript
const event = await floatpay.testEvents.create(
  { type: "transaction.succeeded", data: { amount: 4200 } },
  { idempotencyKey: "test-1" },
);

Live stream. For a local integration, watch events arrive instead of polling /v1/events. Test Mode only, and closes after 15 minutes — reconnect for a longer session.

GET/v1/events/stream
Server-Sent Events. Sends event: floatpay.event for each new event, a heartbeat comment every 15 s, and event: close when the connection reaches its 15-minute cap. At most 3 concurrent streams per key.
typescript
for await (const event of floatpay.events.stream()) {
  console.log(event.type, event.data.object);
}

CLI

floatpay login, listen, and trigger — a local webhook receiver with no tunnel.

Listen does locally what the live stream above does over the wire: it reads GET /v1/events/stream and re-delivers each event to a URL on your machine, signed the same way a real webhook delivery is — no tunnel, no publicly reachable endpoint while you build.

Not on npm yet

Build it from the repo and link it — the plan is npx --package @floatpay/cli floatpay once it ships.
bash
npm run build --prefix cli && npm link --prefix cli
floatpay --help

Log in. Stores the key under ~/.config/floatpay/config.json (owner-read-only). A live key is refused unless you also pass --live — listen and trigger refuse one regardless, the same as the API does.

bash
floatpay login --key fp_test_...

Listen. Verify forwarded events with webhooks.constructEvent and the printed fpwh_... secret, exactly like a production endpoint — a real signature, from a secret this process invented for the session rather than one FloatPay holds.

bash
floatpay listen --forward-to http://localhost:3000/webhook

# Ready! Your local webhook secret is fpwh_3f9a...
# Forwarding events to http://localhost:3000/webhook.
# → transaction.succeeded [200] 41ms

Trigger and quick reads. The same test-event call as above, from the terminal — useful with listen running in another tab.

bash
floatpay trigger transaction.succeeded --data '{"amount": 4200}'
floatpay transactions list --limit 5
floatpay events list --type transaction.succeeded

Errors

One shape, so you write one handler.
json
{
  "error": {
    "type": "card_error",
    "code": "card_declined",
    "message": "INSUFFICIENT FUNDS",
    "param": "amount"
  }
}
authentication_error

401

Key missing, malformed, or revoked (missing_api_key, invalid_api_key) — or past its end date (api_key_expired).
permission_error

403

The key lacks the scope for this call (insufficient_scope), or the request came from outside the key's IP allowlist (ip_not_allowed).
invalid_request_error

400 · 404 · 409

Bad input, unknown id, or wrong state.
card_error

402

The gateway declined. Not a server error — show the message to your customer and let them try another card.
rate_limit_error

429

Back off; Retry-After tells you how long.
gateway_error

502 · 504

Upstream failed or timed out. Retry with the SAME idempotency key — the outcome is genuinely unknown.
api_error

500

Ours. Please report it.
typescript
import { FloatPayError } from "@floatpay/node";

try {
  await floatpay.transactions.create(params, { idempotencyKey: key });
} catch (e) {
  if (e instanceof FloatPayError) {
    if (e.type === "card_error") return showCustomer(e.message);
    if (e.retryable) return retryLater(key);   // same key, always
    throw e;
  }
  throw e;
}

Every response — success or error — carries a Request-Id header, and every error body adds a matching request_log_url. Paste either into Developers → Logs, or hand it to support: it finds the exact call, with no body or key ever attached to it.

API reference

Base URL https://float-pay.com/api/v1. All responses are JSON.

Download OpenAPI spec(3.1, JSON) — or the plain-markdown mirror at /llms.txt.

Import into Postman

Postman → Import → Link, paste https://float-pay.com/docs/openapi.json. Every endpoint below arrives as a request, with the bearer auth and Idempotency-Key header already wired from the spec.
POST/v1/transactions
Create a charge or an authorization. Requires Idempotency-Key.
amount

integer · required

Minor units. 1050 is $10.50.
payment_token

string

From FloatPay.js. Provide this or customer_vault_id.
customer_vault_id

string

An existing stored profile.
capture

boolean

false authorizes without charging. Defaults to true.
order_id

string

Your reference. Shown in the dashboard and CSV exports.
customer

object

first_name, last_name, email.
GET/v1/transactions
List transactions. Query: limit (max 100), page, created_after / created_before (YYYY-MM-DD), search, amount_min / amount_max (minor units). Defaults to the last 30 days.
GET/v1/transactions/{id}
Retrieve one transaction, with its full event history.
POST/v1/transactions/{id}/refund
Refund. Omit amount for a full refund.
POST/v1/transactions/{id}/void
Cancel a transaction that has not settled.
POST/v1/transactions/{id}/capture
Settle a prior authorization.
POST/v1/checkout/sessions
Create a hosted Checkout Session. Requires Idempotency-Key.
amount

integer

Minor units. Provide this or line_items, never both.
line_items

array

[{ name, description?, unit_amount, quantity }] — snapshotted at creation.
customer_email

string

Pre-fills the email field on the page.
description

string

Shown on the page and stored with the charge.
capture

boolean

false authorizes without charging. Defaults to true.
success_url

string

https (http://localhost in Test Mode). {CHECKOUT_SESSION_ID} is substituted.
cancel_url

string

Where the page's cancel link goes.
expires_at

string | integer

ISO 8601 or Unix seconds. Clamped to 24 hours.
metadata

object

At most 20 string values.
GET/v1/checkout/sessions/{id}
Retrieve a session. Read payment_status and transaction_id.
POST/v1/checkout/sessions/{id}/expire
Close an unpaid session early. A session already paid returns 409.
GET/v1/customers
List stored payment profiles.
GET/v1/customers/{id}
Retrieve one stored profile. Token and metadata only — no card number exists to return.

Products & prices

POST/v1/products
Create a product. Requires Idempotency-Key.
name

string · required

Up to 250 characters.
description

string

Up to 1,000 characters.
image_url

string

Must be an https:// URL.
active

boolean

Defaults to true.
metadata

object

Up to 20 string keys.
GET/v1/products
List products. Query: active (true/false), limit (max 100), page.
GET/v1/products/{id}
Retrieve one product.
PATCH/v1/products/{id}
Update a product, or archive it with { "active": false }.
DELETE/v1/products/{id}
Delete a product outright. Fails 409 if it has any prices — archive it instead.
POST/v1/prices
Create a price for a product. Requires Idempotency-Key.
product

string · required

A product id.
unit_amount

integer · required

Minor units. 1050 is $10.50.
currency

string

USD only, today.
type

string · required

one_time or recurring.
recurring_interval

string

day, week, month, or year. Required when type is recurring; omit for one_time.
recurring_interval_count

integer

1–52. Defaults to 1.
nickname

string

Up to 250 characters.
active

boolean

Defaults to true.
metadata

object

Up to 20 string keys.
GET/v1/prices
List prices. Query: product (a product id), active, limit, page.
GET/v1/prices/{id}
Retrieve one price.
PATCH/v1/prices/{id}
Update active, nickname, or metadata. Amount, currency, product and interval are fixed once a price exists — archive it with { "active": false } and create a new one instead. There is no delete: a price may still be referenced by a later Payment Link, Invoice, or Subscription.
POST/v1/payment_links
Create a payment link. Requires Idempotency-Key and the payment_links:write scope.
name

string · required

Your own label. The customer never sees it.
line_items

array

Exactly one of this or custom_amount. Each item takes price_id, or name and unit_amount, plus quantity and adjustable_quantity { enabled, min, max }. At most 20 items, at most one adjustable.
custom_amount

object

{ minimum, maximum, presets?, label? } — minor units. The customer names the amount inside these bounds.
collect

object

{ phone, address } — booleans. Collected on the pre-step and kept with the session.
custom_fields

array

At most 3: { key, label, type: text|numeric|dropdown, options?, optional? }. Answers arrive as cf_<key> on the session.
after_completion

object

{ type: "message", message } or { type: "redirect", url } with {CHECKOUT_SESSION_ID}. Defaults to a message.
restrictions

object

{ completed_sessions: { limit } }. The link closes itself when the limit is reached.
inactive_message

string

Shown on the page once the link is closed.
active

boolean

Defaults to true.
metadata

object

Up to 20 string keys, minus one per custom field.
GET/v1/payment_links
List payment links. Query: active, limit, page.
GET/v1/payment_links/{id}
Retrieve one payment link.
PATCH/v1/payment_links/{id}
Update active, name, metadata, after_completion, inactive_message or restrictions. The price, the questions and the collection toggles are fixed once the link exists. There is no delete.
GET/v1/payment_links/{id}/sessions
The Checkout Sessions this link created, newest first — who paid, for how much, and what they answered.

Invoices

POST/v1/invoices
Create a draft invoice. Requires Idempotency-Key.
customer_email

string · required

Where the invoice is sent.
customer_name

string

Shown on the page and the PDF.
items

array

[{ price? , description?, quantity?, unit_amount? }] — a catalog price, or free text with its own amount. Snapshotted.
discount_amount

integer

Minor units, subtracted from the subtotal. Never more than it.
tax_amount

integer

Minor units, added after the discount.
days_until_due

integer

0 is on receipt. Turned into a calendar date at write time. Provide this or due_date.
due_date

string

YYYY-MM-DD.
memo

string

Shown on the page, the email and the PDF.
footer

string

The small print at the bottom of the document.
custom_fields

array

[{ label, value }], at most four — a PO number, a project code.
reminder_schedule

object

{ before_days: [3], on_due: true, after_days: [3, 7] } by default.
metadata

object

At most 20 string values.
GET/v1/invoices
List invoices. Query: status (draft, open, past_due, paid, void, uncollectible, all), search (number, name or email), limit (max 100), page. Lines are omitted from a list — retrieve one to get them.
GET/v1/invoices/{id}
Retrieve one invoice, with its lines.
PATCH/v1/invoices/{id}
Update a draft. Anything finalized answers 409 invalid_state. Sending items replaces the whole list.
DELETE/v1/invoices/{id}
Delete a draft. Nothing was sent and no number was minted, so nothing is lost. Use /void for the rest.
POST/v1/invoices/{id}/finalize
draft → open. Mints INV-0001 and locks the lines. Fails 400 with no lines.
POST/v1/invoices/{id}/send
Email the customer the invoice, the pay link and the PDF. Safe to call again to chase them; only the first send emits invoice.sent.
POST/v1/invoices/{id}/void
Cancel an open invoice. The number is kept, and any open pay page stops working.
POST/v1/invoices/{id}/mark_paid
Settled outside FloatPay — a cheque, a bank transfer. No transaction is created; paid_out_of_band is true.
POST/v1/invoices/{id}/mark_uncollectible
Written off. Distinct from void, which says the invoice should never have existed.

Things to know

The parts that will surprise you later if we do not say them now.

Timestamps are gateway-local, with no offset

created_at looks like 2026-08-20T15:35:00 — no Z, because it is your gateway account’s local time and we will not stamp a timezone we cannot verify. Do not parse it as UTC. If you need absolute ordering, use the order in which we return results.

Pages are a point-in-time view

Pagination is page and limit, not a cursor, because the underlying data is fetched as a date window. If new payments land while you page through today, a row can shift. For a stable snapshot, narrow the window with created_before.

A Merchant Account is sandbox-only until live actions are enabled

Every FloatPay Merchant Account starts able to do everything in Test Mode and nothing with real money — we enable live actions per account, once. Until then, taking a payment or refunding one from the dashboard answers 403 live_actions_disabled, with the same status and the same wording whichever screen you were on. Test Mode is never gated: build the whole integration against it first.

Rate limit: 100 requests per minute per key

Every response carries RateLimit-Remaining. Read it rather than discovering the ceiling under load.

Not in v1 yet

Invoices, subscriptions, payment links and vault writes are not exposed yet. Poll GET /v1/transactions in the meantime — or take the events you need over a webhook — and tell us what you need next; the list is shaped by what integrators actually ask for.

Questions? See pricing or write to hello@float-pay.com.