@backendfree/payments
Taking a payment into the business's own account, from your own server with a secret key.
Updated 22 October 2026 About 4 minutes View as Markdown
A project's money: what has been taken, and taking some. Built on
@backendfree/core.
npm install @backendfree/core @backendfree/payments
import { Client } from '@backendfree/core';
import { Payments } from '@backendfree/payments';
const payments = new Payments(
new Client({ origin: 'https://backendfree.com', key: process.env.BACKENDFREE_SECRET_KEY! }),
);
const payment = await payments.checkout({
amount: 3500, // minor units: CHF 35.00
description: 'Deposit',
success_url: 'https://shop.example/thanks?payment={id}',
cancel_url: 'https://shop.example/cart',
metadata: { order: 'A-1042' }, // your own references, returned on every read and event
});
redirect(payment.checkout_url);
{id} in either address becomes the payment's id, which does not exist until
this call returns, so the page after paying can read the payment it was for.
metadata comes back on the payment and in every payment.* event about it,
so the webhook can find the order it paid for: at most 20 keys, each value text.
Server only
Every method here needs a secret key, and constructing Payments with a
publishable one throws. A payment carries the paying customer's name and email,
and a checkout is a charge on a bank account, so none of it belongs in a bundle
whoever loads the site can read.
That is also why the mistake is refused at construction rather than by a 403 in production: the stack trace points at the line that is wrong.
Why there is no refund method
Refunding is the one act that moves money out of the business's own account, so it lives in the payments screen in the dashboard, behind a capability only the account owner holds.
A secret key lives in server code, deploy settings and CI, which is further than the account owner's own screen. A leaked key that can read payment records is one incident; one that could refund would drain the account. Reading payment records with a key is deliberate and bounded, so a developer can render a receipt; issuing money back is not the same thing and is not in the API.
Nothing here says a payment was made
A checkout is a page that exists. The provider's webhook is what says money moved, and the page the customer lands on afterwards says nothing at all.
Take the platform's payment.succeeded webhook and verify it:
import { verifyWebhook } from '@backendfree/core';
const event = await verifyWebhook({ body: await request.text(), headers, secret });
if (event.name === 'payment.succeeded') {
markPaid(event.data.metadata.order, event.data.amount, event.data.currency);
}
When the provider refuses
checkout() does not throw for a provider that would not open a page. The
payment comes back with an empty checkout_url and the reason on it, so the
attempt is on record and you have an id to try again with.
if (!payment.checkout_url) {
payment = await payments.offerAgain(payment.id, { success_url, cancel_url });
}
offerAgain is keyed on the payment, so asking twice is one page, and what is
owed comes off the payment rather than off the call. Calling checkout() a
second time would record a second payment for one order, and a customer handed
both pages could pay twice.
Reading what has been taken
await payments.list({ status: 'paid' });
await payments.payment(id);
await payments.forSubject('bookings.booking', bookingId); // was it paid for?
A payment says what it settled, how much, what has gone back and what is left to give back. It never says which provider account the money landed in: that is the business's own and appears nowhere in this API.
refundable is published rather than left to you to work out, because the
arithmetic is not what it looks like. A refund that is still settling is money
already spent, so subtracting only the settled ones shows more left than there
is.
Money
Always minor units and a currency. 3500 with CHF is 35 francs.
A checkout takes a currency counted in hundredths, the same ones a project's
currency is chosen from on its settings screen, and leaving currency out uses
the project's own. Anything else (JPY, KWD and ISK among them) throws with the
code invalid_currency and records no payment:
import { BackendFreeError } from '@backendfree/core';
try {
await payments.checkout({ amount: 500, currency: 'JPY', success_url, cancel_url });
} catch (error) {
if (error instanceof BackendFreeError && error.code === 'invalid_currency') {
// nothing was recorded: send a currency the project can be priced in
}
}
To show an amount, formatMoney reads the places from the currency rather
than from a constant, so it is right for any code, including ones a checkout
does not take:
import { formatMoney } from '@backendfree/payments';
formatMoney({ amount: 6500, currency: 'CHF' }, 'de-CH'); // CHF 65.00
formatMoney({ amount: 6500, currency: 'JPY' }, 'en-US'); // ¥6,500
Dividing by a hundred yourself is wrong in both directions: JPY has no minor unit and KWD has three places.
Reading a status
import { hasPaid, isOver } from '@backendfree/payments';
hasPaid(payment); // the money arrived, refunded payments included
isOver(payment); // nothing further will happen, so do not offer a page
A status only ever moves forward. A provider redelivers events for days and promises no order, so the platform applies each one as a maximum and a late "still pending" cannot undo a payment that arrived.