Taking payments from your app
An app can start payments on a site that uses SitePack Payments: a club administration collecting membership fees, a booking tool taking a deposit, a POS app sending a payment link. The payer pays the merchant behind the site, SitePack handles the checkout, the fees and the payouts, and your app hears back through webhooks.
These payments have no webshop order. Your app decides what is being paid for: a description plus lines with a description, quantity and price.
Who can take payments
A payment always goes to the merchant of the site your app is installed on. That site needs SitePack Payments, and the merchant has to be live with it:
- A website or webshop that has SitePack Payments switched on.
- A payments profile. Merchants without a website (a sports club, an association) sign up for SitePack Payments on its own at admin.sitepack.eu/payments. Each profile is one organisation or activity they take payments for, with its own payment methods, contact details and payouts. To your app a profile is just a site.
The merchant goes through the payments onboarding (company details, identity and bank account checks) before the first payment. Until that is done, starting a payment answers 409 (see Errors).
Your app does not handle card details, does not need its own payment provider and does not invoice the merchant: SitePack invoices the transaction fees to the merchant directly.
1. Ask for the payments scopes
Declare the scopes in your app.json, and subscribe to the payment webhooks:
{
"auth": {
"scopes": ["payments:read", "payments:write"]
},
"webhooks": [
{ "topic": "payment.paid", "address": "https://app.example.com/webhooks/sitepack" },
{ "topic": "payment.failed", "address": "https://app.example.com/webhooks/sitepack" },
{ "topic": "payment.canceled", "address": "https://app.example.com/webhooks/sitepack" },
{ "topic": "payment.refunded", "address": "https://app.example.com/webhooks/sitepack" }
]
}
| Scope | What it allows |
|---|---|
payments:read | Read the payments your app can see on the site, and which payment methods it can take. |
payments:write | Start payments and refund them. Includes payments:read. |
Starting a payment never charges the merchant anything besides the usual transaction fee: the payer pays.
2. Install on the right profile
The merchant installs your app through the normal OAuth flow. An access token always belongs to one site.
A merchant with more than one site or profile picks the one to connect on the consent screen when your app asks for a payments:* scope. Payments profiles are listed first and one is preselected, also when the install link was opened from a website. To connect a second profile, the merchant installs your app again from that profile: your app then holds one token per profile, and every webhook names the site it is about in site.
3. Check that payments are ready
Before you offer a payment, ask the site where it stands and which methods it takes:
GET /api/public/v1/payment-methods
Authorization: Bearer <access token>
{
"payments": { "enabled": true, "live": true },
"payment_methods": [
{ "method": "ideal", "name": "iDeal | Wero" },
{ "method": "creditcard", "name": "Creditcard" }
]
}
enabledisfalsewhen the site has no SitePack Payments at all. Ask the merchant to set it up; your app cannot do that for them.liveisfalsewhile the merchant is still in the onboarding.payment_methodsis then empty.payment_methodsare the methods the merchant switched on that are active right now. Only these are accepted when you start a payment.
Your app can hold the payments scopes on a site that is not ready. Show the merchant why payments are unavailable rather than failing on the first payment.
4. Start a payment
POST /api/public/v1/payments
Authorization: Bearer <access token>
Content-Type: application/json
Idempotency-Key: membership-2026-member-1042
{
"description": "Membership 2026 - Hockey club De Waal",
"method": "ideal",
"return_url": "https://app.example.com/payments/return?member=1042",
"lines": [
{ "description": "Senior membership", "quantity": 1, "price_cents": 18500 },
{ "description": "Club shirt", "quantity": 2, "price_cents": 2500, "vat_percentage": 21 }
],
"external_reference": "INV-2026-0042",
"customer": { "email": "member@example.com", "first_name": "Sam", "last_name": "Jansen" },
"metadata": { "member_id": "1042" }
}
| Field | Required | Notes |
|---|---|---|
description | Yes | What the payer pays for, shown at checkout and on their bank statement. At most 255 characters. |
lines | Yes | 1 to 100 lines. Each has a description (required, at most 255 characters), a quantity (1 to 10000, defaults to 1), a price_cents per unit (0 or more) and an optional vat_percentage (0 to 100, defaults to 21). |
method | Yes | One of the methods GET /payment-methods returned. |
return_url | Yes | An https url the payer comes back to after paying, failing or canceling. |
external_reference | No | Your own reference, at most 191 characters. |
customer | No | email, first_name and last_name of the payer. |
metadata | No | Up to 20 keys of your own (keys at most 40 characters, plain values at most 500 characters), returned as sent. |
The amount is the total of the lines (here 185.00 + 2 x 25.00 = 235.00 euro) and has to be at least 1 cent. All amounts are in euro cents.
The answer is 201 Created with the payment:
{
"payment": {
"uuid": "019f4b2e-7c1a-7e3d-9b8f-5a6c2d1e0f93",
"status": "open",
"description": "Membership 2026 - Hockey club De Waal",
"external_reference": "INV-2026-0042",
"method": "ideal",
"amount_cents": 23500,
"refunded_cents": 0,
"currency": "EUR",
"lines": [
{ "description": "Senior membership", "quantity": 1, "price_cents": 18500, "vat_percentage": null },
{ "description": "Club shirt", "quantity": 2, "price_cents": 2500, "vat_percentage": 21 }
],
"metadata": { "member_id": "1042" },
"checkout_url": "https://checkout.example/...",
"return_url": "https://app.example.com/payments/return?member=1042",
"created": "2026-10-28T14:05:12+00:00"
}
}
Send the payer to checkout_url. It is only set while the payment can still be paid.
Retries and the Idempotency-Key
Send an Idempotency-Key header (any unique string up to 100 characters) with every new payment. When a request times out and you send it again with the same key, you get the payment the first call created instead of a second one. Use a key that belongs to what is being paid for, such as an invoice number.
5. Hear the outcome
When the payer comes back on your return_url, do not trust the visit: read the payment back with GET /api/public/v1/payments/{uuid}, or wait for the webhook.
| Topic | When it fires |
|---|---|
payment.paid | The payment is completed. |
payment.failed | The payment failed. |
payment.canceled | The payer canceled, or the payment expired unpaid. |
payment.refunded | (Part of) the payment was refunded, also for every further partial refund, or the payer reversed it at their bank (chargeback). |
Each topic fires once per change. The payload is thin, like every webhook: site is the site (profile) uuid and arguments.uuid the payment uuid. Read the payment with GET /api/public/v1/payments/{uuid} (payments:read).
{
"uuid": "019f4b31-0a2c-7f11-8d4e-3b9a6c5d2e10",
"test_mode": false,
"topic": "payment.paid",
"site": "019eca47-21a4-7935-8636-641e7370897b",
"created_at": "2026-10-28T14:06:40Z",
"arguments": {
"uuid": "019f4b2e-7c1a-7e3d-9b8f-5a6c2d1e0f93"
}
}
A payment's status is one of open, pending, paid, failed, canceled or refunded (refunded in full; a partial refund keeps paid and raises refunded_cents).
6. Refund
POST /api/public/v1/payments/{uuid}/refunds
Content-Type: application/json
{ "amount_cents": 2500, "description": "Club shirt returned" }
Leave out amount_cents to refund what is left. Only a paid payment can be refunded, and the refund comes out of the merchant's payments balance. The answer is the payment after the refund, and payment.refunded fires.
List payments
GET /api/public/v1/payments lists the payments started through the API on this site, newest first, with the usual page and limit pagination. Webshop orders and their payments are not in this list; read those with GET /api/public/v1/orders/{uuid}/payments (orders:read).
Errors
Errors use the standard error format. The payments specific ones:
| Status | code | Meaning |
|---|---|---|
409 | payments_not_enabled | The site has no SitePack Payments. The merchant has to set it up. |
409 | payments_not_live | The merchant is still finishing the payments onboarding. |
409 | payment_not_started | The payment provider refused the payment. Check that the method is still active and try again. |
409 | payment_not_refundable | Only a paid payment can be refunded. |
409 | refund_failed | The refund could not be processed, usually because the payments balance is too low. |
422 | validation_failed | A field is missing or invalid; field in the error names it (for example lines.1.price_cents, or method for a method the profile does not take). |
404 | not_found | No payment with that uuid on this site. |
Testing
Install your app on a test site or profile of your own and use it through the same API. A payments profile whose onboarding is not finished answers payments_not_live, which is the moment to check that your app explains this to the merchant. Use the Test button next to a webhook topic in the Partner Portal to see a payload arrive.
Next steps
- API Reference: Payments: every field of the five endpoints.
- Authentication (OAuth): getting a token per profile.
- Webhooks: signing and retries.
- App Marketplace Policy: what is and is not allowed around payments.