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.
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.
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"
}'| Field | Notes |
|---|---|
| customer_idrequired | An existing cus_…. |
| amount_centsrequired | Positive integer in the smallest unit. |
| currencyrequired | Three-letter code, and one your account accepts. Anything else is a 400. |
| payment_methodrequired when hosted | One 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_mode | hosted (the default) or embedded. See Take the payment on your own page. |
| success_urlrequired | Where the customer lands after paying. cancel_url is optional. |
| description | Up to 255 characters. Shown on the Mollie page, on the payer’s bank statement, and as the invoice line. Defaults to Payment. |
| tax_behavior | inclusive or exclusive. Whether the amount you sent includes VAT or has it added on top. |
| refund_window_days | How long this charge stays refundable. Default 30, maximum 365, 0 disables refunds for it. |
| metadata | Your 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.
{
"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.
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"
}'{
"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:
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 sameIdempotency-Keyreturns 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 asexpiredand you getone_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 pastexpires_at, so expect the event one to two hours later, not atexpires_atitself. Create a new one-off payment if the buyer comes back. - Confirming does not extend it. An element mounted or reloaded after
expires_atreportsclient_secret_expiredwhatever 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:
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());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 happens | What you see |
|---|---|
| A card clears without a challenge | onSuccess with { oneShotPaymentId, paymentStatus }, then one_shot_payment.succeeded. |
| 3D Secure, a bank, PayPal or a bank transfer | The element sends the top window there, and the buyer comes back to your success_url. The webhook settles it. |
| The payment is declined | onError 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 there | onError with code: "payment_not_open". Nothing was charged. |
| The payment was paid and then refunded | onError 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 reason | onError 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.
| status | Meaning |
|---|---|
| open / pending | Created, and waiting on the payer or the rail. SEPA sits here for days. |
| authorized | Authorised and awaiting capture, which Mollie does automatically. Only reachable on the capture-supporting methods, such as PayPal. |
| paid | Settled. paid_at is stamped and the invoice is written. |
| failed / expired / canceled | Terminal, nothing was collected. failure_reason says why where the provider gave one. |
| refunded | Fully 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:
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
billkit one-shots create \
--customer cus_Ja7Ye2vMq9xN4TdWpr --amount 1999 --payment-method ideal \
--success-url https://example.com/thanksAdd --ui-mode embedded and drop --payment-method to create one for the payment element instead; the
command prints the object with its client_secret.
billkit one-shots create \
--customer cus_Ja7Ye2vMq9xN4TdWpr --amount 1999 --ui-mode embedded \
--success-url https://example.com/thanksbillkit 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.
BillKit