/ Payouts v1 Dashboard ↗

Accept Payments

Sell something from your own site and get paid into your Avvio balance. You create a checkout link on your server, send the buyer to its shareUrl, and hear about the payment on a webhook. If you have integrated Stripe Checkout, every field here has a name you already know; the mapping table is at the bottom.

This is money arriving. Everything else on this site is money leaving. The two share one API key, one webhook endpoint and one event feed.

Live keys only, for now. Checkout has no sandbox yet. A test key (avvio_test_…) gets 400 TEST_MODE_UNSUPPORTED on every checkout route, and the message says so in one sentence rather than pretending it is a permissions problem. Use a live key against a verified organization: a live link with no payments costs nothing, and you can pause or delete it. We would rather tell you this here than have you discover it after building.

bash
export AVVIO_API_KEY=avvio_live_…
export AVVIO_ORG_ID=cmsx…
export AVVIO_BASE_URL=https://api.avvio.xyz/business/api/v1

Every route lives under /checkout/organizations/{orgId}. Reads work with a read-only key; creating, publishing and pausing need write, and a read-only key on a write is 403 INSUFFICIENT_SCOPE.


1. Create a product#

A product is a name and one fixed price in one currency. Links are made from it, so you describe the thing once and sell it many times.

createCheckoutProduct
avvio-payments product create --name "Consulting (60 min)" --currency USD --amount 150.00
# Set AVVIO_IDEMPOTENCY_KEY once; reuse it on every retry.
curl -s -X POST "$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/products" \
  -H "x-api-key: $AVVIO_API_KEY" \
  -H "Idempotency-Key: $AVVIO_IDEMPOTENCY_KEY" \
  -H "content-type: application/json" \
  -d '{
        "name": "Consulting (60 min)",
        "currency": "USD",
        "unitAmount": "150.00"
      }'
const product = await avvio.createProduct({
  name: 'Consulting (60 min)',
  currency: 'USD',
  unitAmount: '150.00',
});
// → { id, name, currency, unitAmount, archivedAt: null, linkCount: 0, … }

Keep id. A link made from a product copies its name and price at that moment; repricing the product later changes nothing about links already made, which is the behaviour you want for a URL you have already sent to people. The description and image are read live, so a typo fixed on the product is fixed on every page.

You can skip this step and send currency + items on the link instead, for a one-off amount. Sending both a productId and items is a 400.


One call, with publish: true, returns a live link whose shareUrl is the page to redirect the buyer to.

createCheckoutLink
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=1042
# Set AVVIO_IDEMPOTENCY_KEY once; reuse it on every retry.
curl -s -X POST "$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/links" \
  -H "x-api-key: $AVVIO_API_KEY" \
  -H "Idempotency-Key: $AVVIO_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
      }'
const link = await avvio.createCheckoutLink({
  productId: product.id,                    // 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d
  successUrl: 'https://example.com/thanks',
  cancelUrl: 'https://example.com/pricing',
  clientReferenceId: 'order_1042',          // your order id: the join key
  metadata: { orderId: '1042' },
  publish: true,                             // live now; the response carries shareUrl
});
res.redirect(link.shareUrl);

What the four Stripe-shaped fields do:

Field What it does
successUrl Where the payer page sends the buyer after a card payment. https only. Bank and crypto payments settle later and never redirect
cancelUrl Rendered as a "Back to {your name}" link on the payer page. https only
clientReferenceId Your id for what this link pays for (an order, a booking). Letters, digits, - and _, up to 200. Echoed on every payment event. This is your join key
metadata Up to 50 string keys (key ≤ 40 characters, value ≤ 500). Echoed on every payment event, never shown to the buyer

Also worth knowing:

When publishing is refused#

Publishing a card link mints the hosted page behind it, and that step can be refused: a business whose card onboarding is not finished, a currency the card processor cannot price. When that happens inside a publish: true create, the call still answers 201 with the kept draft and a publishError saying why:

jsonc
{
  "id": "4f1c2a9e-…",
  "slug": "7e2a9c4b1d0f",
  "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 read publishError. Its type uses the same vocabulary as an error envelope (BAD_REQUEST, CONFLICT, NOT_FOUND, FORBIDDEN, INTERNAL, or a more specific code). Fix the cause and POST /links/{linkId}/publish, or DELETE the draft. It comes back as a 201 rather than an error on purpose: a stored idempotent response is only kept for a success, so an error would release your key and an automatic retry would create a second draft. A draft is invisible and unpayable either way.

status Meaning
draft Not publicly readable. Edit or delete it
sent Live. shareUrl serves the page
cancelled Paused. The page answers 410, every rail behind it stops. Permanent in this version: make a new link to sell again

A live link cannot be edited: it is a URL someone may already be looking at, and repricing it under them is a money bug. Pause it and create a new one. A link that has been live cannot be deleted either, because its payments live under it. Pause is the one exit.


3. Fulfil on checkout_payment.paid#

The webhook is how your server learns the buyer paid. Four event types make up the family:

Event Meaning
checkout_payment.paid Money is in your balance. Fulfil on this, and only this
checkout_payment.failed A card attempt failed. Nothing to fulfil
checkout_payment.refunded A full refund was issued from the dashboard. Reverse the fulfilment
checkout_payment.reversed A chargeback. The money left your balance. Reverse the fulfilment

Subscribe explicitly. An endpoint registered with an empty events list receives every payout-side type but not this family. Name the types you want when you register the endpoint (a live endpoint is registered from the dashboard by a human, for the reasons in Webhooks):

jsonc
{ "url": "https://example.com/hooks/avvio", "events": ["checkout_payment.paid", "checkout_payment.refunded", "checkout_payment.reversed"] }

This is deliberate: a receiver written for payouts that answers 4xx to a type it does not know would climb toward auto-disable, and a checkout feature must not switch off a payout webhook.

The body is the same envelope as every other event, with data shaped as WebhookCheckoutPayment:

jsonc
{
  "id": "cmf9x1b2c0003q8b7h6j8k0lm",   // equals svix-id. Dedupe on this
  "sequence": "48213",
  "type": "checkout_payment.paid",
  "createdAt": "2026-09-12T10:00:04.000Z",
  "apiVersion": 1,
  "livemode": true,
  "data": {
    "paymentId": "cmf9x1b2c0003q8b7h6j8k0lm",
    "linkId": "4f1c2a9e-8f7d-4c3b-9a2e-6b5d4c3f2a10",
    "linkSlug": "7e2a9c4b1d0f",
    "productId": "2a7b8c9d-…",
    "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
    "refunded": null,
    "paidAt": "2026-09-12T10:00:04.000Z",
    "createdAt": "2026-09-12T09:59:58.000Z"
  }
}

A handler that does the four things that matter:

js
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 after

  if (event.type !== 'checkout_payment.paid') return handleOther(event);
  if (await seen(event.id)) return;                   // 2. dedupe on the event id
  const { clientReferenceId, amount, currency } = event.data;
  const order = await orders.find(clientReferenceId); // 3. join on YOUR reference
  if (!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);
});

clientReferenceId on the event is the buyer visit's ?client_reference_id= when the card page carried one, else the one you set on the link. Compare amount and currency against your own record of the order before fulfilling; a buyer paid what the link asked, but the order is yours to check.

Recovering. Webhooks are the fast path, not the guarantee. Every event is also a row in the feed, and a delivery you never received is indistinguishable from one that never fired:

bash
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/events?type=checkout_payment.paid&since=$SINCE" \
  -H "x-api-key: $AVVIO_API_KEY"

Carry nextSince back as since. The feed publishes a row about two seconds after the write, so a poll fired the instant a test payment lands can be empty for a beat; the webhook does not wait. The recipe is in Reconciliation; a checkout event is one more type on the same feed.


4. The success page#

After a card payment the payer page sends the buyer to your successUrl with two query parameters appended:

code
https://example.com/thanks?avvio_link=7e2a9c4b1d0f&client_reference_id=order_1042

avvio_link is the link's public slug, the last path segment of shareUrl (the public page carries no id). The create response returns slug beside id, so remember the pair, or list your links and match on slug. client_reference_id is present when we know it (the link's, or the one the buyer's visit carried). Bank and crypto payments never redirect; they settle later and you hear about them on the webhook.

Do not trust the redirect. Anyone can type that URL. The redirect tells the page which order to show; whether it is paid comes from the webhook, or from the authoritative read:

listCheckoutPayments
avvio-payments checkout payments <linkId>
curl -s "$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/links/$linkId/payments" \
  -H "x-api-key: $AVVIO_API_KEY"
const { items } = await avvio.listCheckoutPayments(linkId);
const paid = items.find(
  (p) => p.status === 'paid' && p.clientReferenceId === orderId,
);
// amountBase "15000" at decimals 2 is 150.00

Amounts on this read are base units with decimals beside them ("15000" at decimals: 2 is 150.00), the dashboard's convention. The webhook body for the same payment uses decimal strings. clientReferenceId on a payment row is the buyer visit's, card rail only; the link-level one is on the link.


You do not need a server to sell one thing. Create the product and the link in the dashboard, publish it, and paste shareUrl wherever you like:

html
<a href="https://business.avvio.xyz/i/7e2a9c4b1d0f">Buy now</a>

To carry a per-buyer reference without a server, append ?client_reference_id=<value> to shareUrl:

html
<a href="https://business.avvio.xyz/i/7e2a9c4b1d0f?client_reference_id=order_1042">Buy now</a>

It arrives as clientReferenceId on the payment and on the event. Two honest limits:


6. Refunds, reversals and the silences#

paid is not final. A card payment can be refunded at any time and reversed (charged back) weeks later. A ledger that treats paid as final books reversed money as revenue forever.

Refunds are dashboard-only in this version. An owner or admin issues one from the payment's row. Giving a buyer's money back is a signing decision, the same class as choosing where a payout lands, and the machine member an API key acts as does not hold that role. The refund arrives on your webhook as checkout_payment.refunded and moves refundedBase on the payment.

Three things that are not announced, and that you should read off the payment rather than wait for:

Silence What you see instead
A partial card refund No event. The payment stays paid; refundedBase moves. Read GET /links/{linkId}/payments
A bank deposit short of the total No event. The payment sits in pending with reviewReason saying why, until someone accepts it in the dashboard
An open dispute No event until it resolves. disputeSubstatus is set on the payment meanwhile; a loss arrives as checkout_payment.reversed

If you reconcile on a schedule, page GET /links/{linkId}/payments for your live links and compare status and refundedBase with what you hold.

settledAt is null in this version: the money is in your balance at the processor, and we do not see it move from there.


7. If you know Stripe#

Stripe Here
POST /v1/checkout/sessions POST /checkout/organizations/{orgId}/links with publish: true
session.url shareUrl
Payment Links A link created in the dashboard, or without publish
success_url successUrl
cancel_url cancelUrl
client_reference_id clientReferenceId (same alphabet, same 200-character limit)
metadata metadata (same limits: 50 keys, 40-character keys, 500-character values)
?client_reference_id= on a Payment Link URL ?client_reference_id= on shareUrl (card rail, best effort)
checkout.session.completed checkout_payment.paid
charge.refunded checkout_payment.refunded (full refunds only; partial moves refundedBase)
charge.dispute.closed (lost) checkout_payment.reversed
payment_link.active = false POST /links/{linkId}/pause (permanent)
line_items / price_data productId, or currency + items
payment_method_types methods (omitted = card)
Amounts in minor units (1500) Decimal strings on links and events ("15.00"); base units with decimals on payment rows

What has no equivalent here yet: a test mode for checkout, subscriptions, and an API refund. Each is on the list, and none is pretended.


Then#