Accept a checkout payment
Create a checkout link, send the buyer to it and fulfil the order when the paid webhook arrives. Checkout covers link states and the Stripe mapping.
export AVVIO_API_KEY=avvio_live_…export AVVIO_ORG_ID=cmsx…export AVVIO_BASE_URL=https://api.avvio.xyz/business/api/v1Routes live under /checkout/organizations/{orgId}, shortened below to
/checkout/…. Reads work with a read-only key; writes need write, or get
403 INSUFFICIENT_SCOPE.
Summary
Section titled “Summary”- Create a product:
POST /checkout/…/products. - Create and publish a link:
POST /checkout/…/links. - Send the buyer to
shareUrl, and back to yoursuccessUrl. - Fulfil on the
checkout_payment.paidwebhook. - Rehearse every outcome in the sandbox.
-
Create a product
Section titled “Create a product”A product is a name and one fixed price in one currency, reused for as many links as you need.
POST /checkout/organizations/{orgId}/products IDEMPOTENCY_KEY=$(uuidgen) # new key per call; reuse it only to retry this exact requestcurl -s -X POST "$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/products" \-H "x-api-key: $AVVIO_API_KEY" \-H "Idempotency-Key: $IDEMPOTENCY_KEY" \-H "content-type: application/json" \-d '{"name": "Consulting (60 min)","currency": "USD","unitAmount": "150.00"}'POST /checkout/organizations/{orgId}/products const product = await avvio.createProduct({name: 'Consulting (60 min)',currency: 'USD',unitAmount: '150.00',});// → { id, name, currency, unitAmount, archivedAt: null, linkCount: 0, … }POST /checkout/organizations/{orgId}/products import osimport uuidimport requestsidempotency_key = str(uuid.uuid4()) # new key per call; reuse it only to retry this exact requestres = requests.post(f"{os.environ['AVVIO_BASE_URL']}/checkout/organizations/{os.environ['AVVIO_ORG_ID']}/products",headers={"x-api-key": os.environ["AVVIO_API_KEY"],"Idempotency-Key": idempotency_key,},json={"name": "Consulting (60 min)","currency": "USD","unitAmount": "150.00"},)print(res.status_code, res.json())POST /checkout/organizations/{orgId}/products avvio-payments product create --name "Consulting (60 min)" --currency USD --amount 150.00Response {"id": "2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d","name": "Consulting (60 min)","description": null,"currency": "USD","unitAmount": "150.00","archivedAt": null,"linkCount": 0,"createdAt": "2026-09-12T09:55:00.000Z"}Keep
id. A link copies the product’s name and price when it is made, so repricing the product leaves existing links unchanged. The description and image are read live.For a one-off amount, skip the product and send
currencyanditemson the link. Sending bothproductIdanditemsis a400. -
Create and publish a link
Section titled “Create and publish a link”Create one link per order, with
publish: true.POST /checkout/organizations/{orgId}/links IDEMPOTENCY_KEY=$(uuidgen) # new key per call; reuse it only to retry this exact requestcurl -s -X POST "$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/links" \-H "x-api-key: $AVVIO_API_KEY" \-H "Idempotency-Key: $IDEMPOTENCY_KEY" \-H "content-type: application/json" \-d '{"productId": "2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d","successUrl": "https://example.com/thanks","cancelUrl": "https://example.com/pricing","clientReferenceId": "order_1042","metadata": {"orderId": "1042"},"publish": true}'POST /checkout/organizations/{orgId}/links const link = await avvio.createCheckoutLink({productId: product.id, // 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5dsuccessUrl: 'https://example.com/thanks',cancelUrl: 'https://example.com/pricing',clientReferenceId: 'order_1042', // your order id: the join keymetadata: { orderId: '1042' },publish: true, // live now; the response carries shareUrl});res.redirect(link.shareUrl);POST /checkout/organizations/{orgId}/links import osimport uuidimport requestsidempotency_key = str(uuid.uuid4()) # new key per call; reuse it only to retry this exact requestres = requests.post(f"{os.environ['AVVIO_BASE_URL']}/checkout/organizations/{os.environ['AVVIO_ORG_ID']}/links",headers={"x-api-key": os.environ["AVVIO_API_KEY"],"Idempotency-Key": idempotency_key,},json={"productId": "2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d","successUrl": "https://example.com/thanks","cancelUrl": "https://example.com/pricing","clientReferenceId": "order_1042","metadata": {"orderId": "1042"},"publish": True},)print(res.status_code, res.json())POST /checkout/organizations/{orgId}/links avvio-payments checkout create --product 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d \--success-url https://example.com/thanks --cancel-url https://example.com/pricing \--ref order_1042 --meta orderId=1042Response {"id": "4f1c2a9e-8f7d-4c3b-9a2e-6b5d4c3f2a10","slug": "7e2a9c4b1d0f","status": "sent","shareUrl": "https://business.avvio.xyz/i/7e2a9c4b1d0f","fromName": "Northstar Consulting","currency": "USD","total": "150.00","clientReferenceId": "order_1042","received": null,"createdAt": "2026-09-12T09:58:00.000Z"}Store
slugbesideid: the success page receives only the slug.Field What it does successUrlhttps. Where the buyer lands after a card payment. Bank and crypto payments never redirect cancelUrlhttps. A “Back to {your name}” link on the payer page clientReferenceIdYour order id and join key. Letters, digits, -and_, up to 200. Echoed on every payment eventmetadataUp to 50 string keys (key ≤ 40 characters, value ≤ 500). Echoed on events, never shown to the buyer expiresAtISO-8601 with a timezone, at most a year out. Omitted, the link runs until paused methodsOmitted means card. bankandcryptoneed an account or address.[]makes a link nothing can payfromNameDefaults to your organization’s name. Shown on the page. On the card statement, a statementDescriptorwins, then your organization’s registered descriptor, thenfromNameWithout
publish: trueyou get adraft(link states).When publishing is refused
Section titled “When publishing is refused”A card link cannot publish while card onboarding is unfinished. The call still answers
201and keeps the draft, with apublishError:{"id": "4f1c2a9e-…","status": "draft", // not "sent""shareUrl": "https://…/i/7e2a9c4b1d0f", // answers 404 while a draft"publishError": {"status": 400,"type": "BAD_REQUEST","message": "This business is not set up to accept cards yet. Finish card onboarding, then publish the link."}}Branch on
status !== "sent", then readpublishError.type:BAD_REQUEST,CONFLICT,NOT_FOUND,FORBIDDEN,INTERNAL, or a specific code such asLINK_EXPIRED. Fix the cause and callPOST /links/{linkId}/publish, orDELETEthe draft. The answer is a201because only successes are stored against an idempotency key, and an error would let a retry create a second draft.A card link in a currency the processor cannot price is different: the create (or update) itself fails with
400, and no draft is stored.publishandDELETEof a draft have no SDK method yet; call them over HTTP. -
Send the buyer, and take them back
Section titled “Send the buyer, and take them back”Redirect the buyer to
shareUrl. After a card payment they return to yoursuccessUrl:https://example.com/thanks?avvio_link=7e2a9c4b1d0f&client_reference_id=order_1042avvio_linkis the link’s slug;client_reference_idappears when known.GET /checkout/organizations/{orgId}/links/{linkId}/payments curl -s "$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/links/$linkId/payments" \-H "x-api-key: $AVVIO_API_KEY"GET /checkout/organizations/{orgId}/links/{linkId}/payments // One link per order: the link is the join, not the payment row's clientReferenceIdconst { items } = await avvio.listCheckoutPayments(linkId);const paid = items.find((p) => p.status === 'paid');// amountBase "15000" at decimals 2 is 150.00GET /checkout/organizations/{orgId}/links/{linkId}/payments import osimport requestslinkId = "<linkId>"res = requests.get(f"{os.environ['AVVIO_BASE_URL']}/checkout/organizations/{os.environ['AVVIO_ORG_ID']}/links/{linkId}/payments",headers={"x-api-key": os.environ["AVVIO_API_KEY"],},)print(res.status_code, res.json())GET /checkout/organizations/{orgId}/links/{linkId}/payments avvio-payments checkout payments <linkId>This read returns base units with
decimals("15000"atdecimals: 2is 150.00); the webhook uses decimal strings. A payment row’sclientReferenceIdis the buyer visit’s (?client_reference_id=), on the card rail only, so it is usuallynullwhen you setclientReferenceIdon the link. Create one link per order and look for apaidrow on that link, as above. -
Fulfil on
Section titled “Fulfil on checkout_payment.paid”checkout_payment.paidEvent Meaning checkout_payment.paid The acceptor has the money. Fulfil on this, and only this checkout_payment.failed A card attempt failed. Nothing to fulfil checkout_payment.partially_ refunded Part went back ( refundsays which). Stillpaidcheckout_payment.refunded Refunded in full. Reverse the fulfilment checkout_payment.reversed A chargeback. Reverse the fulfilment Name these types when you register the endpoint. An empty
eventslist receives every payout type but none of these, so a payout-only receiver never4xxs its way to auto-disable.datais aWebhookCheckoutPayment(every event):{"id": "cmf9x7q4r0011q8b7w2e4t6yu", // equals svix-id. Dedupe on this"type": "checkout_payment.paid","livemode": true,"data": {"paymentId": "cmf9x1b2c0003q8b7h6j8k0lm","linkId": "4f1c2a9e-8f7d-4c3b-9a2e-6b5d4c3f2a10","clientReferenceId": "order_1042", // your join key"metadata": { "orderId": "1042" },"status": "paid","failureCode": null,"kind": "card", // card | bank | crypto | cashapp. Never the processor"amount": "150.00", // decimal string in `currency`"currency": "USD","fee": "4.35", // "0.00" when none was stated"net": "145.65", // null when the processor did not say"linkExpiredAt": null // the link's expiresAt when this landed AFTER it// …linkSlug, productId, refunded, paidAt, createdAt}}const { verifyWebhook } = require('@avvio/payments');app.post('/hooks/avvio', express.raw({ type: '*/*' }), async (req, res) => {let event;try {event = verifyWebhook({ body: req.body, headers: req.headers, secret: process.env.AVVIO_WEBHOOK_SECRET });} catch {return res.sendStatus(400); // 1. verify over the raw bytes}res.sendStatus(200); // ack first, work afterif (event.type !== 'checkout_payment.paid') return handleOther(event);if (await seen(event.id)) return; // 2. dedupe on the event idconst { clientReferenceId, amount, currency } = event.data;const order = await orders.find(clientReferenceId); // 3. join on your referenceif (!order) return alert('paid, no order', event);if (order.total !== amount || order.currency !== currency) {return alert('amount mismatch', event); // 4. compare amount + currency}await fulfil(order, event.data.paymentId);});The event’s
clientReferenceIdis the buyer visit’s?client_reference_id=when the card page carried one, otherwise the link’s.linkExpiredAtis set when the payment landed after the link’sexpiresAt, such as a slow wire. The payment ispaideither way, so decide whether to honour or refund it.Recovering missed events
Section titled “Recovering missed events”A delivery you never received looks the same as one that never fired, so the event feed is the record. Read
GET /payments/organizations/{orgId}/events?type=checkout_payment.paid&since=and passnextSinceback assince. A row appears about two seconds after the write (Reconcile your ledger). -
Test it in the sandbox
Section titled “Test it in the sandbox”With an
avvio_test_…key, pay the link yourself:IDEMPOTENCY_KEY=$(uuidgen) # new key per call; reuse it only to retry this exact requestcurl -s -X POST \"$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/links/$LINK_ID/payments/simulate" \-H "x-api-key: $AVVIO_API_KEY" \-H "Idempotency-Key: $IDEMPOTENCY_KEY" \-H 'Content-Type: application/json' -d '{"clientReferenceId":"order_1042"}'The cents of the total choose the outcome:
.01declines,.02pays after 20 seconds,.03refunds,.04charges back,.05declines for insufficient funds,.06pays and is then half refunded, and any other ending pays at once. Every ending and its events are in Environments & sandbox. To force an outcome, callPOST /checkout/…/payments/{paymentId}/simulatewith{"action":"chargeback"}. Events arrive signed, withlivemode: false.Before launch, run one small live payment. A real card exercises the hosted page, 3-D Secure and the wallet buttons, which the simulator cannot. The processing fee on a refunded live payment is not returned.
Check the outcome
Section titled “Check the outcome”The order is paid once your handler has processed checkout_payment.paid for
its clientReferenceId, or GET /links/{linkId}/payments shows a paid row.
To reconcile on a schedule, page that read for your live links and compare
status and refundedBase (null when nothing was refunded). Next, handle refunds.
Was this page helpful?