cURL
curl --request POST 'https://api.integrateledger.com/v1/billing/attach' \
-H "Authorization: Bearer $LEDGER_API_KEY" \
-H 'Content-Type: application/json' \
--data '{"customerId":"user_123","planId":"starter"}'import os
from ledger_sdk import Ledger
ledger = Ledger(api_key=os.environ["LEDGER_API_KEY"])
ledger.attach(customer_id="user_123", plan_id="starter")import { Ledger } from "@ledger/sdk";
const ledger = new Ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" });
await ledger.attach({ customerId: "user_123", planId: "starter" });const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({customerId: '<string>', planId: '<string>'})
};
fetch('https://api.integrateledger.com/v1/billing/attach', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.integrateledger.com/v1/billing/attach",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'customerId' => '<string>',
'planId' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.integrateledger.com/v1/billing/attach"
payload := strings.NewReader("{\n \"customerId\": \"<string>\",\n \"planId\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.integrateledger.com/v1/billing/attach")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"customerId\": \"<string>\",\n \"planId\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.integrateledger.com/v1/billing/attach")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"customerId\": \"<string>\",\n \"planId\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"checkoutUrl": "<string>",
"subscriptionId": 123
}{
"error": "<string>"
}{
"error": "<string>"
}{
"error": "<string>"
}{
"error": "<string>"
}{
"error": "<string>"
}{
"error": "<string>"
}Billing
Attach a plan or add-on to a customer
Assign a free plan immediately, or start Stripe Checkout for a paid plan or credit pack in the selected environment.
POST
/
v1
/
billing
/
attach
cURL
curl --request POST 'https://api.integrateledger.com/v1/billing/attach' \
-H "Authorization: Bearer $LEDGER_API_KEY" \
-H 'Content-Type: application/json' \
--data '{"customerId":"user_123","planId":"starter"}'import os
from ledger_sdk import Ledger
ledger = Ledger(api_key=os.environ["LEDGER_API_KEY"])
ledger.attach(customer_id="user_123", plan_id="starter")import { Ledger } from "@ledger/sdk";
const ledger = new Ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" });
await ledger.attach({ customerId: "user_123", planId: "starter" });const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({customerId: '<string>', planId: '<string>'})
};
fetch('https://api.integrateledger.com/v1/billing/attach', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.integrateledger.com/v1/billing/attach",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'customerId' => '<string>',
'planId' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.integrateledger.com/v1/billing/attach"
payload := strings.NewReader("{\n \"customerId\": \"<string>\",\n \"planId\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.integrateledger.com/v1/billing/attach")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"customerId\": \"<string>\",\n \"planId\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.integrateledger.com/v1/billing/attach")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"customerId\": \"<string>\",\n \"planId\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"checkoutUrl": "<string>",
"subscriptionId": 123
}{
"error": "<string>"
}{
"error": "<string>"
}{
"error": "<string>"
}{
"error": "<string>"
}{
"error": "<string>"
}{
"error": "<string>"
}Pass
customerId with exactly one of planId or addOnId. Use your application’s stable customer ID, and create the customer first with Create or get a customer. Your API key selects Sandbox or Live. Paid live Checkout requires live activation and Ledger’s live-charge gate; see Sandbox and live environments.
Paid Checkout requires a stored Stripe connection with both a webhook signing secret and endpoint ID. If the webhook setup is incomplete, Ledger returns 502 stripe_not_configured before creating a chargeable link. Free plans do not require Stripe.
curl --fail-with-body --request POST https://api.integrateledger.com/v1/billing/attach \
--header "Authorization: Bearer $LEDGER_API_KEY" \
--header "Content-Type: application/json" \
--data '{"customerId":"user_123","planId":"pro"}'
import os
from ledger_sdk import Ledger
ledger = Ledger(api_key=os.environ["LEDGER_API_KEY"])
checkout = ledger.attach(customer_id="user_123", plan_id="pro")
print(checkout["checkoutUrl"])
import { Ledger } from "@ledger/sdk";
const ledger = new Ledger({ apiKey: process.env.LEDGER_API_KEY ?? "" });
const checkout = await ledger.attach({ customerId: "user_123", planId: "pro" });
if ("checkoutUrl" in checkout) console.log(checkout.checkoutUrl);
Response and credit timing
A free plan returnssubscriptionId and status: "active" immediately, without Stripe or a Checkout URL. The first monthly allowance is available at once. Repeating the same attach returns the same subscription without granting twice.
A paid plan returns subscriptionId and checkoutUrl. Ledger activates the plan and grants its allowance only after a verified paid Stripe invoice webhook. A credit pack returns addOnPurchaseId and checkoutUrl; Ledger grants its credits after a signed, paid Checkout completion webhook. A browser redirect alone does not grant credits.
An active free plan can start a paid plan Checkout. Its remaining allowance stays available while Checkout is open. After the verified paid invoice arrives, the remaining free plan allowance expires and the paid allowance begins. Previously purchased credit packs stay available. A failed or abandoned Checkout leaves the free plan active.
Retrying Checkout
Retrying the same pending paid plan or credit pack returns its existing open Checkout URL. If that Stripe session has expired, Ledger closes the pending attempt and creates a new Checkout on the next attach. Returning to yourcancelUrl does not itself expire the Stripe session; the open link remains resumable until Stripe expires it.
409 subscription_exists means this customer already has an incompatible active or pending plan. 409 purchase_pending means a different credit pack Checkout is pending. See the billing guide for the complete lifecycle.Authorizations
Bearer authentication header of the form Bearer <token>, where <token> is your auth token.
Body
application/json