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
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.
npm install @floatpay/node3. 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.
<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.
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
curl https://float-pay.com/api/v1/transactions \
-H "Authorization: Bearer fp_test_..."The key decides test or live
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:readscope
transactions:writescope
customers:read · customers:writescope
catalog:read · catalog:writescope
checkout:writescope
payment_links:writescope
invoices:writescope
subscriptions:writescope
events:readscope
webhooks:writescope
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
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
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_tokenstring
customer_vault_idstring
floatpay.customers.list().Content-Security-Policy
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, 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
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.
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.
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
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.
Payment Links
A Payment Link is a RULE, not a single payment. Every visit creates a Checkout Session from that rule, so one link can take a hundred payments, and every amount is computed on our side. Your customer may choose a quantity or name a donation amount; nothing they send can name a price.
const link = await floatpay.paymentLinks.create(
{
name: "Consultation", // yours; the customer never sees it
line_items: [{ price_id: price.id }], // or { name, unit_amount }
custom_fields: [
{ key: "phone_number", label: "Best number to call", type: "text" },
],
after_completion: { type: "message", message: "We will call you today." },
restrictions: { completed_sessions: { limit: 20 } },
},
{ idempotencyKey: "link_consultation_v1" },
);
link.url; // https://float-pay.com/pay/l/plink_...Share it. The URL is the whole product. In the dashboard each link also has a QR code to download as SVG or PNG — the SVG is the one that scales to a poster — and a copyable buy-button snippet: one plain anchor, no JavaScript of ours running on your site.
<a href="https://float-pay.com/pay/l/plink_..."
target="_blank" rel="noopener"
style="display:inline-block;padding:12px 20px;border-radius:8px;
background:#111;color:#fff;font:600 15px/1 system-ui,sans-serif;
text-decoration:none">Pay Consultation</a>Let the customer choose. Use custom_amount for a donation, or adjustable_quantity on one line item for “how many?”. Both are bounded by values you set, and the bounds are enforced on our server, not in the page.
await floatpay.paymentLinks.create(
{
name: "Spring appeal",
custom_amount: {
minimum: 500, // $5 — minor units, like every amount
maximum: 100000, // $1,000
presets: [1000, 2500, 5000],
label: "Donation", // what the donor sees
},
},
{ idempotencyKey: "link_spring_appeal" },
);After payment is either a message on our page — for a merchant with nowhere to send anyone — or a redirect to your own URL, where {CHECKOUT_SESSION_ID} is replaced with the session id so your handler can retrieve the session and check payment_status.
Learn who paid without polling: subscribe to payment_link.paid. It carries the link id, the session id, the transaction id you can refund, the amount, the email, and custom_fields — the answers your customer typed, which is what you fulfil from.
{
"type": "payment_link.paid",
"data": {
"payment_link_id": "plink_...",
"session_id": "cs_...",
"transaction_id": "8912345678",
"amount_total": 15000,
"currency": "USD",
"customer_email": "buyer@example.com",
"custom_fields": { "phone_number": "+1 555 0100" }
}
}A link is priced once, and never repriced
PATCH changes whether the link is open, what happens after payment, the words shown when it is closed, the usage limit, its name and its metadata. It cannot change the price, the questions, or what is collected — the URL may already be on a printed poster, and a QR code that silently charges something else is the worst thing this feature could do. To sell something else, create another link.Turn a link off with deactivate(), or let it close itself with restrictions. Either way the page then shows your inactive_message, and no session is created — so nobody can pay a link that is done.
Invoices
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}.
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 fileTotals 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
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.
// 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
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.
// 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
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:
{
"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.
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
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
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.
/v1/webhook_endpointsurl, enabled_events (catalog types or ["*"]), description. The response is the only place secret ever appears. Maximum 16 per mode./v1/webhook_endpoints/v1/webhook_endpoints/{id}/v1/webhook_endpoints/{id}status (enabled / disabled)./v1/webhook_endpoints/{id}/secret/rollgrace_hours is 24 (default) or 0./v1/webhook_endpoints/{id}Events
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.
/v1/eventstype (comma-separated) or types[] (repeated), created[gte] / created[lte] (Unix seconds), page, limit (max 100)./v1/events/{id}Ordering is not a guarantee
created, and dedupe by id — the same rule webhook handlers follow.const page = await floatpay.events.list({ type: "transaction.succeeded", limit: 10 });
const event = await floatpay.events.retrieve(page.data[0].id);Testing
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.
Declineamount
AVS matchaddress
address1: 888, zip: 77777.CVV matchcvv
9993DS — frictionlesscard
4100000000000100 — authenticates with no challenge.3DS — challengecard
4100000000005000 — challenge code 12345 passes.ACHaccount / routing
24413815 / 490000018Test cardscard
4111111111111111 (Visa), 5431111111111111 (Mastercard), 341111111111111 (Amex), 6011000991300009 (Discover) — any future expiry, e.g. 10/29.Sandbox only
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.
/v1/test/events403 test_mode_only. Requires Idempotency-Key.typestring · required
dataobject
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.
/v1/events/streamevent: 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.for await (const event of floatpay.events.stream()) {
console.log(event.type, event.data.object);
}CLI
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
npx --package @floatpay/cli floatpay once it ships.npm run build --prefix cli && npm link --prefix cli
floatpay --helpLog 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.
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.
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] 41msTrigger and quick reads. The same test-event call as above, from the terminal — useful with listen running in another tab.
floatpay trigger transaction.succeeded --data '{"amount": 4200}'
floatpay transactions list --limit 5
floatpay events list --type transaction.succeededErrors
{
"error": {
"type": "card_error",
"code": "card_declined",
"message": "INSUFFICIENT FUNDS",
"param": "amount"
}
}authentication_error401
missing_api_key, invalid_api_key) — or past its end date (api_key_expired).permission_error403
insufficient_scope), or the request came from outside the key's IP allowlist (ip_not_allowed).invalid_request_error400 · 404 · 409
card_error402
rate_limit_error429
gateway_error502 · 504
api_error500
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
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
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./v1/transactionsIdempotency-Key.amountinteger · required
payment_tokenstring
customer_vault_idstring
captureboolean
order_idstring
customerobject
/v1/transactionslimit (max 100), page, created_after / created_before (YYYY-MM-DD), search, amount_min / amount_max (minor units). Defaults to the last 30 days./v1/transactions/{id}/v1/transactions/{id}/refundamount for a full refund./v1/transactions/{id}/void/v1/transactions/{id}/capture/v1/checkout/sessionsIdempotency-Key.amountinteger
line_itemsarray
customer_emailstring
descriptionstring
captureboolean
success_urlstring
cancel_urlstring
expires_atstring | integer
metadataobject
/v1/checkout/sessions/{id}payment_status and transaction_id./v1/checkout/sessions/{id}/expire/v1/customers/v1/customers/{id}Products & prices
/v1/productsIdempotency-Key.namestring · required
descriptionstring
image_urlstring
activeboolean
metadataobject
/v1/productsactive (true/false), limit (max 100), page./v1/products/{id}/v1/products/{id}{ "active": false }./v1/products/{id}409 if it has any prices — archive it instead./v1/pricesIdempotency-Key.productstring · required
unit_amountinteger · required
currencystring
typestring · required
recurring_intervalstring
recurring_interval_countinteger
nicknamestring
activeboolean
metadataobject
/v1/pricesproduct (a product id), active, limit, page./v1/prices/{id}/v1/prices/{id}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./v1/payment_linksIdempotency-Key and the payment_links:write scope.namestring · required
line_itemsarray
custom_amountobject
collectobject
custom_fieldsarray
after_completionobject
restrictionsobject
inactive_messagestring
activeboolean
metadataobject
/v1/payment_linksactive, limit, page./v1/payment_links/{id}/v1/payment_links/{id}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./v1/payment_links/{id}/sessionsInvoices
/v1/invoicesIdempotency-Key.customer_emailstring · required
customer_namestring
itemsarray
discount_amountinteger
tax_amountinteger
days_until_dueinteger
due_datestring
memostring
footerstring
custom_fieldsarray
reminder_scheduleobject
metadataobject
/v1/invoicesstatus (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./v1/invoices/{id}/v1/invoices/{id}409 invalid_state. Sending items replaces the whole list./v1/invoices/{id}/void for the rest./v1/invoices/{id}/finalizeINV-0001 and locks the lines. Fails 400 with no lines./v1/invoices/{id}/sendinvoice.sent./v1/invoices/{id}/void/v1/invoices/{id}/mark_paidpaid_out_of_band is true./v1/invoices/{id}/mark_uncollectibleThings to know
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
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
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
RateLimit-Remaining. Read it rather than discovering the ceiling under load.Not in v1 yet
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.