Node.js and PHP SDKs
Flint's official server SDKs let your backend call the Flint API with a typed method for every operation: @flintpay/node for TypeScript and JavaScript, and flintpay/flint for PHP. Both are generated from the same API contract as the API reference, so methods, parameters, and response fields use the names you see there. Release 7.0.0 covers 609 operations.
The SDKs handle the parts of an integration that are easy to get wrong over raw HTTP: they keep amounts out of floating point, send and reuse idempotency keys, retry only when a retry can't repeat an action, follow pagination cursors, and verify webhook signatures.
npm
@flintpay/node
npm install @flintpay/node
Node.js 22.3+ as ESM, or 22.12+ to require() it from CommonJS. TypeScript projects need TypeScript 5.9+ with NodeNext module resolution and @types/node 22.16+. Plain JavaScript works too. Package on npm.
Packagist
flintpay/flint
composer require flintpay/flint
PHP 8.2+ with the curl and json extensions, and no framework dependency. JWT webhook verification also needs openssl. Package on Packagist.
Every Flint API key is secret, so the SDKs run on your server, a worker, or another trusted backend, never in browser or mobile code.
Create a client#
A client holds your credential and sends every request to https://api.withflintpay.com. Constructing one makes no network calls.
import { Client } from "@flintpay/node";
const flint = new Client({ apiKey: process.env.FLINT_API_KEY! });
Call await flint.close() when your process is done with the client, to release any open event streams.
<?php
require __DIR__ . '/vendor/autoload.php';
use Flint\{Client, ClientOptions};
$flint = new Client(new ClientOptions(apiKey: getenv('FLINT_API_KEY')));
The client owns one reusable cURL handle. Use it from one execution context at a time, and call $flint->close() when a script is done with it.
The key decides where requests run. A flint_test_ key runs in test mode against the sandbox it belongs to, and a flint_live_ key runs against your live account. Both use the same hostname and the same code, so moving to production is a change of key.
Default credentials#
When you pass no credential, the client finds one in the environment the first time it sends a request:
FLINT_API_KEY, an API key.FLINT_ACCESS_TOKEN, an access token you obtained elsewhere. Setting both variables fails withambiguous_credentials.- A workload identity, when
FLINT_WORKLOAD_IDENTITY_IDis set. In GitHub Actions, grant the jobpermissions: id-token: writeand the SDK requests the runner's OIDC token and exchanges it for a short-lived Flint token. On other platforms, put the OIDC token inFLINT_OIDC_TOKENor the path of a token file inFLINT_OIDC_TOKEN_FILE. The SDK caches the Flint token and refreshes it before it expires.
So with FLINT_API_KEY set, new Client() in Node or new Client(new ClientOptions()) in PHP is enough. A credential you pass always wins over the environment.
An app that calls Flint for many merchants, such as a partner app, should set ambientCredentials: false and pass each merchant's credential explicitly. A request that is missing a merchant's credential then fails instead of running under your app's own key.
Take a payment#
Create an order, open a Flint-hosted checkout for it, send the buyer there, and read the paid order back.
const order = await flint.orders.create(
{
line_items: [
{
type: "custom",
name: "Design consultation",
quantity: "1",
unit_price_money: { amount: "2500", currency: "USD" },
fulfillment: { requirement: "none" },
},
],
},
{ idempotencyKey: "first-payment-order-001" },
);
const session = await flint.checkoutSessions.create(
{
type: "order",
order_id: order.order_id,
redirects: {
success_url: "https://example.com/thanks",
cancel_url: "https://example.com/checkout",
},
},
{ idempotencyKey: "first-payment-checkout-001" },
);
// Redirect the buyer to this URL.
console.log(session.url);
// After they pay:
const paid = await flint.orders.get(order.order_id);
console.log(paid.payment_status); // "paid"
use Flint\RequestOptions;
$order = $flint->orders->create([
'line_items' => [[
'type' => 'custom',
'name' => 'Design consultation',
'quantity' => '1',
'unit_price_money' => ['amount' => '2500', 'currency' => 'USD'],
'fulfillment' => ['requirement' => 'none'],
]],
], new RequestOptions(idempotencyKey: 'first-payment-order-001'));
$session = $flint->checkoutSessions->create([
'type' => 'order',
'order_id' => $order->order_id,
'redirects' => [
'success_url' => 'https://example.com/thanks',
'cancel_url' => 'https://example.com/checkout',
],
], new RequestOptions(idempotencyKey: 'first-payment-checkout-001'));
// Redirect the buyer to this URL.
echo $session->url, PHP_EOL;
// After they pay:
$paid = $flint->orders->get($order->order_id);
echo $paid->payment_status, PHP_EOL; // paid
orders.create returns the order with its totals computed. checkoutSessions.create returns the session, and its url is the hosted page that collects the order's balance. Pay with a test card and orders.get reports payment_status as paid. In production, fulfill from the order.paid webhook, not from the success redirect, because a buyer can close the tab before the redirect happens.
Testing walks through the same three calls over raw HTTP, with the response bodies. To run them in a web app with the webhook that grants access, follow Accept a payment with Next.js, which has an Express tab.
What a call looks like#
Every method has the same shape:
- Path IDs first, in URL order.
flint.orders.get(orderId),flint.customers.update(customerId, { email }),flint.orders.updateLineItem(orderId, lineItemId, { quantity }). - Then one flat params object. Body fields and query parameters sit together under the API's wire names, such as
unit_price_moneyandpage_size, never camelCase. When a body has alternatives, include the field that picks one, such astypeoncheckoutSessions.createorrefund_typeonrefunds.create. The SDK rejects fields the operation doesn't declare before sending anything. - Then request options. A plain object in Node, a
RequestOptionsin PHP. Idempotency keys, timeouts, and per-request credentials go here; see Request options.
A few operations whose body takes one of several shapes, such as orders.pay and paymentIntents.confirm, take a single input object instead, with the path ID beside a body: flint.orders.pay({ order_id, body: { ... } }, { idempotencyKey }).
Methods return the resource, not the response envelope: orders.get returns the order. Operations whose response names more than one object return all of them: orders.pay returns { order, order_payment_attempt }. list returns one page, { data, next_page_token }. Binary downloads, such as an invoice PDF, return { data, meta } with the bytes in data. The Node reference and PHP reference give each method's signature and return.
Read the HTTP response#
Every method has a WithResponse companion that returns the full response body, the response metadata, and the raw text:
const response = await flint.orders.getWithResponse(orderId);
response.body.data.order_id;
response.meta.status; // 200
response.meta.requestId; // include this when you contact Flint about a request
Use it when the status code carries meaning. Create-or-return operations, such as fulfillments.createEvent and customers.create, return 201 when Flint created the resource and 200 when it returned an existing one. body.meta.idempotency_replayed is true when the response replays an earlier request with the same idempotency key.
Exact money#
Amounts, quantities, and other 64-bit integers are strings in both SDKs: "2500", not 2500. They are still integers in the currency's minor unit, so "2500" is $25.00, but a value never passes through floating point on its way to Flint or back. On the wire these are JSON integers; the SDK converts them. Raw HTTP requests must send numbers.
In TypeScript these fields have the type `${bigint}`. Pass a string literal such as "2500" or a bigint such as BigInt(quantity). A plain string, such as the result of String(total), doesn't type-check. Money and amounts covers minor units and currencies.
Errors#
A failed call throws SdkError. Its most important field is outcome, which says whether the request could have changed anything at Flint:
outcome | What happened | What to do |
|---|---|---|
not_sent | The SDK rejected the call before sending it, for example a missing required field or idempotency key. Nothing changed. | Fix the input and call again. |
response | Flint answered with an error. code is the API's error code, such as VERSION_CONFLICT. | Branch on code. |
unknown | The request may have reached Flint, and the SDK's own retries didn't settle it. | Call again with the same input and error.idempotencyKey, or read the resource back first. |
import { SdkError } from "@flintpay/node";
try {
await flint.refunds.create(
{
refund_type: "amount",
payment_intent_id: paymentIntentId,
amount_money: { amount: "500", currency: "USD" },
reason: "requested_by_customer",
},
{ idempotencyKey: savedKey },
);
} catch (error) {
if (!(error instanceof SdkError)) throw error;
console.error(error.code, error.status, error.meta?.requestId);
if (error.outcome === "unknown") {
// The refund may exist. Retry with error.idempotencyKey, or read it back first.
}
}
use Flint\{RequestOptions, SdkError};
try {
$flint->refunds->create([
'refund_type' => 'amount',
'payment_intent_id' => $paymentIntentId,
'amount_money' => ['amount' => '500', 'currency' => 'USD'],
'reason' => 'requested_by_customer',
], new RequestOptions(idempotencyKey: $savedKey));
} catch (SdkError $error) {
error_log($error->errorCode . ' ' . $error->status . ' ' . ($error->meta['requestId'] ?? ''));
if ($error->outcome === SdkError::OUTCOME_UNKNOWN) {
// The refund may exist. Retry with $error->idempotencyKey, or read it back first.
}
}
The error also carries kind, a broad category such as validation, conflict, rate_limit, or transport, and retryAfterMs when Flint sent Retry-After. Each kind has its own subclass of SdkError, such as errors.ConflictError in Node and Flint\Errors\ConflictError in PHP, so you can catch one category with instanceof or a catch clause. In PHP the API's code is errorCode, because code belongs to PHP's Exception.
Some outcomes are not errors. A declined card arrives as a payment attempt in a successful response, which Declines and payment attempts covers. A refund the processor rejects is still a created refund. The response can already carry status: "failed"; if it is pending, follow the refund with a read or event, as Refunds describes.
Retries and idempotency#
A call retries on its own only when the retry can't run your request twice: reads retry, and so do writes that carry an idempotency key. How a write gets its key depends on the operation.
| The operation's key | Examples | What the SDK does |
|---|---|---|
| Required | refunds.create, orders.pay, paymentIntents.create | Requires your key. TypeScript won't compile the call without idempotencyKey, and PHP throws SdkError with outcome: "not_sent". |
| Optional | Most writes, such as orders.create and customers.update | Uses your key if you pass one. Otherwise it generates one before the first attempt and sends it on every retry of that call. |
| Not accepted | Previews and other writes that only compute an answer | Sends the request once. Passing idempotencyKey fails with outcome: "not_sent". |
Operations that move money or stock require your key because the key has to outlive one call. Save it with the action before the first attempt, and every later attempt, from any process, sends the same key and gets the original result instead of running the action again. A key the SDK generates lasts only for one call.
await flint.refunds.create(params, { idempotencyKey: refundJob.idempotencyKey });
The SDK retries when no response arrived, and when Flint's error has remediation.retry: "same_request", which covers rate limits, a request still in progress under the same key, and most 5xx errors. It waits at least as long as Retry-After when Flint sends it, and backs off with jitter otherwise. A call makes at most 3 attempts and stops at its deadlineMs. Errors with remediation.retry of after_refresh or none reach your code on the first attempt. Set maxAttempts: 1 to turn retries off.
When the attempts run out with outcome: "unknown", the error's idempotencyKey is the key that was sent. Call again with that key and the same input, or follow the operation's recovery path in Idempotency. A new key could run the action twice.
Flint accepts a key on 335 writes and requires one on 35 of them. Idempotency lists them and covers choosing keys and how long Flint keeps them.
Request options#
The last argument to any method sets options for that call only. timeoutMs, deadlineMs, maxAttempts, and the credential options also work on the client, where they set the default for every call.
| Option | What it does |
|---|---|
idempotencyKey | Sent as Idempotency-Key. See Retries and idempotency. |
timeoutMs | Limit for one attempt, including reading the body. Default 10000. |
deadlineMs | Limit for the whole call, including retries and the waits between them. A duration, not a timestamp. Default 30000. In a listItems or listPages iteration, each page fetch gets its own deadline. |
maxAttempts | Total attempts, including the first, up to the operation's limit of 3. 1 turns retries off. |
maxPages, maxItems | Caps for a listItems or listPages iteration. maxPages defaults to 1000. |
headers | Extra headers for this request. The SDK controls the credential and Flint-Version headers. |
apiKey | A different API key for this call. The other credential shortcuts work here too. |
signal (Node), cancellation (PHP) | Stops the local work. A write Flint already received may still complete. |
Pagination#
Every list operation has an Items companion that follows next_page_token for you and yields one resource at a time, with your filters applied to every page:
for await (const order of flint.orders.listItems(
{ payment_status: ["paid"], page_size: 100 },
{ maxItems: 1000 },
)) {
console.log(order.order_id, order.pricing_amounts.total_money.amount);
}
foreach ($flint->orders->listItems(
['payment_status' => ['paid'], 'page_size' => 100],
new RequestOptions(maxItems: 1000),
) as $order) {
echo $order->order_id, PHP_EOL;
}
Iteration is lazy: the SDK fetches the next page only when you reach it. listPages yields whole pages instead, and list fetches a single page. Pagination covers sorting, filters, and what a cursor holds.
Verify a webhook#
New webhook endpoints sign each delivery with a Flint JWT, which you verify against Flint's public keys and your endpoint's ID. There is no shared secret. The SDK checks the token, its issuer, audience, and lifetime, and that it signs the exact body you received. It fetches and caches Flint's keys from https://api.withflintpay.com/.well-known/jwks.json.
import express from "express";
import { SdkError } from "@flintpay/node";
const app = express();
// Register before express.json(), so the handler gets the raw bytes.
app.post("/webhooks/flint", express.raw({ type: "application/json" }), async (req, res) => {
let verified;
try {
verified = await flint.verifyWebhookAsync(req.body, req.headers, {
endpointIds: [process.env.FLINT_WEBHOOK_ENDPOINT_ID!],
});
} catch (error) {
if (error instanceof SdkError && error.code === "webhook_jwks_unavailable") {
return res.sendStatus(503); // Flint's keys couldn't be loaded: ask Flint to retry.
}
return res.sendStatus(400);
}
if (verified.known) {
// verified.event is typed by its event_type.
}
// Record the event durably, then acknowledge it.
res.sendStatus(200);
});
use Flint\SdkError;
try {
$verified = $flint->verifyWebhookDelivery(
file_get_contents('php://input'),
getallheaders(),
['endpoint_ids' => [getenv('FLINT_WEBHOOK_ENDPOINT_ID')]],
);
} catch (SdkError $error) {
http_response_code($error->errorCode === 'webhook_jwks_unavailable' ? 503 : 400);
exit;
}
if ($verified['known']) {
$event = $verified['event'];
}
// Record the event durably, then acknowledge it.
http_response_code(200);
Under PHP-FPM, pass a PSR-16 cache as 'cache', or enable APCu, so workers share Flint's keys instead of each fetching them. Behind Apache, set CGIPassAuth On so PHP receives the Authorization header.
Pass the raw request body. Verifying JSON you parsed and serialized again fails, because the signature covers the exact bytes. On a failed check the SDK throws SdkError with a code such as webhook_invalid_token, webhook_invalid_audience, webhook_token_expired, or webhook_body_mismatch; answer 400 and process nothing.
known is false for an event the SDK doesn't recognize, usually an event type newer than your SDK release, and reason says why. The delivery is still authentic. Store it for review rather than treating it as handled.
HMAC endpoints#
An endpoint with hmac in its signing_methods signs deliveries with a shared secret instead, in the Standard Webhooks format. Flint returns the secret once, when you create the endpoint:
const endpoint = await flint.webhookEndpoints.create({
url: "https://example.com/webhooks/flint",
enabled_events: ["order.paid"],
signing_methods: ["hmac"],
});
// Store endpoint.secret in your secret manager now. Reads never return it.
Verify those deliveries with verifyWebhook and a list of secrets. Pass the current and previous secret while you rotate:
const { event, known } = flint.verifyWebhook(req.body, req.headers, [
process.env.FLINT_WEBHOOK_SECRET!,
]);
$verified = $flint->verifyWebhook(
file_get_contents('php://input'),
getallheaders(),
[getenv('FLINT_WEBHOOK_SECRET')],
);
It checks the webhook-id, webhook-timestamp, and webhook-signature headers and rejects a timestamp more than 300 seconds from your clock. To add HMAC to an existing endpoint, see Change signing methods.
Events that flint listen forwards to localhost are HMAC-signed with the secret the CLI prints, whatever your registered endpoints use, so verify them with verifyWebhook during local development.
Verification is not deduplication. Retries and resends deliver the same event more than once; Webhooks covers storing webhook_event_id so you process each event once.
Credential modes#
Most operations take your API key, which is the merchant mode. Operations that act for a signed-in buyer, a checkout, an invoice, or an account still being set up take a different credential. Each mode has a shortcut option that selects it and supplies its token, on the client or on one request.
| Mode | Shortcut | Credential | Used by |
|---|---|---|---|
merchant | apiKey | Your API key, or an access token | Most of the API |
customer | customerToken | A customer session secret, flint_cses_... | The /v1/me operations, under flint.me |
checkout | checkoutToken | The checkout_auth_token from a checkout session | That session's checkout and order calls, when you build your own checkout |
invoice | invoiceToken | The access token from an invoice's public URL | That invoice's buyer detail, PDF, and checkout session |
onboarding | onboardingToken | The token from POST /v1/onboarding/verify-email | Setting up an account before it has an API key, in its default sandbox |
const myOrders = await flint.me.listOrders({}, { customerToken: sessionSecret });
const order = await flint.orders.get(orderId, undefined, {
checkoutToken: checkoutAuthToken,
});
In TypeScript, each method accepts only the modes its operation allows, so passing customerToken to a merchant-only method doesn't compile. A request sends one credential: give one shortcut, or authMode with credentials, not both. Authentication covers each credential and what it can reach.
Versions and upgrades#
Each SDK release is generated for one API version and sends it as Flint-Version on every request, so a response shape doesn't change under a deployed app, except for the breaking changes that currently ship into supported versions in place. Release 7.0.0 of both packages targets API version 2026-09-07. To move to a new API version, upgrade the SDK. The version table shows which release targets which API version, and API versions and upgrades covers what changes between versions.
Major SDK releases can change method signatures. Before you upgrade across one, read the migration guide. The changes most integrations notice:
- 7.0.0 calls the API's current paths, such as
/v1/sandboxes. A few methods have new names, such asme.listPaymentIntentsforme.listPayments, and the old names still work. Bodies with alternatives need the field that picks one, such astypeoninvoices.create, and the SDK rejects undeclared fields. - 6.0.0 made required idempotency keys part of the TypeScript signatures, and made reads and keyed writes retry on their own. Remove your own retry loop around SDK calls, or set
maxAttempts: 1. It also added JWT webhook verification. - 5.1.0 added default credentials from the environment.
Examples for every operation#
Each package ships a runnable script per operation, named for its resource and method: node/examples/orders-create.mjs and php/examples/orders-create.php in flint-pay/flint-sdks. The generated references document every method's parameters, return, and accepted credential modes.
Next steps#
- Embedded payments: collect the card in your own page and let Flint confirm it.
- Build your own checkout: run checkout from your backend with the checkout credential.
- Webhooks: register an endpoint and fulfill from
order.paid. - CLI: the same API from a terminal or a CI job, including forwarding sandbox webhooks to localhost.
- Orders API reference and Payments API reference: every field on the objects these calls return.
