Take payments
Charge a one-time payment from your own website for dues, donations, deposits or anything else that isn't a product.
Use this when your site needs a "Pay" button for something that isn't in your shop: monthly studio dues, a deposit on a custom commission, a donation to a shared kiln fund. You decide the line items and amounts. Stripe's hosted payment page takes the card, and the money goes to your connected Stripe account.
Every payment is saved on the payer's contact and listed in the dashboard under Commerce → Website payments, where you can also refund it.
| You want to | Use |
|---|---|
| Check that you can take payments | GET /v1/org?include=paymentsReady |
| Start a payment and get the page to send the payer to | POST /v1/checkouts |
| Find out whether it was paid | GET /v1/checkouts/{checkoutId} |
For products from your Lahuta shop, use Sell products instead. It handles variants, shipping and coupons.
Check that payments are ready
Payments need a connected Stripe account that's finished setup. If it isn't, starting a checkout fails with PaymentsNotReady. Ask first, and hide your pay button until the answer is true:
curl "https://api.lahuta.org/v1/org?include=paymentsReady" \
-H "X-Api-Key: $LAHUTA_API_KEY"{
"name": "Northside Pottery",
"slug": "northside-pottery",
"image": null,
"paymentsReady": true
}paymentsReady is only in the response when you ask for it with include. To connect Stripe, see Get paid.
Start a payment
Send who's paying, what for, and where to send them afterwards. You get back a Stripe page to redirect them to.
curl -X POST https://api.lahuta.org/v1/checkouts \
-H "X-Api-Key: $LAHUTA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"key": "dues:member-117:2026-10",
"contact": {
"kind": "person",
"firstName": "Jordan",
"lastName": "Ellis",
"emails": [{ "label": null, "value": "jordan.ellis@example.com" }]
},
"items": [
{ "name": "Studio membership, October 2026", "amount": 12000, "quantity": 1 }
],
"metadata": { "memberId": "117", "month": "2026-10" },
"successUrl": "https://northsidepottery.com/membership/paid",
"cancelUrl": "https://northsidepottery.com/membership"
}'{
"checkoutId": "0197b340-2b17-7d05-9a6c-3e8f1b2d4c96",
"url": "https://checkout.stripe.com/c/pay/cs_live_a1B2c3D4e5F6g7H8"
}| Field | Required | What it does |
|---|---|---|
key | Yes | Your name for this payment. The same key never opens a second payment. See Idempotency |
contact | Yes | Who's paying. Saved the same way as PUT /v1/contacts. Their first email address is filled in on the Stripe page |
items | Yes | 1 to 50 lines, each with a name up to 200 characters, an amount in cents for one, and a quantity from 1 to 999 |
successUrl | Yes | Where Stripe sends the payer after paying. ?checkout=<checkoutId> is added to it |
cancelUrl | Yes | Where Stripe's back link goes |
metadata | No | Up to 20 of your own key and value pairs, returned when you check the status. Keys are up to 40 letters, digits, _, . or -. Values are up to 500 characters |
Amounts are in US cents. 12000 is $120.00. The total is the sum of amount × quantity over all items.
Check whether it was paid
There are no webhooks. When the payer comes back to your successUrl, read checkout from the query string and ask for the status:
export default async function PaidPage({ searchParams }) {
const { checkout } = await searchParams
const res = await fetch(`https://api.lahuta.org/v1/checkouts/${checkout}`, {
headers: { "X-Api-Key": process.env.LAHUTA_API_KEY },
})
const payment = await res.json()
if (payment.status === "paid")
return <p>Thanks! Your dues for October are paid.</p>
if (payment.status === "open") return <p>Confirming your payment…</p>
return <p>This payment didn't go through. Please try again.</p>
}{
"checkoutId": "0197b340-2b17-7d05-9a6c-3e8f1b2d4c96",
"status": "paid",
"amountCents": 12000,
"contactId": "0197b340-2a90-7c41-8e2d-5f1a9b3c6e07",
"metadata": { "memberId": "117", "month": "2026-10" },
"paidAt": "2026-09-25T18:22:45.000Z"
}status | Meaning |
|---|---|
open | Not paid yet. The payer can still pay, including after a declined card |
paid | The money arrived. paidAt says when |
expired | 24 hours passed without a payment. Start a new one with a new key |
refunded | Paid, then refunded in full or in part from the dashboard |
Stripe can confirm a payment a little after the payer lands on your page, so open there usually means "not yet". Refresh or poll every few seconds for up to a minute before you tell them something went wrong. Don't treat a visit to successUrl as proof of payment on its own. Always check the status.
What your team sees
- The payment in Commerce → Website payments, with the payer, amount and status. Refunds are made from there.
- The payment on the payer's contact in the CRM.
- An email when it's paid, for everyone who has Website payments turned on under Settings → Notifications.
Errors
| Error | When | What to do |
|---|---|---|
409 PaymentsNotReady | Your Stripe account can't take payments yet | Finish Stripe setup. Hide the pay button meanwhile |
409 Conflict | This key's payment is already paid, refunded or expired | Use a new key for a new payment |
429 TooManyRequests | Over 300 checkouts this hour, counted together with product checkouts | Wait retryAfterSeconds. See Rate limits |
502 CheckoutUnavailable | Stripe didn't open the page. Nothing was charged | Retry with the same key |
404 NotFound | The checkoutId isn't one of your organization's payments | Check the id |
Sending the same key again while the payment is still open gives you the same checkout, so a double-clicked pay button can't charge twice.
See Checkouts in the API reference for every field.