Docs
API 2026-05-27.dahlia Dashboard

Payments

Create a link that sells your Prices, share it, switch it off and read its payments.

A payment link is a page address that sells a fixed set of your Prices, again and again, to anyone who opens it. You make it once with POST /v1/payment_links, then post the address in a message, a social profile, an email or a QR code. You need no website and no cart: each buyer who opens the link goes on to a hosted Checkout Session of their own and pays there.

Pick a link when many buyers pay for the same thing. When each order has its own cart, create a Checkout Session per order instead. To make and share links without code, use the Dashboard.

Sell a Price through a link#

  1. A link sells Prices from your own catalog, never a bare amount. One call to POST /v1/prices with product_data[name] writes a new Product and its Price together. Amounts are whole numbers in the currency's smallest unit: unit_amount=2500 with currency=usd is $25.00. The link sells in your store's currency, so give the Price that currency.

    Create a Price with its Product
    curl https://api.fluveo.com/v1/prices \
      -H "Authorization: Bearer sk_test_..." \
      -H "Idempotency-Key: tee-price-1" \
      -d currency=usd \
      -d unit_amount=2500 \
      --data-urlencode "product_data[name]=Pro T-shirt"
    const response = await fetch("https://api.fluveo.com/v1/prices", {
      method: "POST",
      headers: {
        Authorization: "Bearer sk_test_...",
        "Idempotency-Key": "tee-price-1",
      },
      body: new URLSearchParams({
        currency: "usd",
        unit_amount: "2500",
        "product_data[name]": "Pro T-shirt",
      }),
    });
    const price = await response.json();
    console.log(price.id);
    import requests
    
    response = requests.post(
        "https://api.fluveo.com/v1/prices",
        headers={
            "Authorization": "Bearer sk_test_...",
            "Idempotency-Key": "tee-price-1",
        },
        data={
            "currency": "usd",
            "unit_amount": 2500,
            "product_data[name]": "Pro T-shirt",
        },
    )
    price = response.json()
    print(price["id"])
    <?php
    $ch = curl_init("https://api.fluveo.com/v1/prices");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer sk_test_...",
            "Idempotency-Key: tee-price-1",
        ],
        CURLOPT_POSTFIELDS => http_build_query([
            "currency" => "usd",
            "unit_amount" => 2500,
            "product_data" => [
                "name" => "Pro T-shirt",
            ],
        ]),
    ]);
    $price = json_decode(curl_exec($ch), true);
    echo $price["id"];

    The answer is the Price. Keep its id for the link.

    Response (abridged)
    {
      "id": "price_...",
      "object": "price",
      "active": true,
      "currency": "usd",
      "product": "prod_...",
      "type": "one_time",
      "unit_amount": 2500
    }
  2. Name the Price in line_items[0][price] and how many units one payment buys in line_items[0][quantity]. Here the buyer lands on your thank-you page after paying, and the link keeps a message for the day you switch it off. Send an Idempotency-Key: a retry with the same key and body returns the same link, never a second one.

    Create a payment link
    curl https://api.fluveo.com/v1/payment_links \
      -H "Authorization: Bearer sk_test_..." \
      -H "Idempotency-Key: tee-link-1" \
      -d "line_items[0][price]=price_..." \
      -d "line_items[0][quantity]=1" \
      -d "after_completion[type]=redirect" \
      --data-urlencode "after_completion[redirect][url]=https://shop.example.com/thanks" \
      --data-urlencode "inactive_message=This sale has ended." \
      -d "metadata[campaign]=spring"
    const response = await fetch("https://api.fluveo.com/v1/payment_links", {
      method: "POST",
      headers: {
        Authorization: "Bearer sk_test_...",
        "Idempotency-Key": "tee-link-1",
      },
      body: new URLSearchParams({
        "line_items[0][price]": "price_...",
        "line_items[0][quantity]": "1",
        "after_completion[type]": "redirect",
        "after_completion[redirect][url]": "https://shop.example.com/thanks",
        inactive_message: "This sale has ended.",
        "metadata[campaign]": "spring",
      }),
    });
    const link = await response.json();
    console.log(link.url);
    import requests
    
    response = requests.post(
        "https://api.fluveo.com/v1/payment_links",
        headers={
            "Authorization": "Bearer sk_test_...",
            "Idempotency-Key": "tee-link-1",
        },
        data={
            "line_items[0][price]": "price_...",
            "line_items[0][quantity]": 1,
            "after_completion[type]": "redirect",
            "after_completion[redirect][url]": "https://shop.example.com/thanks",
            "inactive_message": "This sale has ended.",
            "metadata[campaign]": "spring",
        },
    )
    link = response.json()
    print(link["url"])
    <?php
    $ch = curl_init("https://api.fluveo.com/v1/payment_links");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer sk_test_...",
            "Idempotency-Key: tee-link-1",
        ],
        CURLOPT_POSTFIELDS => http_build_query([
            "line_items" => [
                [
                    "price" => "price_...",
                    "quantity" => 1,
                ],
            ],
            "after_completion" => [
                "type" => "redirect",
                "redirect" => [
                    "url" => "https://shop.example.com/thanks",
                ],
            ],
            "inactive_message" => "This sale has ended.",
            "metadata" => [
                "campaign" => "spring",
            ],
        ]),
    ]);
    $link = json_decode(curl_exec($ch), true);
    echo $link["url"];

    The answer is a payment_link whose id starts with plink_. It is active, and url is the address to share.

    Response (abridged)
    {
      "id": "plink_...",
      "object": "payment_link",
      "active": true,
      "currency": "usd",
      "url": "https://.../p/plink_...",
      "after_completion": {
        "type": "redirect",
        "redirect": { "url": "https://shop.example.com/thanks" }
      },
      "inactive_message": "This sale has ended.",
      "metadata": { "campaign": "spring" },
      "livemode": false
    }
  3. Share the url#

    Put url wherever your buyers are. Opening it shows the line items and their total. When the buyer goes on, Fluveo opens a new Checkout Session for that visit and sends the buyer to its page, where they choose how to pay. After payment the buyer goes where after_completion says.

A link holds up to 20 lines, each with an active one-time Price (type one_time) of an active Product, all in one currency. To sell something not in your catalog yet, send line_items[0][price_data] with currency, unit_amount and product_data[name] in place of price: Fluveo creates the Price first, then the link. A Price whose amount the buyer picks (custom_unit_amount) must be the link's only line, with quantity 1.

Body parameters
  • line_itemsarrayRequired

    What the link sells, at most 20 lines, all in your store's currency. Each line takes quantity and one of price or price_data.

    4 child fields
    • pricestringOptional

      The price_... id of one of your active one-time Prices.

    • price_dataobjectOptional

      A new Price to create for this line: currency, unit_amount in the smallest currency unit, and product_data[name].

    • quantityintegerRequired

      Units of this line in one payment.

    • adjustable_quantityobjectOptional

      Lets the buyer pick this line's quantity on the link's page: enabled, with an optional minimum (from 0) and maximum (from 1 to 999999, 99 when unset). The line's quantity must sit between them.

  • after_completionobjectOptional

    Where the buyer goes after paying: type=hosted_confirmation, with an optional hosted_confirmation[custom_message], or type=redirect with redirect[url].

  • inactive_messagestringOptional

    The text the link's page shows once the link is inactive, up to 500 characters.

  • metadataobjectOptional

    Up to 50 key-value pairs of your own, sent as metadata[key]=value. Keys starting with fluveo_ are reserved.

  • The methods the page offers: any of card, pix and boleto your store takes (see Payment methods). Without it, the page offers the default methods of a Checkout Session.

  • restrictionsobjectOptional

    restrictions[completed_sessions][limit]: how many payments the link takes before it turns inactive by itself. A buyer whose checkout opened before the limit was reached cannot pay it afterwards: the page answers "This link can no longer accept payments. Contact the seller for more information."

  • submit_typestringOptional

    The word on the pay button: auto, book, donate or pay.

  • auto (the default) or required, to always ask the buyer for a full address.

  • shipping_address_collection[allowed_countries][]: the two-letter countries the page takes a shipping address in.

  • phone_number_collection[enabled]=true asks the buyer for a phone number.

  • name_collectionobjectOptional

    name_collection[individual] and name_collection[business], each with enabled and optional: whether the page asks for the buyer's name or the business name.

  • automatic_taxobjectOptional

    automatic_tax[enabled]: only false is taken.

  • managed_paymentsobjectOptional

    managed_payments[enabled]: only false is taken, and only on create.

The Payment Links reference has every field of the object.

Read a link back#

Retrieve one link by its id with GET /v1/payment_links/{payment_link}.

Retrieve a payment link
curl https://api.fluveo.com/v1/payment_links/plink_... \
  -H "Authorization: Bearer sk_test_..."
const response = await fetch("https://api.fluveo.com/v1/payment_links/plink_...", {
  headers: { Authorization: "Bearer sk_test_..." },
});
const link = await response.json();
console.log(link.active);
import requests

response = requests.get(
    "https://api.fluveo.com/v1/payment_links/plink_...",
    headers={"Authorization": "Bearer sk_test_..."},
)
link = response.json()
print(link["active"])
<?php
$ch = curl_init("https://api.fluveo.com/v1/payment_links/plink_...");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_test_..."],
]);
$link = json_decode(curl_exec($ch), true);
echo $link["active"] ? "true" : "false";

Its line items are a list of their own. Each item has an id that starts with li_, the price, the quantity, the item's amount_total in the smallest currency unit, and a description taken from the Product's name.

List a link's line items
curl https://api.fluveo.com/v1/payment_links/plink_.../line_items \
  -H "Authorization: Bearer sk_test_..."
const response = await fetch("https://api.fluveo.com/v1/payment_links/plink_.../line_items", {
  headers: { Authorization: "Bearer sk_test_..." },
});
const items = await response.json();
console.log(items.data[0].id);
import requests

response = requests.get(
    "https://api.fluveo.com/v1/payment_links/plink_.../line_items",
    headers={"Authorization": "Bearer sk_test_..."},
)
items = response.json()
print(items["data"][0]["id"])
<?php
$ch = curl_init("https://api.fluveo.com/v1/payment_links/plink_.../line_items");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_test_..."],
]);
$items = json_decode(curl_exec($ch), true);
echo $items["data"][0]["id"];
Response (abridged)
{
  "object": "list",
  "data": [
    {
      "id": "li_...",
      "object": "item",
      "description": "Pro T-shirt",
      "quantity": 1,
      "amount_total": 2500,
      "currency": "usd",
      "price": { "id": "price_...", "object": "price", "unit_amount": 2500 }
    }
  ],
  "has_more": false
}

GET /v1/payment_links lists your links, newest first. Filter it with active, and page it with limit (1 to 100, default 10), starting_after and ending_before (see Pagination).

List the active payment links
curl -G https://api.fluveo.com/v1/payment_links \
  -H "Authorization: Bearer sk_test_..." \
  -d active=true \
  -d limit=3
const query = new URLSearchParams({
  active: "true",
  limit: "3",
});
const response = await fetch("https://api.fluveo.com/v1/payment_links?" + query, {
  headers: { Authorization: "Bearer sk_test_..." },
});
const links = await response.json();
console.log(links.data.length);
import requests

response = requests.get(
    "https://api.fluveo.com/v1/payment_links",
    headers={"Authorization": "Bearer sk_test_..."},
    params={
        "active": "true",
        "limit": 3,
    },
)
links = response.json()
print(len(links["data"]))
<?php
$query = http_build_query([
    "active" => "true",
    "limit" => 3,
]);
$ch = curl_init("https://api.fluveo.com/v1/payment_links?" . $query);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_test_..."],
]);
$links = json_decode(curl_exec($ch), true);
echo count($links["data"]);

Change a link#

POST /v1/payment_links/{payment_link} changes only what you send. It takes the create's fields except managed_payments, plus active. Lines are neither added nor removed: you edit an existing line by its li_ id, with a new quantity or adjustable_quantity. To sell a different set of Prices, create a new link.

Change a line's quantity
curl https://api.fluveo.com/v1/payment_links/plink_... \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: tee-link-1-qty-2" \
  -d "line_items[0][id]=li_..." \
  -d "line_items[0][quantity]=2"
const response = await fetch("https://api.fluveo.com/v1/payment_links/plink_...", {
  method: "POST",
  headers: {
    Authorization: "Bearer sk_test_...",
    "Idempotency-Key": "tee-link-1-qty-2",
  },
  body: new URLSearchParams({
    "line_items[0][id]": "li_...",
    "line_items[0][quantity]": "2",
  }),
});
const link = await response.json();
console.log(link.id);
import requests

response = requests.post(
    "https://api.fluveo.com/v1/payment_links/plink_...",
    headers={
        "Authorization": "Bearer sk_test_...",
        "Idempotency-Key": "tee-link-1-qty-2",
    },
    data={
        "line_items[0][id]": "li_...",
        "line_items[0][quantity]": 2,
    },
)
link = response.json()
print(link["id"])
<?php
$ch = curl_init("https://api.fluveo.com/v1/payment_links/plink_...");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer sk_test_...",
        "Idempotency-Key: tee-link-1-qty-2",
    ],
    CURLOPT_POSTFIELDS => http_build_query([
        "line_items" => [
            [
                "id" => "li_...",
                "quantity" => 2,
            ],
        ],
    ]),
]);
$link = json_decode(curl_exec($ch), true);
echo $link["id"];

The answer is the whole link. An empty value removes an optional field: inactive_message= clears the message, and metadata[campaign]= removes one key.

Switch a link off#

Send active=false. The link keeps its id, its lines and its history, and active=true switches it back on.

Switch the link off
curl https://api.fluveo.com/v1/payment_links/plink_... \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: tee-link-1-off" \
  -d active=false
const response = await fetch("https://api.fluveo.com/v1/payment_links/plink_...", {
  method: "POST",
  headers: {
    Authorization: "Bearer sk_test_...",
    "Idempotency-Key": "tee-link-1-off",
  },
  body: new URLSearchParams({
    active: "false",
  }),
});
const link = await response.json();
console.log(link.active);
import requests

response = requests.post(
    "https://api.fluveo.com/v1/payment_links/plink_...",
    headers={
        "Authorization": "Bearer sk_test_...",
        "Idempotency-Key": "tee-link-1-off",
    },
    data={
        "active": "false",
    },
)
link = response.json()
print(link["active"])
<?php
$ch = curl_init("https://api.fluveo.com/v1/payment_links/plink_...");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer sk_test_...",
        "Idempotency-Key: tee-link-1-off",
    ],
    CURLOPT_POSTFIELDS => http_build_query([
        "active" => "false",
    ]),
]);
$link = json_decode(curl_exec($ch), true);
echo $link["active"] ? "true" : "false";
Response (abridged)
{
  "id": "plink_...",
  "object": "payment_link",
  "active": false,
  "inactive_message": "This sale has ended.",
  "url": "https://.../p/plink_..."
}

A buyer who opens the url of a switched-off link gets a page titled "This payment link is no longer available", with HTTP status 404, and no way to pay. Under the title the page shows your inactive_message, or a line asking the buyer to contact the seller when the link has none. A link that took the number of payments in restrictions[completed_sessions][limit] turns inactive by itself, and its page says it reached its payment limit.

A link you switched off, or one that reached its payment limit, takes any change and stays inactive until you send active=true; a link at its limit switches back on only when the same update, or an earlier one, sets restrictions[completed_sessions][limit] above its count. An expired link takes no change but active.

Payments made through a link#

A link never charges anyone itself. Every buyer who goes on from its page gets a new Checkout Session, and the payment happens in that session, through its PaymentIntent, as on Hosted checkout. The session's payment_link field holds the link's id. To list the paid sessions of one link, filter the Checkout Sessions by payment_link:

List the Checkout Sessions of a link
curl -G https://api.fluveo.com/v1/checkout/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -d payment_link=plink_... \
  -d status=complete
const query = new URLSearchParams({
  payment_link: "plink_...",
  status: "complete",
});
const response = await fetch("https://api.fluveo.com/v1/checkout/sessions?" + query, {
  headers: { Authorization: "Bearer sk_test_..." },
});
const sessions = await response.json();
console.log(sessions.data.length);
import requests

response = requests.get(
    "https://api.fluveo.com/v1/checkout/sessions",
    headers={"Authorization": "Bearer sk_test_..."},
    params={
        "payment_link": "plink_...",
        "status": "complete",
    },
)
sessions = response.json()
print(len(sessions["data"]))
<?php
$query = http_build_query([
    "payment_link" => "plink_...",
    "status" => "complete",
]);
$ch = curl_init("https://api.fluveo.com/v1/checkout/sessions?" . $query);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_test_..."],
]);
$sessions = json_decode(curl_exec($ch), true);
echo count($sessions["data"]);

After a buyer paid the link at quantity 2, the list holds one session, and amount_total is in the smallest currency unit:

Response (abridged)
{
  "object": "list",
  "data": [
    {
      "id": "cs_test_...",
      "object": "checkout.session",
      "payment_link": "plink_...",
      "status": "complete",
      "payment_status": "paid",
      "amount_total": 5000,
      "currency": "usd",
      "payment_intent": "pi_..."
    }
  ],
  "has_more": false
}

For card and Pix, fulfil the order from the checkout.session.completed event, never from the visit to your thank-you page. Read data.object.payment_link to know which link sold it. For a Boleto, wait for checkout.session.async_payment_succeeded and confirm payment_status=paid; an unpaid Boleto ends with checkout.session.async_payment_failed. A session that ends without issuing a Boleto emits checkout.session.expired.

Link states and events#

activeWhat a buyer gets at url
trueThe link's lines and total, and a way on to pay.
falseA 404 page with your inactive_message. You switched the link off, or it reached its payment limit.
EventWhen
payment_link.createdYou created a link. Sent once, even when the create is retried with its Idempotency-Key.
payment_link.updatedA field of the link changed, such as active. previous_attributes holds each changed field's old value. A change of a line's quantity sends none.
checkout.session.completedA buyer paid through the link's session, or was issued a Boleto (then payment_status is unpaid).
checkout.session.async_payment_succeededThe Boleto of a complete session was paid.
checkout.session.async_payment_failedThe Boleto of a complete session expired unpaid.
checkout.session.expiredA session opened from the link closed without payment.

The session's PaymentIntent sends its own events, such as payment_intent.succeeded. See Event types for each body, and Webhooks to receive them.

Errors you may see#

ErrorWhy
400 resource_missing on line_items[0][price]The Price is not one of your store's, in this mode.
400 on line_items[0][price], "Payment Links cannot include deleted or archived prices." (no code)The Price is archived. Use an active Price.
400 on line_items (no code)A Price whose amount the buyer picks shares the link with other lines.
400 parameter_unknownA parameter the link does not take. param names it.
400 automatic_tax_unavailableautomatic_tax[enabled]=true. Send false or leave it out.
400 managed_payments_unavailablemanaged_payments[enabled]=true. Send false or leave it out.
400 payment_link_no_valid_payment_methodsOne payment through the link would be more than 99999999 in the currency's smallest unit.
400 payment_link_inactiveYou changed something other than active on a link that expired. A link you switched off, or one that reached its payment limit, takes any change; send active=true with it to switch the link back on in the same call.
400 "You cannot reactivate a payment link if its restrictions have been met. Increase the limit for completed sessions." (no code)active=true on a link that reached its payment limit. Raise restrictions[completed_sessions][limit] above the link's count in the same call, or before it. A higher limit alone leaves the link switched off.
400 idempotency_error (no code)You reused an Idempotency-Key with a different body.
404 resource_missing on payment_linkThe plink_ id is not one of your store's links in this mode.

See Errors for the shape of every error.

Test it#

  1. With a sk_test_ key, create the Price and the link as above.
  2. Open the link's url in a browser. The page lists the Pro T-shirt at $25.00 (2500 in cents).
  3. Go on to pay and use the test card 4242 4242 4242 4242, any future expiry date and any CVC. The browser lands on https://shop.example.com/thanks.
  4. List the Checkout Sessions with payment_link=plink_...: one is complete and paid, and your endpoint got checkout.session.completed with the same payment_link.
  5. Send the link create again with the same Idempotency-Key and body: the same plink_ comes back with Idempotent-Replayed: true, and no second payment_link.created is sent.
  6. Switch the link off and open its url again: the page shows "This sale has ended." and no way to pay. GET /v1/events?type=payment_link.updated shows the change, with previous_attributes.active true.
  7. Create a link with line_items[0][price]=price_000000000000000000000000: it answers 400 resource_missing on line_items[0][price].

See Testing for the other test cards.

Next#