Guides
Sessions
Send a customer from your app to subscribe, pay an invoice, change plan, cancel, or manage billing, bring them back, and know for sure when they're done.
A session is a hosted page your server creates for one customer. The customer acts on the Abuna page and comes back to your site. Abuna then sends your server a webhook that carries your own metadata, so you know which of your users it was.
Actions and session types
Each customer action has its own endpoint. Every one returns the same session object and sends the same session.completed and session.expired events. The session's type tells you which action created it.
| Action | Endpoint | Type | Completes when | Expires | Events to rely on |
|---|---|---|---|---|---|
| Sell one price to a new subscriber | POST /subscriptions/start with price_id | checkout | The first payment succeeds. | 24 hours after creation. | session.completed, and subscription.paid for a late payment. |
| Have a customer pay one open invoice | POST /invoices/{id}/pay | payment | That invoice is paid through the session. | 24 hours, or sooner if the invoice can no longer be paid through it. | invoice.paid |
| Have a customer confirm a move to another price | POST /subscriptions/{id}/change-plan with price_id | plan_change | The change is confirmed, and paid if it costs anything. | 7 days, or the end of the current period if sooner. | subscription.updated |
| Have a customer confirm a cancel | POST /subscriptions/{id}/request-cancel | cancel | The customer confirms. | 24 hours after creation. | subscription.updated for a scheduled cancel, subscription.canceled when it ends. |
| Send a customer to manage their subscription, with a link back to your app | POST /subscriptions/{id}/portal with return_url | portal | The customer first opens it. | 1 hour after creation, if not opened. | The events for what the customer does in the portal. |
Every path starts with /v1/apps/{appID}. Check data.type in your webhook handler, so each type reaches the right code.
How it works
- Your server calls the endpoint for the action with your secret key and gets back a session with a
url. - You redirect the customer to that
url. - The customer acts on the page: they subscribe, pay an invoice, confirm a plan change or a cancel, or manage their subscription in the customer portal.
- Abuna links the customer back to your success URL, or your return URL for a portal session.
- Abuna sends
session.completedto your webhook endpoint. You act on it when it arrives.
The next sections walk through starting a subscription. The other actions follow the same steps.
Start a subscription from your server
Create the session from your server with your secret key, in the Authorization header. Never call this from a browser or a mobile app. A publishable key or a dashboard sign-in can't create a session: both get 403 with code forbidden.
Call Start a subscription with the price_id to sell. Put your own user ID in metadata. Abuna copies it onto the subscription. Subscription events carry that metadata; session events carry the metadata passed on that session. Pass it again when you create another session.
curl https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/subscriptions/start \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"price_id": "01J9ZQ5N1P2Q3R4S5T6V7W8XYZ",
"customer": {"email": "ana@example.com"},
"success_url": "https://example.com/welcome",
"cancel_url": "https://example.com/pricing",
"metadata": {"user_id": "42"}
}'{
"id": "01J9ZQ9S1T2V3W4X5Y6Z7A8B9C",
"type": "checkout",
"status": "open",
"url": "https://app.abuna.app/checkout/7f2c9e4a1b8d3f6e0c5a9d2b7e4f1a8c3d6b",
"success_url": "https://example.com/welcome",
"cancel_url": "https://example.com/pricing",
"return_url": null,
"metadata": {
"user_id": "42"
},
"customer_id": null,
"price_id": "01J9ZQ5N1P2Q3R4S5T6V7W8XYZ",
"subscription_id": null,
"invoice_id": null,
"plan_offer_id": null,
"at_period_end": null,
"expires_at": 1727686400,
"completed_at": null,
"created_at": 1727600000
}The response has status 201 and a checkout session. The session is open and expires 24 hours after it was created, at expires_at. Start a subscription lists every parameter and error.
Prefill the customer
If you already know who is paying, tell Abuna, and the customer doesn't type it again. Send one of these, not both:
customer_id: an existing customer in the app. The checkout uses their name, email, and phone number.customer: any ofemail,name, andphone_number. Write the phone number with its country code, like+237671234567.
The checkout shows each prefilled field locked, so the customer can't change it. They fill in the rest. A customer needs an email address and a phone number to subscribe.
Send the customer to the session
Redirect the customer's browser to the session's url. Create a new session each time a customer starts a checkout. The url belongs to one customer, so don't post it anywhere public. For a link anyone can use, use a checkout link instead.
import http from "node:http";
http
.createServer(async (req, res) => {
if (req.method === "POST" && req.url === "/subscribe") {
const user = await currentUser(req);
const session = await createCheckoutSession(user); // the request above
res.writeHead(303, { Location: session.url }).end();
return;
}
res.writeHead(404).end();
})
.listen(3000);On the page, the customer fills in their details. Abuna creates the customer and a pending subscription, sets the session's customer_id and subscription_id, and shows the pay step on the same page. Once the first payment succeeds, the subscription becomes active and the session becomes completed, in the same step.
Opening the url again shows where the customer left off. It never creates a second subscription.
Handle the return
Checkout, payment, and paid upgrades show a "Back to" button after payment. Cancel sessions and plan changes with no payment due return straight to your success URL. Portal sessions show a "Back to" link for your return URL.
After the payment goes through, the customer sees a done screen with a link back to your success URL. Abuna picks the URL in this order:
- The
success_urlyou passed when you created the session. - Your app's Success URL, under Settings, General, Redirect URLs. Abuna copies it onto the session when you create it, so changing the setting later doesn't change existing sessions.
- With neither, the customer stays on Abuna's done screen.
Abuna adds the session and subscription IDs to the success URL in one of two ways:
- If the URL contains
{SESSION_ID}or{SUBSCRIPTION_ID}, Abuna replaces each one with the ID. - Otherwise, Abuna adds
session_idandsubscription_idquery parameters. It keeps your own query parameters and the#fragment.
https://example.com/welcome/{SESSION_ID}
https://example.com/welcome/01J9ZQ9S1T2V3W4X5Y6Z7A8B9C
https://example.com/welcome?plan=pro#start
https://example.com/welcome?plan=pro&session_id=01J9ZQ9S1T2V3W4X5Y6Z7A8B9C&subscription_id=01J9ZQ6M1N2P3Q4R5S6T7V8W9X#startThe cancel URL works the same way: the cancel_url you passed, or your app's Cancel URL. The checkout links to it for a customer who leaves before paying, and from the page of an expired session. Abuna adds nothing to it.
On your success page, you can read the session with GET/v1/apps/{appID}/sessions/{id} to show the right message.
const url = new URL(req.url, "https://example.com");
const sessionId = url.searchParams.get("session_id");
const response = await fetch(
"https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/sessions/" + encodeURIComponent(sessionId),
{ headers: { Authorization: "Bearer sk_test_..." } },
);
const session = await response.json();
// The ID came from the URL, so check the session is this user's.
if (!response.ok || session.metadata.user_id !== String(user.id)) {
return showPage("pricing");
}
if (session.status === "completed") {
return showPage("welcome");
}
// Paid but not confirmed yet, or a mobile money approval still pending.
return showPage("confirming-your-payment");Grant access on the webhook, not the redirect
Anyone can type your success URL into a browser, and a customer can close the tab before they get there. Grant access when session.completed reaches your webhook endpoint. Abuna sends it once the first payment succeeds, whether or not the customer came back.
{
"id": "01J9ZQ8E1F2G3H4J5K6M7N8P9Q",
"type": "session.completed",
"environment": "test",
"created_at": 1727600600,
"data": {
"session_id": "01J9ZQ9S1T2V3W4X5Y6Z7A8B9C",
"type": "checkout",
"subscription_id": "01J9ZQ6M1N2P3Q4R5S6T7V8W9X",
"customer_id": "01J9ZQ6A1B2C3D4E5F6G7H8J9K",
"price_id": "01J9ZQ5N1P2Q3R4S5T6V7W8XYZ",
"metadata": {
"user_id": "42"
},
"completed_at": 1727600600
}
}data.metadata is what you passed when you created the session. data.subscription_id and data.customer_id are the subscription and customer Abuna created. Store them next to your user.
Check the X-Abuna-Signature header before you trust the body. Receiving webhooks explains the scheme, and the example below uses it.
import crypto from "node:crypto";
import http from "node:http";
function verifyAbunaSignature(rawBody, headers, secret) {
const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"));
return ["x-abuna-signature", "x-abuna-signature-previous"].some((name) => {
const received = Buffer.from(headers[name] ?? "");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
http
.createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", async () => {
const rawBody = Buffer.concat(chunks);
if (!verifyAbunaSignature(rawBody, req.headers, process.env.ABUNA_WEBHOOK_SECRET)) {
res.writeHead(400).end();
return;
}
res.writeHead(200).end();
const event = JSON.parse(rawBody);
if (await alreadyHandled(event.id)) return;
if (event.type === "session.completed" && event.data.type === "checkout") {
const { metadata, subscription_id, customer_id } = event.data;
await grantAccess(metadata.user_id, subscription_id, customer_id);
}
await markHandled(event.id);
});
})
.listen(3000);Pay an overdue invoice from your app
A payment session sends a customer to pay one open invoice, for example from a "Pay now" button on your billing page. Call Pay an invoice with the invoice's ID in the path.
To find the invoice, list invoices with status=open, or keep the invoice_id from the invoice.created event when Abuna issues it. success_url, cancel_url, and metadata are optional, as for a checkout.
Create the session
curl https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/invoices/01J9ZQ7V1W2X3Y4Z5A6B7C8D9E/pay \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"success_url": "https://example.com/billing/paid",
"cancel_url": "https://example.com/billing",
"metadata": {"user_id": "42"}
}'{
"id": "01J9ZQ9V1W2X3Y4Z5A6B7C8D9E",
"type": "payment",
"status": "open",
"url": "https://app.abuna.app/pay/3a8d1f6c9e2b5a7d0f4c8e1b6a9d3f7c2e5b",
"success_url": "https://example.com/billing/paid",
"cancel_url": "https://example.com/billing",
"return_url": null,
"metadata": {
"user_id": "42"
},
"customer_id": "01J9ZQ6A1B2C3D4E5F6G7H8J9K",
"price_id": null,
"subscription_id": "01J9ZQ6M1N2P3Q4R5S6T7V8W9X",
"invoice_id": "01J9ZQ7V1W2X3Y4Z5A6B7C8D9E",
"plan_offer_id": null,
"at_period_end": null,
"expires_at": 1727686400,
"completed_at": null,
"created_at": 1727600000
}The session's url is a pay page for that invoice, and invoice_id names it. The invoice must be open. Otherwise you get one of these errors:
409already_paid: the invoice is already paid.409invoice_not_open: the invoice is void, for example because its subscription was canceled.404not_found: the app has no invoice with that ID.
Redirect and handle the return
Redirect the customer to the url. Once the invoice is paid through the session, the session completes and the customer sees a "Back to" button for your success URL. Abuna replaces {SESSION_ID}, {SUBSCRIPTION_ID}, and {INVOICE_ID} in it. Without placeholders, it adds session_id, subscription_id, and invoice_id query parameters. The cancel URL is the way back for a customer who leaves without paying.
http
.createServer(async (req, res) => {
const url = new URL(req.url, "https://example.com");
const user = await currentUser(req);
// The "Pay now" button on your billing page posts here.
if (req.method === "POST" && url.pathname === "/billing/pay") {
const session = await createPaymentSession(user); // the request above
res.writeHead(303, { Location: session.url }).end();
return;
}
// success_url: Abuna adds session_id, subscription_id, and invoice_id.
if (req.method === "GET" && url.pathname === "/billing/paid") {
const response = await fetch(
"https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/sessions/" +
encodeURIComponent(url.searchParams.get("session_id")),
{ headers: { Authorization: "Bearer sk_test_..." } },
);
const session = await response.json();
if (!response.ok || session.metadata.user_id !== String(user.id)) {
return showPage(res, "billing");
}
return showPage(res, session.status === "completed" ? "payment-received" : "confirming-your-payment");
}
res.writeHead(404).end();
})
.listen(3000);Confirm through the webhook
{
"id": "01J9ZQ8F1G2H3J4K5M6N7P8Q9R",
"type": "session.completed",
"environment": "test",
"created_at": 1727600600,
"data": {
"session_id": "01J9ZQ9V1W2X3Y4Z5A6B7C8D9E",
"type": "payment",
"invoice_id": "01J9ZQ7V1W2X3Y4Z5A6B7C8D9E",
"subscription_id": "01J9ZQ6M1N2P3Q4R5S6T7V8W9X",
"customer_id": "01J9ZQ6A1B2C3D4E5F6G7H8J9K",
"metadata": {
"user_id": "42"
},
"completed_at": 1727600600
}
}Use invoice.paid to know the invoice is settled. It fires however the customer paid. Use session.completed to know the customer finished the payment you sent them to. It carries your metadata.
// Inside the verified webhook handler above.
if (event.type === "invoice.paid") {
// Fires however the invoice was paid: this session, a pay link, or a retry.
await markInvoicePaid(event.data.subscription_id, event.data.invoice_id);
}
if (event.type === "session.completed" && event.data.type === "payment") {
// The customer finished the payment you sent them to.
await notifyUser(event.data.metadata.user_id, "Thanks, your payment went through.");
}When a payment session expires
A payment session expires after 24 hours, or sooner once its invoice can no longer be paid through it: it was paid another way, it was voided, or the subscription ended. Abuna then sends session.expired. A session whose invoice was paid another way expires. It doesn't complete, so don't wait on session.completed to settle the invoice.
The pay links Abuna already sends in emails and on Telegram keep working on their own. A payment session doesn't change them.
Add an upgrade button to your app
A plan_change session sends a customer to confirm a move to another price. Call Change plan with the subscription's ID in the path and the new price_id. success_url, cancel_url, and metadata are optional.
It follows the plan change rules. The price must be one of the subscription's plan options, or you get 422 price_not_eligible. The subscription must be active, or you get 409 plan_change_unavailable. An unknown subscription gets 404 not_found. It replaces any pending offer or plan_change session, and the older session gets session.expired. Abuna sends plan_offer.created, but doesn't email or message the customer: you redirect them.
Create the session
curl https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/subscriptions/01J9ZQ6M1N2P3Q4R5S6T7V8W9X/change-plan \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"price_id": "01J9ZQ5P1Q2R3S4T5V6W7X8YZA",
"success_url": "https://example.com/plan/changed",
"cancel_url": "https://example.com/plan",
"metadata": {"user_id": "42"}
}'{
"id": "01J9ZQ9W1X2Y3Z4A5B6C7D8E9F",
"type": "plan_change",
"status": "open",
"url": "https://app.abuna.app/plan-change/5d2b8e1f4a7c0d3e6b9f2a5c8e1d4b7a0f3c",
"success_url": "https://example.com/plan/changed",
"cancel_url": "https://example.com/plan",
"return_url": null,
"metadata": {
"user_id": "42"
},
"customer_id": "01J9ZQ6A1B2C3D4E5F6G7H8J9K",
"price_id": "01J9ZQ5P1Q2R3S4T5V6W7X8YZA",
"subscription_id": "01J9ZQ6M1N2P3Q4R5S6T7V8W9X",
"invoice_id": null,
"plan_offer_id": "01J9ZQAF1G2H3J4K5M6N7P8Q9R",
"at_period_end": null,
"expires_at": 1728204800,
"completed_at": null,
"created_at": 1727600000
}The url is the confirm page. It shows the current plan, the new one, and what the change costs now. price_id is the new price, and plan_offer_id is the offer behind the session.
Redirect and handle the return
Redirect the customer to the url. The customer reaches your success URL only once the change is done:
- Upgrade: after payment, the customer can choose "Back to" to return to your site. An upgrade that costs
0returns them directly when they confirm. - Downgrade: when they confirm. It is scheduled for the end of the paid period, and the customer keeps the current plan until then.
Your cancel URL is the "Back to" link on the confirm and pay steps.
http
.createServer(async (req, res) => {
const url = new URL(req.url, "https://example.com");
const user = await currentUser(req);
// The "Upgrade" button posts here.
if (req.method === "POST" && url.pathname === "/plan/upgrade") {
const session = await createPlanChangeSession(user); // the request above
res.writeHead(303, { Location: session.url }).end();
return;
}
// success_url: the customer confirmed, and paid if the upgrade cost anything.
if (req.method === "GET" && url.pathname === "/plan/changed") {
return showPage(res, "plan-changed");
}
// cancel_url: the "Back to" link on the confirm and pay steps.
if (req.method === "GET" && url.pathname === "/plan") {
return showPage(res, "plans");
}
res.writeHead(404).end();
})
.listen(3000);Confirm through the webhook
{
"id": "01J9ZQ8G1H2J3K4M5N6P7Q8R9S",
"type": "session.completed",
"environment": "test",
"created_at": 1727600900,
"data": {
"session_id": "01J9ZQ9W1X2Y3Z4A5B6C7D8E9F",
"type": "plan_change",
"subscription_id": "01J9ZQ6M1N2P3Q4R5S6T7V8W9X",
"customer_id": "01J9ZQ6A1B2C3D4E5F6G7H8J9K",
"metadata": {
"user_id": "42"
},
"completed_at": 1727600900,
"plan_offer_id": "01J9ZQAF1G2H3J4K5M6N7P8Q9R",
"previous_price_id": "01J9ZQ5N1P2Q3R4S5T6V7W8XYZ",
"price_id": "01J9ZQ5P1Q2R3S4T5V6W7X8YZA",
"direction": "upgrade",
"effective_at": 1727600900
}
}direction is upgrade or downgrade. effective_at is when the new price starts: now for an upgrade, the end of the paid period for a downgrade.
plan_offer.*, subscription.updated, and invoice.* events still fire as for any plan change. Rely on two of them:
session.completedto know the customer finished the flow you started. It carries yourmetadata.subscription.updatedto know the price changed. For a downgrade, that comes at the end of the period, after the session completes.
// Inside the verified webhook handler above.
if (event.type === "session.completed" && event.data.type === "plan_change") {
// The customer finished the flow. A downgrade starts later, at effective_at.
const { metadata, direction, effective_at } = event.data;
await notifyUser(metadata.user_id, direction === "upgrade" ? "You're on the new plan." : "Your plan changes on " + new Date(effective_at * 1000).toDateString());
}
if (event.type === "subscription.updated" && event.data.change?.type === "price_changed") {
// The price actually changed: switch the features on your side now.
await setPlan(event.data.subscription_id, event.data.change.new_price_id);
}When a plan change session expires
A plan change session lasts 7 days, or until the end of the current period if that comes first. It also expires, with session.expired, when its offer is replaced, canceled, or expires, or when an upgrade it started goes unpaid and lapses. An upgrade the customer confirmed but hasn't paid yet keeps the session open until it is paid or lapses.
Add a cancel button to your app
A cancel session sends a customer to confirm a cancel. Call Request a cancel with the subscription's ID in the path. success_url, cancel_url, and metadata are optional, as for a checkout. To cancel without asking the customer, use Cancel a subscription instead.
at_period_end is optional too. Send true to end the subscription at the end of the paid period, or false to end it right away. Leave it out and your app's When customers cancel setting, customer_cancel, decides. Abuna reads the setting when you create the session, and the session's at_period_end always holds the result.
Create the session
curl https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/subscriptions/01J9ZQ6M1N2P3Q4R5S6T7V8W9X/request-cancel \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"success_url": "https://example.com/account/canceled",
"cancel_url": "https://example.com/account",
"metadata": {"user_id": "42"}
}'{
"id": "01J9ZQ9X1Y2Z3A4B5C6D7E8F9G",
"type": "cancel",
"status": "open",
"url": "https://app.abuna.app/cancel/9e4b7a1d3f6c0e8b2d5a9f1c4e7b0d3a6f2c",
"success_url": "https://example.com/account/canceled",
"cancel_url": "https://example.com/account",
"return_url": null,
"metadata": {
"user_id": "42"
},
"customer_id": "01J9ZQ6A1B2C3D4E5F6G7H8J9K",
"price_id": null,
"subscription_id": "01J9ZQ6M1N2P3Q4R5S6T7V8W9X",
"invoice_id": null,
"plan_offer_id": null,
"at_period_end": true,
"expires_at": 1727686400,
"completed_at": null,
"created_at": 1727600000
}The subscription must be active and not already scheduled to cancel. Otherwise you get one of these errors:
409subscription_canceled: the subscription has already ended.409subscription_not_active: the subscription isn'tactive, for example because its first period was never paid.409cancel_already_scheduled: a cancel is already scheduled for the end of the period.404not_found: the app has no subscription with that ID.
Redirect and handle the return
Redirect the customer to the url. The page shows what will happen and the end date, with a Confirm button and a "Back to" link to your cancel URL. What happens follows the cancel rules:
- At the end of the period: the subscription stays
active, and the customer keeps access until the paid period ends. - Unpaid period: if the current period isn't paid, or is already over, the subscription ends right away instead. The page tells the customer before they confirm.
- Right away: the subscription ends now, and its open invoices are voided.
The session completes when the customer confirms, whether the cancel is scheduled or immediate. Abuna then sends them to your success URL, with {SESSION_ID} and {SUBSCRIPTION_ID} filled in, or session_id and subscription_id added, as for the other types.
http
.createServer(async (req, res) => {
const url = new URL(req.url, "https://example.com");
const user = await currentUser(req);
// The "Cancel subscription" button posts here.
if (req.method === "POST" && url.pathname === "/account/cancel") {
const session = await createCancelSession(user); // the request above
res.writeHead(303, { Location: session.url }).end();
return;
}
// success_url: the customer confirmed. Abuna adds session_id and subscription_id.
if (req.method === "GET" && url.pathname === "/account/canceled") {
return showPage(res, "sorry-to-see-you-go");
}
// cancel_url: the "Back to" link. The customer changed their mind.
if (req.method === "GET" && url.pathname === "/account") {
return showPage(res, "account");
}
res.writeHead(404).end();
})
.listen(3000);Confirm through the webhook
{
"id": "01J9ZQ8H1J2K3M4N5P6Q7R8S9T",
"type": "session.completed",
"environment": "test",
"created_at": 1727600300,
"data": {
"session_id": "01J9ZQ9X1Y2Z3A4B5C6D7E8F9G",
"type": "cancel",
"subscription_id": "01J9ZQ6M1N2P3Q4R5S6T7V8W9X",
"customer_id": "01J9ZQ6A1B2C3D4E5F6G7H8J9K",
"metadata": {
"user_id": "42"
},
"completed_at": 1727600300,
"cancel_at": 1729000000,
"immediate": false
}
}immediate is true when the subscription ended right away. cancel_at is when a scheduled cancel ends the subscription, and null when it ended right away.
subscription.updated (a scheduled cancel) or subscription.canceled (an immediate one) still fire, as for any cancel. Rely on two events:
session.completedto know the customer confirmed the cancel you sent them to. It carries yourmetadata.subscription.canceledto remove access. For a scheduled cancel, it comes later, atcancel_at.
// Inside the verified webhook handler above.
if (event.type === "session.completed" && event.data.type === "cancel") {
// The customer confirmed. Access ends on subscription.canceled, not here.
const { metadata, immediate, cancel_at } = event.data;
await notifyUser(metadata.user_id, immediate ? "Your subscription has ended." : "Your subscription ends on " + new Date(cancel_at * 1000).toDateString());
}
if (event.type === "subscription.canceled") {
// Right away for an immediate cancel. At cancel_at for a scheduled one.
await revokeAccess(event.data.subscription_id);
}When a cancel session expires
A cancel session the customer doesn't confirm expires 24 hours after creation, and Abuna sends session.expired. The subscription doesn't change.
Add a Manage billing link to your app
A portal session sends a customer to the customer portal for one subscription, with a link back to your app. There they can change plan, pay, cancel, connect Telegram, and update their contact details. Call Open the customer portal with the subscription's ID in the path and a return_url. metadata is optional. The subscription can have any status.
success_url and cancel_url don't apply to a portal session. Sending either returns 400 with code invalid_json. return_url follows the same URL rules as the other two.
Create the session
curl https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/subscriptions/01J9ZQ6M1N2P3Q4R5S6T7V8W9X/portal \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"return_url": "https://example.com/account",
"metadata": {"user_id": "42"}
}'{
"id": "01J9ZQ9Y1Z2A3B4C5D6E7F8G9H",
"type": "portal",
"status": "open",
"url": "https://app.abuna.app/portal/2c7f0a3d6b9e1c4f8a2d5b7e0c3f6a9d1b4e",
"success_url": null,
"cancel_url": null,
"return_url": "https://example.com/account",
"metadata": {
"user_id": "42"
},
"customer_id": "01J9ZQ6A1B2C3D4E5F6G7H8J9K",
"price_id": null,
"subscription_id": "01J9ZQ6M1N2P3Q4R5S6T7V8W9X",
"invoice_id": null,
"plan_offer_id": null,
"at_period_end": null,
"expires_at": 1727603600,
"completed_at": null,
"created_at": 1727600000
}The url is the customer portal, with a "Back to" link to your return_url at the top. An unknown subscription returns 404 with code not_found.
Redirect and let the customer come back
Create a new session each time the customer clicks your "Manage billing" button, and redirect them to its url right away. The url must be opened within 1 hour of creation. After that, it shows an invalid link. Once opened, it keeps working like the customer portal does.
The "Back to" link stays while the customer moves around the portal: changing plan, connecting Telegram, updating contact details, and paying. On the pay page, the back and done links return to your return_url, and "Manage subscription" returns to the portal.
http
.createServer(async (req, res) => {
const url = new URL(req.url, "https://example.com");
const user = await currentUser(req);
// The "Manage billing" button posts here. Make a new session every time.
if (req.method === "POST" && url.pathname === "/account/billing") {
const session = await createPortalSession(user); // the request above
res.writeHead(303, { Location: session.url }).end();
return;
}
// return_url: the "Back to" link at the top of the portal.
if (req.method === "GET" && url.pathname === "/account") {
return showPage(res, "account");
}
res.writeHead(404).end();
})
.listen(3000);Act on what the customer does
{
"id": "01J9ZQ8J1K2M3N4P5Q6R7S8T9V",
"type": "session.completed",
"environment": "test",
"created_at": 1727600020,
"data": {
"session_id": "01J9ZQ9Y1Z2A3B4C5D6E7F8G9H",
"type": "portal",
"subscription_id": "01J9ZQ6M1N2P3Q4R5S6T7V8W9X",
"customer_id": "01J9ZQ6A1B2C3D4E5F6G7H8J9K",
"metadata": {
"user_id": "42"
},
"completed_at": 1727600020
}
}A portal session completes when the customer first opens it, and session.completed fires then. It doesn't mean the customer changed anything. Each thing they do in the portal sends its own event, as it always has:
- Change plan:
subscription.updated, andinvoice.createdandinvoice.paidfor an upgrade with something to pay. - Cancel, or keep a scheduled cancel:
subscription.updated, thensubscription.canceledwhen the subscription ends. - Pay an invoice:
invoice.paid, orinvoice.payment_failed. - Update contact details:
customer.updated.
// Inside the verified webhook handler above.
// A portal session.completed only means the customer opened the portal.
// Act on what they did there, from these events.
if (event.type === "subscription.updated") {
await syncSubscription(event.data.subscription_id, event.data.price_id, event.data.cancel_at);
}
if (event.type === "subscription.canceled") {
await revokeAccess(event.data.subscription_id);
}
if (event.type === "invoice.paid") {
await markInvoicePaid(event.data.subscription_id, event.data.invoice_id);
}
if (event.type === "customer.updated") {
await syncContact(event.data.customer_id, event.data.email, event.data.phone_number);
}When a portal session expires
A portal session nobody opens within 1 hour expires, and Abuna sends session.expired. An opened session has already completed, so it never expires.
The subscription's subscription_page_url and the portal links in Abuna's emails keep working as before, with no link back to your app.
Metadata
metadata is an object of your own keys and values. It can have up to 20 keys. Each key is 1 to 40 characters and each value is a string of up to 500 characters. Metadata outside these limits returns 422 with code invalid and an errors item for metadata. Don't put secrets in it.
The subscription a checkout session creates starts with the same metadata. Every webhook that carries a subscription_id, like subscription.paid, invoice.paid, or subscription.canceled, also carries that subscription's metadata. To change it later, update the subscription.
Expiry
A checkout session lasts 24 hours. Within about a minute of expires_at, an open session becomes expired and Abuna sends session.expired. Its page then tells the customer the checkout has expired, with a link to your cancel URL. To start again, create a new session. The other types have their own rules: see payment, plan change, cancel, and portal expiry.
To expire a session sooner, for example when the customer picks another plan on your site, call POST/v1/apps/{appID}/sessions/{id}/expire.
curl -X POST https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/sessions/01J9ZQ9S1T2V3W4X5Y6Z7A8B9C/expire \
-H "Authorization: Bearer sk_test_..."A session that is already completed or expired returns 409 with code session_not_open.
A session completes at most once
A session ends in one of two states, completed or expired, and never changes after that. You get one session.completed or one session.expired for it, never both. An expired session never completes.
A customer who leaves during a payment
A mobile money payment waits for the customer to approve it on their phone. If they close the page while it waits, nothing is lost: when they approve, the subscription becomes active and session.completed still arrives. Your provider tells Abuna about the approval through the webhook URL you set in Connecting a payment provider.
Abuna doesn't expire a session while its payment is pending, even past expires_at. If that payment fails, the session expires on its own afterwards.
If you expire a session yourself while its payment is pending and the payment then succeeds, the subscription becomes active but the session stays expired.
The subscription of an expired session
If the customer filled in their details but didn't pay before the session expired, the pending subscription stays. Abuna emailed the customer its pay link when it started it. If the customer pays it before your app's time to pay runs out, the subscription becomes active. You get subscription.paid with your metadata, but no session.completed. If they don't pay, the subscription ends with subscription.canceled and reason checkout_expired.
To catch these late payments, also grant access on subscription.paid. Key your grant on subscription_id, so getting both events grants access once.
Retry safely with an idempotency key
A network error can hide whether your request created a session. To retry without making a second one, send an Idempotency-Key header. Every endpoint that creates a session takes one. Use a new random value, up to 255 characters, for each attempt, and the same value on each retry of it.
curl https://api.abuna.app/v1/apps/01J9ZQ4Y7R3T6V8W2X5B1C0DEF/subscriptions/start \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2b9e-4d3a-4c8e-9a7f-2e5b8d1c0a43" \
-d '{"price_id": "01J9ZQ5N1P2Q3R4S5T6V7W8XYZ", "metadata": {"user_id": "42"}}'Abuna keeps each key for 24 hours, per app. When the same key comes back:
- With the same request, you get the first response again, with the same status and body, and the header
Idempotent-Replayed: true. No new session is created. - With a different request, to another path or with another body, you get
422with codeidempotency_key_reused. - While the first request is still running, you get
409with codeidempotency_in_progress. Wait a moment and retry. - If the first request failed with a
5xx, Abuna doesn't keep it, and the retry runs as new.
A key longer than 255 characters returns 400 with code invalid.
Test it in Test mode
Use your Test app ID and its sk_test_ key. The flow is the same as in Live mode, but the pay step moves no money. With the Abuna simulator, the default, pick an outcome on the pay step:
- Successful payment: the session completes right away.
- Needs approval, then succeeds: the payment is
pendingfirst, like a mobile money prompt, and succeeds after about 10 seconds. The page waits for it. - Declined payment and Insufficient balance: the payment fails and the customer can try again.
See Simulated payments for how each one behaves. If you connected a provider's sandbox, the pay step also offers it under Test with. Choose it, and its test numbers decide the outcome. See Test numbers.
Cancel and portal sessions work the same way in Test mode, on your Test subscriptions.
In Test mode, success_url, cancel_url, and return_url can be http URLs on localhost, 127.0.0.1, or [::1], so you can come back to your own machine. This also applies to your Test app's default Success URL and Cancel URL, for every session type. Live mode takes only https URLs, and any other URL returns 422 with code invalid and an errors item with code url_invalid. Your webhook endpoint still needs a public https URL in both modes, so expose your machine through a tunnel to receive events.
Next steps
- SessionsSend a customer to a hosted page to start a subscription, pay an invoice, change plan, cancel, or open the customer portal. Then list, read, and expire those sessions.
- Receiving webhooksReceive events on your server and verify that Abuna sent them.
- Test mode and Live modeBuild against Test data with the Abuna simulator or provider sandboxes, then switch to Live with the same code.
- SubscriptionsStart, list, read, update, cancel, keep, and change the plan of a customer's subscription to a price.