BillKit/DocsConsole
Payments guide

Charge once, without a subscription

Take a single payment for a top-up, a setup fee or a one-time order. No mandate, no renewal, and an invoice all the same.

24 Sept 2026·Beginner·8 min read

Not everything you sell recurs. A one-off payment is a single charge with no subscription behind it and no stored payment credential: the customer pays, the money settles, and nothing else happens. Use it for a top-up, a setup fee, an add-on, a single order.

The customer can pay on Mollie’s hosted page (the default) or in a payment form embedded in your own page. Both create the same object, emit the same events and are refunded the same way; only where the buyer types their card differs. Take the payment on your own page covers the embedded form.

It is not a way to save a card for later. Nothing about this flow mints a mandate, so a second charge means a second call and another trip through checkout. If you want to charge the same customer again without asking, that is a subscription.

Create the payment

The customer has to exist first, because a charge has to be attributable to somebody before it is made. There is no customer_email shortcut here, unlike a checkout session. A deleted customer is a 404.

shell
curl https://api.billkit.eu/v1/checkout/one_shot \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711" \
-d '{
  "customer_id": "cus_Ja7Ye2vMq9xN4TdWpr",
  "amount_cents": 1999,
  "currency": "EUR",
  "payment_method": "creditcard",
  "description": "One premium widget",
  "success_url": "https://example.com/thanks",
  "cancel_url": "https://example.com/cart"
}'
FieldNotes
customer_idrequiredAn existing cus_….
amount_centsrequiredPositive integer in the smallest unit.
currencyrequiredThree-letter code, and one your account accepts. Anything else is a 400.
payment_methodrequired when hostedOne of creditcard, ideal, bancontact, eps, applepay, paypal, banktransfer. On the hosted page you commit to one up front, so there is no method picker. directdebit is not accepted (a 400): BillKit uses SEPA Direct Debit only for subscription renewals and does not offer one-off direct debit. Omit it with ui_mode: "embedded", where the buyer picks.
ui_modehosted (the default) or embedded. See Take the payment on your own page.
success_urlrequiredWhere the customer lands after paying. cancel_url is optional.
descriptionUp to 255 characters. Shown on the Mollie page, on the payer’s bank statement, and as the invoice line. Defaults to Payment.
tax_behaviorinclusive or exclusive. Whether the amount you sent includes VAT or has it added on top.
refund_window_daysHow long this charge stays refundable. Default 30, maximum 365, 0 disables refunds for it.
metadataYour own correlation bag, echoed on the object and its events.

payment_method must be one your account can settle in this currency. Read GET /v1/tenant/capabilities for what your Mollie profile has enabled; a method it cannot process is refused here with a 400 rather than failing at the provider.

The bank-redirect methods are national, and BillKit does not enforce that against your customer. iDEAL needs a Dutch bank account, Bancontact a Belgian one, EPS an Austrian one. BillKit does not refuse the charge on country_code, because that field is the billing and tax country, while eligibility is about where the buyer banks, and the two differ for real people. Pick the method from what your buyer chose, not from their invoice address. If they cannot complete it, the payment fails and you get one_shot_payment.failed.

banktransfer settles in days, not seconds. The payer is handed bank details and pays on their own schedule, so Mollie holds the payment open for about a fortnight and expires_at comes back roughly 13 days out. Do not treat a pending bank transfer as a failure, and do not poll for it. Wait for one_shot_payment.succeeded, which may arrive a week after you created the charge. It is one-off only: it mints no mandate, so it cannot start a subscription or settle a metered cycle.

Some methods have a provider minimum, and the check runs on the gross. EPS will not take a charge below €1.00, iDEAL below €0.01 and Apple Pay below €0.02. Cards have none. A charge under the floor is refused here with a 400 parameter_invalid on amount_cents naming the figure, rather than failing later at the provider with nothing to look at. Under tax_behavior: “exclusive” the amount compared is the VAT-inclusive total you actually charge, not the net you sent.

The response carries a redirect_url. Send the customer there. expires_at is when Mollie will give up on an unpaid payment, where Mollie reports one.

Response · 200 OK
{
"id": "osp_Th5Rk2wN8qX4mVpLcy",
"object": "one_shot_payment",
"created": 1789344700,
"livemode": false,
"customer_id": "cus_Ja7Ye2vMq9xN4TdWpr",
"amount_cents": 1999,
"currency": "EUR",
"status": "open",
"payment_method": "creditcard",
"ui_mode": "hosted",
"client_secret": null,
"description": "One premium widget",
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"redirect_url": "https://www.mollie.com/checkout/select-method/...",
"expires_at": null,
"paid_at": null,
"failure_reason": null,
"metadata": {},
"amount_refunded_cents": 0,
"amount_refundable_cents": 0,
"amount_disputed_cents": 0,
"net_cents": 1652,
"tax_cents": 347,
"tax_reverse_charged": false
}

Take the payment on your own page

Send ui_mode: "embedded" and leave payment_method out. No payment is created yet: the response carries a client_secret instead of a redirect_url, and you hand that secret to the one-off payment element in the browser. The buyer picks a method, enters their card or chooses their bank there, and pays without leaving your site, except for the steps that have to happen somewhere else (a 3D Secure challenge, their bank, PayPal, the bank-transfer instructions), after which they come back to your success_url.

shell
curl https://api.billkit.eu/v1/checkout/one_shot \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4712" \
-d '{
  "customer_id": "cus_Ja7Ye2vMq9xN4TdWpr",
  "amount_cents": 1999,
  "currency": "EUR",
  "ui_mode": "embedded",
  "description": "One premium widget",
  "success_url": "https://example.com/thanks",
  "cancel_url": "https://example.com/cart"
}'
Response · 200 OK
{
"id": "osp_Wq3Lm8vN2xR7tKpZcd",
"object": "one_shot_payment",
"created": 1789344760,
"livemode": false,
"customer_id": "cus_Ja7Ye2vMq9xN4TdWpr",
"amount_cents": 1999,
"currency": "EUR",
"status": "open",
"payment_method": null,
"ui_mode": "embedded",
"client_secret": "osp_Wq3Lm8vN2xR7tKpZcd_secret_example",
"description": "One premium widget",
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"redirect_url": null,
"expires_at": 1789346560,
"paid_at": null,
"failure_reason": null,
"metadata": {},
"amount_refunded_cents": 0,
"amount_refundable_cents": 0,
"amount_disputed_cents": 0,
"net_cents": 1652,
"tax_cents": 347,
"tax_reverse_charged": false
}

The same call from the Node SDK:

typescript
const payment = await client.oneShotPayments.create({
customer_id: "cus_Ja7Ye2vMq9xN4TdWpr",
amount_cents: 1999,
currency: "EUR",
ui_mode: "embedded",
description: "One premium widget",
success_url: "https://example.com/thanks",
cancel_url: "https://example.com/cart",
});
// client_secret is typed nullable because a hosted payment has none. An
// embedded one always carries it on this response. Send it to the browser.
const clientSecret = payment.client_secret!;

Python takes ui_mode="embedded" on one_shot_payments.create, PHP the same key in the array, and Laravel’s Billable trait has checkoutChargeEmbedded(), which returns the object rather than a redirect.

About the client_secret:

  • It is returned by this call only. GET /v1/checkout/one_shot/{id} and the list never carry it, and a retry of the create with the same Idempotency-Key returns the same one.
  • It authorises this one payment and nothing else in your account. It cannot read or confirm any other one-off payment, and it does nothing on a checkout session, so it is safe to send to a browser. Your API key never is.
  • It stops working at expires_at, 30 minutes after the create. A payment nobody confirmed by then is closed as expired and you get one_shot_payment.failed, as you would for a buyer who abandoned the hosted page. That happens on BillKit’s expiry pass, which runs hourly and waits an hour past expires_at, so expect the event one to two hours later, not at expires_at itself. Create a new one-off payment if the buyer comes back.
  • Confirming does not extend it. An element mounted or reloaded after expires_at reports client_secret_expired whatever the payment’s state, including one that was paid, so check the payment before creating another.

The amount is fixed when you create it. VAT is worked out then, from the customer’s country, and the element shows the buyer the total and the VAT in it before they pick a method. Once the buyer confirms, payment_method is set and expires_at becomes the provider’s own deadline for that payment.

Mount the element with the secret. It is the same @billkit-eu/js package and the same frame-src https://js.billkit.eu CSP rule as embedded checkout:

Vanilla JS
import { mountOneShotPaymentElement } from "@billkit-eu/js";

const element = mountOneShotPaymentElement("#checkout", {
clientSecret,
theme: { colorPrimary: "#6d28d9", borderRadius: "10px" },
onChange: ({ complete }) => {
  payButton.disabled = !complete;
},
onSuccess: ({ oneShotPaymentId }) => {
  window.location.href = `/thanks?payment=${oneShotPaymentId}`;
},
onError: ({ code, message }) => {
  payButton.disabled = false;
  if (code !== "payment_declined") showBanner(message);
},
});

payButton.addEventListener("click", () => element.submit());
React
import { BillKitProvider, OneShotPaymentElement } from "@billkit-eu/react";

export function PayNow({ clientSecret }: { clientSecret: string }) {
return (
  <BillKitProvider>
    <OneShotPaymentElement
      clientSecret={clientSecret}
      onSuccess={({ oneShotPaymentId }) => router.push(`/thanks?payment=${oneShotPaymentId}`)}
      onError={({ message }) => toast.error(message)}
    />
  </BillKitProvider>
);
}

The element offers the one-off methods your Mollie account has enabled for the currency, above each method’s provider minimum for this amount, and without the national bank schemes (iDEAL, Bancontact, EPS) when the customer’s stored country rules them out. It never offers directdebit. If nothing is payable, it draws no form and onError receives no_payment_methods. Card fields are Mollie’s own, inside the element; on the rare occasion your Mollie profile cannot be read when the element loads, the element still offers card and sends the buyer to Mollie’s hosted card page to enter it.

What happensWhat you see
A card clears without a challengeonSuccess with { oneShotPaymentId, paymentStatus }, then one_shot_payment.succeeded.
3D Secure, a bank, PayPal or a bank transferThe element sends the top window there, and the buyer comes back to your success_url. The webhook settles it.
The payment is declinedonError with code: "payment_declined", and one_shot_payment.failed. A decline is final for this payment: the element offers no retry. Create a new one-off payment and mount the element with its secret.
The payment expired unpaid or already failed before the buyer got thereonError with code: "payment_not_open". Nothing was charged.
The payment was paid and then refundedonError with code: "payment_refunded". Not a success, and not a payment that never happened.
The client_secret stopped working (30 minutes after the create; a confirm does not extend it)onError with code: "client_secret_expired". The element can no longer show the payment, and its status is unknown there: a card may have cleared, or a bank transfer may still be on its way. Retrieve it with GET /v1/checkout/one_shot/{id} or wait for its webhook before creating another, or you risk charging the buyer twice.
The API refused the client_secret for another reasononError with code: "client_secret_invalid". An integration fault: pass the whole value from the create response, unmodified.

Pressing pay twice charges once. A one-off payment has at most one charge at the provider. Once it exists, every further confirm, from a double click, a retried request or a buyer who pressed Back, answers with that charge’s state and creates nothing.

Fulfil on the webhook here too. onSuccess fires only for a payment that finished without leaving the page, and a buyer who went to their bank never fires it at all. Treat one_shot_payment.succeeded as the record of the money, exactly as on the hosted flow.

Settlement is a webhook, not a redirect

The payment starts open and is not settled when the call returns. It becomes paid only once Mollie confirms, which reaches you as one_shot_payment.succeeded. A failure, an expiry or a cancellation at Mollie is one_shot_payment.failed; read status on the payload to tell them apart. An unpaid open or pending payment past its expires_at is moved to expired even if Mollie’s own notification never arrives.

Do not fulfil on success_url. The redirect means the browser came back, which is not the same as the money arriving, and a customer who closes the tab never redirects at all. Fulfil on one_shot_payment.succeeded.

statusMeaning
open / pendingCreated, and waiting on the payer or the rail. SEPA sits here for days.
authorizedAuthorised and awaiting capture, which Mollie does automatically. Only reachable on the capture-supporting methods, such as PayPal.
paidSettled. paid_at is stamped and the invoice is written.
failed / expired / canceledTerminal, nothing was collected. failure_reason says why where the provider gave one.
refundedFully refunded after settling.

GET /v1/checkout/one_shot/{id} reads one back at any point, and GET /v1/checkout/one_shot lists them newest first, filtered by customer_id or status and paged with limit and starting_after like every other list. They do not appear in GET /v1/payments, which lists subscription charges only.

Tax, and the invoice

A one-off sale is taxed by exactly the same rules as a subscription charge: the rate comes from the customer’s country_code, and a validated non-domestic EU business is reverse-charged. See Charge and record EU VAT.

The one thing specific to this path is tax_behavior on the request, because there is no price to carry the intent. Send inclusive and amount_cents is what the customer pays with VAT backed out of it; send exclusive and VAT is added on top, so the amount_cents you read back is larger than the one you sent. Leave it out and the tax rate for the customer’s country decides.

Every settled one-off sale gets an invoice, in the same numbered series as your subscription charges, carrying one_shot_payment_id where a subscription invoice carries payment_id. That includes domestic sales, not only the reverse-charged ones: a business buyer needs the document to reclaim the VAT they paid you. The invoice list has no one-off filter, so list the customer’s invoices with GET /v1/invoices?customer_id=… and match on one_shot_payment_id.

One-off buyers get their invoice by email, with a download link. The link downloads the PDF directly and works for 90 days. There is no portal to open: the hosted portal is scoped to a subscription, so the email carries no “manage” link. To offer the document anywhere else, serve it yourself from GET /v1/invoices/{id}/pdf.

Refunding one

Same endpoint as any other refund, with one_shot_payment_id as the target:

shell
curl https://api.billkit.eu/v1/refunds \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-order-4711" \
-d '{"one_shot_payment_id": "osp_Th5Rk2wN8qX4mVpLcy", "amount_cents": 500}'

Partial refunds are allowed up to the charged amount, inside the payment’s refund window (30 days unless you set refund_window_days). The payment stays paid with a positive amount_refundable_cents until the refunds cover the whole charge and have settled, at which point it flips to refunded and one_shot_payment.refunded fires. Your create call does not flip it, so a refund that fails leaves the payment paid and refundable.

A settled refund issues a credit note against the sale’s invoice. Full behaviour is in Refunds.

Disputes

A one-off payment can be charged back like any other. The dispute record points at either a payment or a one_shot_payment, never both. See Disputes.

From the CLI

shell
billkit one-shots create \
--customer cus_Ja7Ye2vMq9xN4TdWpr --amount 1999 --payment-method ideal \
--success-url https://example.com/thanks

Add --ui-mode embedded and drop --payment-method to create one for the payment element instead; the command prints the object with its client_secret.

shell
billkit one-shots create \
--customer cus_Ja7Ye2vMq9xN4TdWpr --amount 1999 --ui-mode embedded \
--success-url https://example.com/thanks

billkit one-shots retrieve osp_... reads it back, billkit one-shots list --customer cus_... lists them, and billkit refunds create --one-shot osp_... refunds it. See the CLI guide.