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#
Create the Price the link sells#
A link sells Prices from your own catalog, never a bare amount. One call to
POST /v1/priceswithproduct_data[name]writes a new Product and its Price together. Amounts are whole numbers in the currency's smallest unit:unit_amount=2500withcurrency=usdis $25.00. The link sells in your store's currency, so give the Price that currency.Create a Price with its Productcurl 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
idfor the link.Response (abridged){ "id": "price_...", "object": "price", "active": true, "currency": "usd", "product": "prod_...", "type": "one_time", "unit_amount": 2500 }Create the link#
Name the Price in
line_items[0][price]and how many units one payment buys inline_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 anIdempotency-Key: a retry with the same key and body returns the same link, never a second one.Create a payment linkcurl 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_linkwhoseidstarts withplink_. It isactive, andurlis 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 }Share the url#
Put
urlwherever 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 whereafter_completionsays.
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.
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}.
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.
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"];{
"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).
curl -G https://api.fluveo.com/v1/payment_links \
-H "Authorization: Bearer sk_test_..." \
-d active=true \
-d limit=3const 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.
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.
curl https://api.fluveo.com/v1/payment_links/plink_... \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: tee-link-1-off" \
-d active=falseconst 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";{
"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:
curl -G https://api.fluveo.com/v1/checkout/sessions \
-H "Authorization: Bearer sk_test_..." \
-d payment_link=plink_... \
-d status=completeconst 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:
{
"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#
active | What a buyer gets at url |
|---|---|
true | The link's lines and total, and a way on to pay. |
false | A 404 page with your inactive_message. You switched the link off, or it reached its payment limit. |
| Event | When |
|---|---|
payment_link.created | You created a link. Sent once, even when the create is retried with its Idempotency-Key. |
payment_link.updated | A 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.completed | A buyer paid through the link's session, or was issued a Boleto (then payment_status is unpaid). |
checkout.session.async_payment_succeeded | The Boleto of a complete session was paid. |
checkout.session.async_payment_failed | The Boleto of a complete session expired unpaid. |
checkout.session.expired | A 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#
| Error | Why |
|---|---|
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_unknown | A parameter the link does not take. param names it. |
400 automatic_tax_unavailable | automatic_tax[enabled]=true. Send false or leave it out. |
400 managed_payments_unavailable | managed_payments[enabled]=true. Send false or leave it out. |
400 payment_link_no_valid_payment_methods | One payment through the link would be more than 99999999 in the currency's smallest unit. |
400 payment_link_inactive | You 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_link | The plink_ id is not one of your store's links in this mode. |
See Errors for the shape of every error.
Test it#
- With a
sk_test_key, create the Price and the link as above. - Open the link's
urlin a browser. The page lists the Pro T-shirt at $25.00 (2500in cents). - Go on to pay and use the test card
4242 4242 4242 4242, any future expiry date and any CVC. The browser lands onhttps://shop.example.com/thanks. - List the Checkout Sessions with
payment_link=plink_...: one iscompleteandpaid, and your endpoint gotcheckout.session.completedwith the samepayment_link. - Send the link create again with the same
Idempotency-Keyand body: the sameplink_comes back withIdempotent-Replayed: true, and no secondpayment_link.createdis sent. - Switch the link off and open its
urlagain: the page shows "This sale has ended." and no way to pay.GET /v1/events?type=payment_link.updatedshows the change, withprevious_attributes.activetrue. - Create a link with
line_items[0][price]=price_000000000000000000000000: it answers 400resource_missingonline_items[0][price].
See Testing for the other test cards.