A payment is money taken for an appointment. This resource is read-only, and unlike services it is read-only for a reason that is about risk rather than about ownership: no API key can move money, at any plan, with any scope, and no scope will be added that changes it.
| Method | Endpoint | Scope | Plan |
|---|---|---|---|
| GET | /api/v1/paymentsList payments Query: | payments:read | Groei |
| GET | /api/v1/payments/{id}Retrieve a payment | payments:read | Groei |
What you get back
{
"id": "10b7ee1f-eecd-4527-a105-3d7c33fdbfd7",
"appointmentId": "8db4ab12-82e8-4205-8aea-cfd81dee55d5",
"kind": "settlement",
"amountCents": 1750,
"currency": "EUR",
"status": "succeeded",
"source": "manual",
"refundedAmountCents": 0,
"createdAt": "2026-08-11T11:04:00.000Z",
"updatedAt": "2026-08-11T11:04:00.000Z"
}
There is no customer on a payment, and no card detail of any kind: no last four digits, no brand, no
expiry, no cardholder name. Card data never reaches our servers, which is what keeps the business out
of PCI scope, so there is nothing to expose here even if it were wise to. Resolve the customer
through appointmentId, when there is one.
appointmentId is the online link, and is null for money taken in person
A payment taken online is raised for a booking, so it names one. A payment taken in person belongs to
a receipt: the business rings up a sale at the counter, and that sale may or may not have started as
an appointment. Those rows answer appointmentId: null.
This is a change in the answer rather than in the shape, and it is worth saying plainly if you are already reading this resource: counter payments used to report the appointment their receipt was opened for, and no longer do. The field was a copy of the receipt's own link, and a receipt can be settled more than once - part cash, the rest on a card - so the copy quietly put several payments under one appointment with nothing on this shape to tell them apart. The link now lives in one place.
Two consequences to design around:
?appointmentId=returns the money taken online for that booking. It is not "everything paid for this appointment", and never really was for a split settlement.- Reconciling in-person turnover is a job for the receipt rather than for this endpoint. Receipts are
not on this API yet; until they are,
sourceand thefrom/towindow are what a day's takings are summed with.
source tells you who took the money
source | Means |
|---|---|
stripe | Paid online through Stripe |
mollie | Paid online through Mollie |
manual | The business recorded a payment it took some other way |
manual is the row an integrator most often mishandles, and it is the majority of rows for a lot of
salons: cash at the counter, a card machine that is not ours, a bank transfer. It is a real payment
that we did not process, so there is no provider reference to reconcile it against and no webhook
from a payment provider behind it.
A manual payment with status: "succeeded" is as settled as a Stripe one. If you are reconciling
takings, count it. If you are matching against a Stripe report, exclude it, or you will spend an
afternoon looking for transactions that were never in Stripe.
Statuses
status | Means |
|---|---|
pending | Started and not settled. Online only, and it may never settle |
succeeded | Settled. The money is there |
failed | It did not go through |
refund_pending | A refund has been issued and the provider has not confirmed it |
partially_refunded | Some of it came back |
refunded | All of it came back |
Six values, and the list is documented as extensible: a seventh is an additive change and ships without notice, so route anything you do not recognise to a default branch rather than throwing. See the compatibility rules.
A pending row is not a promise. A customer who opened a Mollie checkout and closed the tab leaves
one behind forever, so a pending payment older than a day or so is almost certainly abandoned rather
than in flight. Nothing sweeps them, because the row is the record that somebody tried.
A refund is a row of its own, and its amount is negative
This changed on 14 August 2026, and it changes a number you may already be summing. A refund used
to be an edit to the payment it reversed: the status moved to partially_refunded or refunded,
refundedAmountCents went up, and no new row appeared. It is now a row of its own, with
kind: "refund" and a negative amountCents.
{
"id": "3fbb0a41-6c1b-4a2c-9a2d-4b21c5f0e9aa",
"appointmentId": "8db4ab12-82e8-4205-8aea-cfd81dee55d5",
"kind": "refund",
"amountCents": -500,
"currency": "EUR",
"status": "succeeded",
"source": "stripe",
"refundedAmountCents": 0,
"createdAt": "2026-08-13T09:20:00.000Z",
"updatedAt": "2026-08-13T09:20:00.000Z"
}
The consequence to design around: GET /api/v1/payments returns negative rows where it never did
before, so summing amountCents over a window now gives you the net rather than the gross. That
is the correct number for a reconciliation and it is a different number from the one you got last
week. If you want the gross, drop the kind: "refund" rows before summing. There is no kind query
parameter, so that is a filter you apply to the page you were given rather than one you ask for.
kind is how you tell the two apart. It carries settlement and refund today, and tip and
gift_card_redemption from a later release, so route anything you do not recognise to a default
branch rather than throwing.
Everything on the original row is unchanged and still says what it always said, which is why this is an additive change rather than a breaking one:
refundedAmountCents: 0: nothing was returned- less than
amountCents, with statuspartially_refunded: the common case when a business keeps a deposit against a late cancellation - equal to
amountCents, with statusrefunded: fully refunded
So there are now two ways to compute what a business kept, and you must not combine them. Either
sum amountCents across every row including the negative ones, or sum amountCents minus
refundedAmountCents across the settlement rows only. Doing both subtracts each refund twice.
refund_pending is the state worth thinking about once rather than never: the business has issued the
refund and the provider has not confirmed. The money is leaving and has not left. No reversing row
exists yet at that point, because the reversal is written when the provider confirms. Reconciliation
that treats it as settled is early by a day; reconciliation that treats it as succeeded revenue is
wrong.
Filtering
from and to filter on creation time, which is when the payment was taken. That is the
opposite of the appointments list, where they filter on start time, and
the difference is deliberate rather than an inconsistency: an appointment is an event in the future
and a payment is an event that already happened. A monthly reconciliation wants payments taken in
August, not payments attached to appointments happening in August.
appointmentId and status are exact matches. There is no updatedSince here, so a payment that
settles later is found by re-reading a window rather than by an incremental sync. The
payment.succeeded webhook is the better answer to that problem: see
webhooks.
What is not here
Charging. There is no endpoint that takes money. A booking that needs prepayment returns a
checkout URL or a client secret from POST /api/v1/appointments and the customer settles it
themselves; see the appointments page.
Refunding. Not on this API, deliberately, and it is on the deny list at permissions. A refund is irreversible and moves real money out of a business's account, and a leaked key that could issue them is a leaked key that can empty a salon's balance. It stays behind a logged-in owner or admin in the dashboard.
Payouts and provider balances. Between the business and its payment provider, and nothing to do with this surface.
A business with no payment provider connected gets 403 PAYMENTS_UNAVAILABLE rather than an empty
list, so you can tell "this salon does not take online payments" apart from "this salon has not sold
anything yet".