Skip to content

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/v1

Routes live under /checkout/organizations/{orgId}, shortened below to /checkout/…. Reads work with a read-only key; writes need write, or get 403 INSUFFICIENT_SCOPE.

  1. Create a product: POST /checkout/…/products.
  2. Create and publish a link: POST /checkout/…/links.
  3. Send the buyer to shareUrl, and back to your successUrl.
  4. Fulfil on the checkout_payment.paid webhook.
  5. Rehearse every outcome in the sandbox.
  1. 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 request
    curl -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"
    }'
    Response
    {
    "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 currency and items on the link. Sending both productId and items is a 400.

  2. 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 request
    curl -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
    }'
    Response
    {
    "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 slug beside id: the success page receives only the slug.

    Field What it does
    successUrl https. Where the buyer lands after a card payment. Bank and crypto payments never redirect
    cancelUrl https. A “Back to {your name}” link on the payer page
    clientReferenceId Your order id and join key. Letters, digits, - and _, up to 200. Echoed on every payment event
    metadata Up to 50 string keys (key ≤ 40 characters, value ≤ 500). Echoed on events, never shown to the buyer
    expiresAt ISO-8601 with a timezone, at most a year out. Omitted, the link runs until paused
    methods Omitted means card. bank and crypto need an account or address. [] makes a link nothing can pay
    fromName Defaults to your organization’s name. Shown on the page. On the card statement, a statementDescriptor wins, then your organization’s registered descriptor, then fromName

    Without publish: true you get a draft (link states).

    A card link cannot publish while card onboarding is unfinished. The call still answers 201 and keeps the draft, with a publishError:

    {
    "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 read publishError.type: BAD_REQUEST, CONFLICT, NOT_FOUND, FORBIDDEN, INTERNAL, or a specific code such as LINK_EXPIRED. Fix the cause and call POST /links/{linkId}/publish, or DELETE the draft. The answer is a 201 because 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. publish and DELETE of a draft have no SDK method yet; call them over HTTP.

  3. Redirect the buyer to shareUrl. After a card payment they return to your successUrl:

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

    avvio_link is the link’s slug; client_reference_id appears 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"

    This read returns base units with decimals ("15000" at decimals: 2 is 150.00); the webhook uses decimal strings. A payment row’s clientReferenceId is the buyer visit’s (?client_reference_id=), on the card rail only, so it is usually null when you set clientReferenceId on the link. Create one link per order and look for a paid row on that link, as above.

  4. Event 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 (refund says which). Still paid
    checkout_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 events list receives every payout type but none of these, so a payout-only receiver never 4xxs its way to auto-disable.

    data is a WebhookCheckoutPayment (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 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);
    });

    The event’s clientReferenceId is the buyer visit’s ?client_reference_id= when the card page carried one, otherwise the link’s. linkExpiredAt is set when the payment landed after the link’s expiresAt, such as a slow wire. The payment is paid either way, so decide whether to honour or refund it.

    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 pass nextSince back as since. A row appears about two seconds after the write (Reconcile your ledger).

  5. With an avvio_test_… key, pay the link yourself:

    IDEMPOTENCY_KEY=$(uuidgen) # new key per call; reuse it only to retry this exact request
    curl -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: .01 declines, .02 pays after 20 seconds, .03 refunds, .04 charges back, .05 declines for insufficient funds, .06 pays 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, call POST /checkout/…/payments/{paymentId}/simulate with {"action":"chargeback"}. Events arrive signed, with livemode: 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.

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?