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 toUse
Check that you can take paymentsGET /v1/org?include=paymentsReady
Start a payment and get the page to send the payer toPOST /v1/checkouts
Find out whether it was paidGET /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"
}
FieldRequiredWhat it does
keyYesYour name for this payment. The same key never opens a second payment. See Idempotency
contactYesWho's paying. Saved the same way as PUT /v1/contacts. Their first email address is filled in on the Stripe page
itemsYes1 to 50 lines, each with a name up to 200 characters, an amount in cents for one, and a quantity from 1 to 999
successUrlYesWhere Stripe sends the payer after paying. ?checkout=<checkoutId> is added to it
cancelUrlYesWhere Stripe's back link goes
metadataNoUp 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:

app/membership/paid/page.jsx
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"
}
statusMeaning
openNot paid yet. The payer can still pay, including after a declined card
paidThe money arrived. paidAt says when
expired24 hours passed without a payment. Start a new one with a new key
refundedPaid, 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

ErrorWhenWhat to do
409 PaymentsNotReadyYour Stripe account can't take payments yetFinish Stripe setup. Hide the pay button meanwhile
409 ConflictThis key's payment is already paid, refunded or expiredUse a new key for a new payment
429 TooManyRequestsOver 300 checkouts this hour, counted together with product checkoutsWait retryAfterSeconds. See Rate limits
502 CheckoutUnavailableStripe didn't open the page. Nothing was chargedRetry with the same key
404 NotFoundThe checkoutId isn't one of your organization's paymentsCheck 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.

On this page