Skip to content
ABUNA
DocumentationSessions

Guides

Sessions

Envoyez un client depuis votre application pour s'abonner, payer une facture, changer de formule, annuler ou gérer sa facturation, ramenez-le, et sachez avec certitude quand il a terminé.

Une session est une page hébergée que votre serveur crée pour un client. Le client agit sur la page d'Abuna et revient sur votre site. Abuna envoie ensuite à votre serveur un webhook qui porte vos propres metadata, pour que vous sachiez de quel utilisateur il s'agissait.

Actions et types de session

Chaque action client utilise un seul endpoint, POST/v1/apps/{appID}/sessions. Le type que vous envoyez choisit l'action. Chaque type renvoie le même objet session et envoie les mêmes événements session.completed et session.expired, avec le type de la session dedans.

ActionÀ envoyer avecTypeSe termine quandExpireÉvénements sur lesquels compter
Vendre un tarif à un nouvel abonnéprice_idcheckoutLe premier paiement réussit.24 heures après la création.session.completed, et subscription.paid pour un paiement tardif.
Faire payer une facture ouverte à un clientinvoice_idpaymentCette facture est payée via la session.24 heures, ou plus tôt si la facture ne peut plus être payée par son intermédiaire.invoice.paid
Faire confirmer à un client le passage à un autre tarifsubscription_id, price_idplan_changeLe changement est confirmé, et payé s'il coûte quelque chose.7 jours, ou la fin de la période en cours si elle arrive plus tôt.subscription.updated
Faire confirmer une annulation à un clientsubscription_idcancelLe client confirme.24 heures après la création.subscription.updated pour une annulation programmée, subscription.canceled quand elle prend fin.
Envoyer un client gérer son abonnement, avec un lien de retour vers votre applicationsubscription_id, return_urlportalLe client l'ouvre pour la première fois.1 heure après la création, s'il ne l'a pas ouverte.Les événements de ce que le client fait dans l'espace client.

Vérifiez data.type dans votre gestionnaire de webhook, pour que chaque type atteigne le bon code.

Comment ça marche

  1. Votre serveur crée une session du type de l'action avec votre clé secrète et récupère une session avec une url.
  2. Vous redirigez le client vers cette url.
  3. Le client agit sur la page : il s'abonne, paie une facture, confirme un changement de formule ou une annulation, ou gère son abonnement dans l'espace client.
  4. Abuna ramène le client vers votre URL de succès, ou votre URL de retour pour une session d'espace client.
  5. Abuna envoie session.completed à votre point de terminaison de webhook. Vous agissez dessus quand il arrive.

Les sections suivantes détaillent le démarrage d'un abonnement. Les autres actions suivent les mêmes étapes.

Démarrez un abonnement depuis votre serveur

Créez la session depuis votre serveur avec votre clé secrète, dans l'en-tête Authorization. N'appelez jamais cela depuis un navigateur ou une application mobile. Une clé publiable ou une connexion au tableau de bord ne peut pas créer de session : les deux obtiennent 403 avec le code forbidden.

Appelez Créer une session de paiement avec type checkout et le price_id à vendre. Mettez votre propre identifiant d'utilisateur dans metadata. Abuna le copie sur l'abonnement. Les événements d'abonnement portent ces métadonnées ; les événements de session portent les métadonnées passées sur cette session. Passez-les à nouveau quand vous créez une autre session.

POST /v1/apps/{appID}/sessions
curl https://api.abuna.app/v1/apps/8ddhXCDW/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "checkout",
    "price_id": "Zt6YbN3q",
    "customer": {"email": "ana@example.com"},
    "success_url": "https://example.com/welcome",
    "cancel_url": "https://example.com/pricing",
    "metadata": {"user_id": "42"}
  }'
Réponse
{
  "id": "A4kN1oSs",
  "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": "Zt6YbN3q",
  "subscription_id": null,
  "invoice_id": null,
  "plan_offer_id": null,
  "at_period_end": null,
  "expires_at": 1727686400,
  "completed_at": null,
  "created_at": 1727600000
}

La réponse a le statut 201 et une session checkout. La session est open et expire 24 heures après sa création, à expires_at. Créer une session de paiement liste chaque paramètre et chaque erreur.

Préremplissez le client

Si vous savez déjà qui paie, dites-le à Abuna, et le client ne le saisit pas à nouveau. Envoyez l'un de ces deux, pas les deux :

  • customer_id : un client existant dans l'application. La page de paiement utilise son nom, son e-mail et son numéro de téléphone.
  • customer : l'un de email, name et phone_number. Écrivez le numéro de téléphone avec son indicatif pays, comme +237671234567.

La page de paiement affiche chaque champ prérempli verrouillé, pour que le client ne puisse pas le modifier. Il remplit le reste. Un client a besoin d'une adresse e-mail et d'un numéro de téléphone pour s'abonner.

Envoyez le client vers la session

Redirigez le navigateur du client vers l'url de la session. Créez une nouvelle session chaque fois qu'un client démarre un paiement. L'url appartient à un seul client, alors ne la publiez nulle part en public. Pour un lien que tout le monde peut utiliser, utilisez plutôt un lien de paiement.

Redirection depuis votre serveur
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);

Sur la page, le client remplit ses informations. Abuna crée le client et un abonnement pending, définit customer_id et subscription_id de la session, et affiche l'étape de paiement sur la même page. Une fois le premier paiement réussi, l'abonnement devient active et la session devient completed, dans la même étape.

Ouvrir à nouveau l'url montre où le client s'est arrêté. Cela ne crée jamais un second abonnement.

Gérez le retour

Les sessions de paiement, de facture et les mises à niveau payées affichent un bouton « Retour » après le paiement. Les sessions d'annulation et les changements de formule sans paiement dû reviennent directement à votre URL de succès. Les sessions d'espace client affichent un lien « Retour » vers votre URL de retour.

Une fois le paiement passé, le client voit un écran de fin avec un lien vers votre URL de succès. Abuna choisit l'URL dans cet ordre :

  1. La success_url que vous avez passée à la création de la session.
  2. L'URL de succès de votre application, sous Paramètres, Général, URL de redirection. Abuna la copie sur la session à sa création, donc changer le réglage plus tard ne modifie pas les sessions existantes.
  3. Sans l'une ni l'autre, le client reste sur l'écran de fin d'Abuna.

Abuna ajoute les identifiants de session et d'abonnement à l'URL de succès de l'une de deux façons :

  • Si l'URL contient {SESSION_ID} ou {SUBSCRIPTION_ID}, Abuna remplace chacun par l'identifiant.
  • Sinon, Abuna ajoute les paramètres de requête session_id et subscription_id. Il conserve vos propres paramètres de requête et le fragment #.
URL de succès et où le client arrive
https://example.com/welcome/{SESSION_ID}
https://example.com/welcome/A4kN1oSs

https://example.com/welcome?plan=pro#start
https://example.com/welcome?plan=pro&session_id=A4kN1oSs&subscription_id=x9QbL2sK#start

L'URL d'annulation fonctionne de la même façon : la cancel_url que vous avez passée, ou l'URL d'annulation de votre application. La page de paiement y renvoie un client qui part avant de payer, et depuis la page d'une session expirée. Abuna n'y ajoute rien.

Sur votre page de succès, vous pouvez lire la session avec GET/v1/apps/{appID}/sessions/{id} pour afficher le bon message.

Votre page de succès
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/8ddhXCDW/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");

Accordez l'accès sur le webhook, pas sur la redirection

N'importe qui peut saisir votre URL de succès dans un navigateur, et un client peut fermer l'onglet avant d'y arriver. Accordez l'accès quand session.completed atteint votre point de terminaison de webhook. Abuna l'envoie une fois le premier paiement réussi, que le client soit revenu ou non.

Corps du webhook session.completed
{
  "id": "01J9ZQ8E1F2G3H4J5K6M7N8P9Q",
  "type": "session.completed",
  "environment": "test",
  "created_at": 1727600600,
  "data": {
    "session_id": "A4kN1oSs",
    "type": "checkout",
    "subscription_id": "x9QbL2sK",
    "customer_id": "Cu5tM8rA",
    "price_id": "Zt6YbN3q",
    "metadata": {
      "user_id": "42"
    },
    "completed_at": 1727600600,
    "subscription": {
      "id": "x9QbL2sK",
      "app_id": "8ddhXCDW",
      "customer_id": "Cu5tM8rA",
      "price_id": "Zt6YbN3q",
      "status": "active",
      "starts_at": 1727600000,
      "activated_at": 1727600100,
      "canceled_at": null,
      "cancel_at": null,
      "current_period_start": 1727600000,
      "current_period_end": 1730192000,
      "ended_reason": null,
      "metadata": {
        "user_id": "42"
      },
      "created_at": 1727600000,
      "manage_token": "8c1f4a7d2e9b3c6f0a5d8e1b4c7f2a9d3e6b"
    }
  }
}

data.metadata est ce que vous avez passé à la création de la session. data.subscription_id et data.customer_id sont l'abonnement et le client qu'Abuna a créés. Conservez-les à côté de votre utilisateur. data.subscription est tout l'abonnement, désormais active.

Vérifiez l'en-tête X-Abuna-Signature avant de faire confiance au corps. Recevoir des webhooks explique le schéma, et l'exemple ci-dessous l'utilise.

Gérer session.completed
import crypto from "node:crypto";
import http from "node:http";

// header is "t=1727600000,v1=<hex>", with a second v1 while a secret rotation overlaps.
function verifyAbunaSignature(rawBody, header, secret) {
  const pairs = (header ?? "").split(",").map((part) => part.split("="));
  const timestamp = pairs.find(([key]) => key === "t")?.[1] ?? "";
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest();
  return pairs.some(([key, value]) => {
    const received = Buffer.from(key === "v1" ? value ?? "" : "", "hex");
    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["x-abuna-signature"], 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);

Payez une facture en retard depuis votre application

Une session payment envoie un client payer une facture ouverte, par exemple depuis un bouton « Payer maintenant » sur votre page de facturation pendant que l'abonnement est past_due. Créez-en une avec type payment et l'identifiant de la facture dans invoice_id. Voyez Créer une session de paiement.

Pour trouver la facture, listez les factures avec status=open, ou conservez l'invoice_id de l'événement invoice.created quand Abuna l'émet. success_url, cancel_url et metadata sont facultatifs, comme pour une session de paiement.

Créez la session

POST /v1/apps/{appID}/sessions
curl https://api.abuna.app/v1/apps/8ddhXCDW/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "payment",
    "invoice_id": "V2nC8xEi",
    "success_url": "https://example.com/billing/paid",
    "cancel_url": "https://example.com/billing",
    "metadata": {"user_id": "42"}
  }'
Réponse
{
  "id": "z8TmY5Pq",
  "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": "Cu5tM8rA",
  "price_id": null,
  "subscription_id": "x9QbL2sK",
  "invoice_id": "V2nC8xEi",
  "plan_offer_id": null,
  "at_period_end": null,
  "expires_at": 1727686400,
  "completed_at": null,
  "created_at": 1727600000
}

L'url de la session est une page de paiement pour cette facture, et invoice_id la nomme. La facture doit être ouverte. Sinon vous obtenez l'une de ces erreurs :

  • 409 already_paid : la facture est déjà payée.
  • 409 invoice_not_open : la facture est annulée, par exemple parce que son abonnement a été annulé.
  • 422 invalid avec un élément errors pour invoice_id : l'application n'a aucune facture avec cet identifiant.

Redirigez et gérez le retour

Redirigez le client vers l'url. Une fois la facture payée via la session, la session se termine et le client voit un bouton « Retour » vers votre URL de succès. Abuna remplace {SESSION_ID}, {SUBSCRIPTION_ID} et {INVOICE_ID} dedans. Sans ces espaces réservés, il ajoute les paramètres de requête session_id, subscription_id et invoice_id. L'URL d'annulation est le chemin de retour d'un client qui part sans payer.

Routes de facturation sur votre serveur
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/8ddhXCDW/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);

Confirmez via le webhook

Corps du webhook session.completed
{
  "id": "01J9ZQ8F1G2H3J4K5M6N7P8Q9R",
  "type": "session.completed",
  "environment": "test",
  "created_at": 1727600600,
  "data": {
    "session_id": "z8TmY5Pq",
    "type": "payment",
    "invoice_id": "V2nC8xEi",
    "subscription_id": "x9QbL2sK",
    "customer_id": "Cu5tM8rA",
    "metadata": {
      "user_id": "42"
    },
    "completed_at": 1727600600,
    "subscription": {
      "id": "x9QbL2sK",
      "app_id": "8ddhXCDW",
      "customer_id": "Cu5tM8rA",
      "price_id": "Zt6YbN3q",
      "status": "active",
      "starts_at": 1727600000,
      "activated_at": 1727600100,
      "canceled_at": null,
      "cancel_at": null,
      "current_period_start": 1727600000,
      "current_period_end": 1730192000,
      "ended_reason": null,
      "metadata": {
        "user_id": "42"
      },
      "created_at": 1727600000,
      "manage_token": "8c1f4a7d2e9b3c6f0a5d8e1b4c7f2a9d3e6b"
    }
  }
}

Utilisez invoice.paid pour savoir que la facture est réglée. Il se déclenche quelle que soit la façon dont le client a payé. Utilisez session.completed pour savoir que le client a terminé le paiement vers lequel vous l'avez envoyé. Il porte vos metadata.

Gérer une facture payée
// 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.");
}

Quand une session de paiement expire

Une session de paiement expire après 24 heures, ou plus tôt une fois que sa facture ne peut plus être payée par son intermédiaire : elle a été payée autrement, elle a été annulée, ou l'abonnement a pris fin. Abuna envoie alors session.expired. Une session dont la facture a été payée autrement expire. Elle ne se termine pas, alors n'attendez pas session.completed pour régler la facture.

Les liens de paiement qu'Abuna envoie déjà dans les e-mails et sur Telegram continuent de fonctionner seuls. Une session de paiement ne les change pas.

Ajoutez un bouton de mise à niveau à votre application

Une session plan_change envoie un client confirmer le passage à un autre tarif. Appelez Créer une session de changement de formule avec l'identifiant de l'abonnement dans subscription_id et le nouveau price_id. success_url, cancel_url et metadata sont facultatifs.

Il suit les règles de changement de formule. Le tarif doit être l'une des options de formule de l'abonnement, sinon vous obtenez 422 price_not_eligible. L'abonnement doit être active ou past_due, sinon vous obtenez 409 plan_change_unavailable. Un abonnement inconnu obtient 422 invalid avec un élément errors pour subscription_id. Il remplace toute offre en attente ou session plan_change, et l'ancienne session obtient session.expired. Abuna envoie plan_offer.created, mais n'écrit pas et n'envoie pas de message au client : c'est vous qui le redirigez.

Créez la session

POST /v1/apps/{appID}/sessions
curl https://api.abuna.app/v1/apps/8ddhXCDW/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "plan_change",
    "subscription_id": "x9QbL2sK",
    "price_id": "Hc8LmV2s",
    "success_url": "https://example.com/plan/changed",
    "cancel_url": "https://example.com/plan",
    "metadata": {"user_id": "42"}
  }'
Réponse
{
  "id": "G2wP6hCs",
  "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": "Cu5tM8rA",
  "price_id": "Hc8LmV2s",
  "subscription_id": "x9QbL2sK",
  "invoice_id": null,
  "plan_offer_id": "q2WkR5oF",
  "at_period_end": null,
  "expires_at": 1728204800,
  "completed_at": null,
  "created_at": 1727600000
}

L'url est la page de confirmation. Elle montre la formule actuelle, la nouvelle, et ce que le changement coûte maintenant. price_id est le nouveau tarif, et plan_offer_id est l'offre derrière la session.

Redirigez et gérez le retour

Redirigez le client vers l'url. Le client n'atteint votre URL de succès qu'une fois le changement terminé :

  • Mise à niveau : après le paiement, le client peut choisir « Retour » pour revenir sur votre site. Une mise à niveau qui coûte 0 le ramène directement quand il confirme.
  • Rétrogradation : quand il confirme. Elle est programmée pour la fin de la période payée, et le client garde la formule actuelle jusque-là.

Votre URL d'annulation est le lien « Retour » sur les étapes de confirmation et de paiement.

Routes de formule sur votre serveur
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);

Confirmez via le webhook

Corps du webhook session.completed
{
  "id": "01J9ZQ8G1H2J3K4M5N6P7Q8R9S",
  "type": "session.completed",
  "environment": "test",
  "created_at": 1727600900,
  "data": {
    "session_id": "G2wP6hCs",
    "type": "plan_change",
    "subscription_id": "x9QbL2sK",
    "customer_id": "Cu5tM8rA",
    "metadata": {
      "user_id": "42"
    },
    "completed_at": 1727600900,
    "plan_offer_id": "q2WkR5oF",
    "previous_price_id": "Zt6YbN3q",
    "price_id": "Hc8LmV2s",
    "direction": "upgrade",
    "effective_at": 1727600900,
    "subscription": {
      "id": "x9QbL2sK",
      "app_id": "8ddhXCDW",
      "customer_id": "Cu5tM8rA",
      "price_id": "Hc8LmV2s",
      "status": "active",
      "starts_at": 1727600000,
      "activated_at": 1727600100,
      "canceled_at": null,
      "cancel_at": null,
      "current_period_start": 1727600900,
      "current_period_end": 1730192900,
      "ended_reason": null,
      "metadata": {
        "user_id": "42"
      },
      "created_at": 1727600000,
      "manage_token": "8c1f4a7d2e9b3c6f0a5d8e1b4c7f2a9d3e6b"
    }
  }
}

direction est upgrade ou downgrade. effective_at est le moment où le nouveau tarif commence : maintenant pour une mise à niveau, la fin de la période payée pour une rétrogradation.

Les événements plan_offer.*, subscription.updated et invoice.* se déclenchent toujours comme pour tout changement de formule. Comptez sur deux d'entre eux :

  • session.completed pour savoir que le client a terminé le flux que vous avez démarré. Il porte vos metadata.
  • subscription.updated pour savoir que le tarif a changé. Pour une rétrogradation, il arrive à la fin de la période, après la fin de la session.
Gérer un changement de formule
// 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);
}

Quand une session de changement de formule expire

Une session de changement de formule dure 7 jours, ou jusqu'à la fin de la période en cours si elle arrive avant. Elle expire aussi, avec session.expired, quand son offre est remplacée, annulée ou expirée, ou quand une mise à niveau qu'elle a démarrée reste impayée et expire. Une mise à niveau que le client a confirmée mais pas encore payée garde la session ouverte jusqu'à son paiement ou son expiration.

Ajoutez un bouton d'annulation à votre application

Une session cancel envoie un client confirmer une annulation. Appelez Créer une session d'annulation avec l'identifiant de l'abonnement dans subscription_id. success_url, cancel_url et metadata sont facultatifs, comme pour une session de paiement. Pour annuler sans demander au client, utilisez plutôt Annuler un abonnement.

at_period_end est facultatif aussi. Envoyez true pour terminer l'abonnement à la fin de la période payée, ou false pour le terminer tout de suite. Laissez-le de côté et le réglage Quand les clients annulent de votre application, customer_cancel, décide. Abuna lit le réglage quand vous créez la session, et l'at_period_end de la session contient toujours le résultat.

Créez la session

POST /v1/apps/{appID}/sessions
curl https://api.abuna.app/v1/apps/8ddhXCDW/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "cancel",
    "subscription_id": "x9QbL2sK",
    "success_url": "https://example.com/account/canceled",
    "cancel_url": "https://example.com/account",
    "metadata": {"user_id": "42"}
  }'
Réponse
{
  "id": "n9RcL3Xu",
  "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": "Cu5tM8rA",
  "price_id": null,
  "subscription_id": "x9QbL2sK",
  "invoice_id": null,
  "plan_offer_id": null,
  "at_period_end": true,
  "expires_at": 1727686400,
  "completed_at": null,
  "created_at": 1727600000
}

L'abonnement doit être active ou past_due, et pas déjà programmé pour être annulé. Sinon vous obtenez l'une de ces erreurs :

  • 409 subscription_canceled : l'abonnement a déjà pris fin.
  • 409 subscription_not_active : l'abonnement est pending : sa première période n'est pas encore payée. Pour laisser le client l'abandonner, envoyez-le plutôt vers l'espace client, où Annuler le termine tout de suite.
  • 409 cancel_already_scheduled : une annulation est déjà programmée pour la fin de la période.
  • 422 invalid avec un élément errors pour subscription_id : l'application n'a aucun abonnement avec cet identifiant.

Redirigez et gérez le retour

Redirigez le client vers l'url. La page montre ce qui va se passer et la date de fin, avec un bouton Confirmer et un lien « Retour » vers votre URL d'annulation. Ce qui se passe suit les règles d'annulation :

  • À la fin de la période : l'abonnement reste active, et le client garde l'accès jusqu'à la fin de la période payée.
  • Période impayée : si la période en cours n'est pas payée, comme pendant que l'abonnement est past_due, ou est déjà terminée, l'abonnement prend fin tout de suite à la place. La page le dit au client avant qu'il confirme.
  • Tout de suite : l'abonnement prend fin maintenant, et ses factures ouvertes sont annulées.

La session se termine quand le client confirme, que l'annulation soit programmée ou immédiate. Abuna l'envoie ensuite vers votre URL de succès, avec {SESSION_ID} et {SUBSCRIPTION_ID} remplis, ou session_id et subscription_id ajoutés, comme pour les autres types.

Routes de compte sur votre serveur
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);

Confirmez via le webhook

Corps du webhook session.completed
{
  "id": "01J9ZQ8H1J2K3M4N5P6Q7R8S9T",
  "type": "session.completed",
  "environment": "test",
  "created_at": 1727600300,
  "data": {
    "session_id": "n9RcL3Xu",
    "type": "cancel",
    "subscription_id": "x9QbL2sK",
    "customer_id": "Cu5tM8rA",
    "metadata": {
      "user_id": "42"
    },
    "completed_at": 1727600300,
    "cancel_at": 1730192000,
    "immediate": false,
    "subscription": {
      "id": "x9QbL2sK",
      "app_id": "8ddhXCDW",
      "customer_id": "Cu5tM8rA",
      "price_id": "Zt6YbN3q",
      "status": "active",
      "starts_at": 1727600000,
      "activated_at": 1727600100,
      "canceled_at": null,
      "cancel_at": 1730192000,
      "current_period_start": 1727600000,
      "current_period_end": 1730192000,
      "ended_reason": null,
      "metadata": {
        "user_id": "42"
      },
      "created_at": 1727600000,
      "manage_token": "8c1f4a7d2e9b3c6f0a5d8e1b4c7f2a9d3e6b"
    }
  }
}

immediate vaut true quand l'abonnement a pris fin tout de suite. cancel_at est le moment où une annulation programmée termine l'abonnement, et null quand il a pris fin tout de suite.

subscription.updated (une annulation programmée) ou subscription.canceled (une annulation immédiate) se déclenchent toujours, comme pour toute annulation. Comptez sur deux événements :

  • session.completed pour savoir que le client a confirmé l'annulation vers laquelle vous l'avez envoyé. Il porte vos metadata.
  • subscription.canceled pour retirer l'accès. Pour une annulation programmée, il arrive plus tard, à cancel_at.
Gérer une annulation
// 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);
}

Quand une session d'annulation expire

Une session d'annulation que le client ne confirme pas expire 24 heures après sa création, et Abuna envoie session.expired. L'abonnement ne change pas.

Ajoutez un lien Gérer la facturation à votre application

Une session portal envoie un client vers l'espace client pour un abonnement, avec un lien de retour vers votre application. Là, il peut changer de formule, payer, annuler, connecter Telegram et mettre à jour ses coordonnées. Appelez Créer une session d'espace client avec l'identifiant de l'abonnement dans subscription_id et une return_url. metadata est facultatif. L'abonnement peut avoir n'importe quel statut, y compris pending et canceled.

success_url et cancel_url ne s'appliquent pas à une session d'espace client. Envoyer l'une ou l'autre renvoie 400 avec le code invalid_json. return_url suit les mêmes règles d'URL que les deux autres.

Créez la session

POST /v1/apps/{appID}/sessions
curl https://api.abuna.app/v1/apps/8ddhXCDW/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "portal",
    "subscription_id": "x9QbL2sK",
    "return_url": "https://example.com/account",
    "metadata": {"user_id": "42"}
  }'
Réponse
{
  "id": "D1vT7rQf",
  "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": "Cu5tM8rA",
  "price_id": null,
  "subscription_id": "x9QbL2sK",
  "invoice_id": null,
  "plan_offer_id": null,
  "at_period_end": null,
  "expires_at": 1727603600,
  "completed_at": null,
  "created_at": 1727600000
}

L'url est l'espace client, avec un lien « Retour » vers votre return_url en haut. Un abonnement inconnu renvoie 422 avec le code invalid et un élément errors pour subscription_id.

Redirigez et laissez le client revenir

Créez une nouvelle session chaque fois que le client clique sur votre bouton « Gérer la facturation », et redirigez-le tout de suite vers son url. L'url doit être ouverte dans l'heure suivant sa création. Après cela, elle affiche un lien invalide. Une fois ouverte, elle continue de fonctionner comme l'espace client.

Le lien « Retour » reste pendant que le client circule dans l'espace client : changer de formule, connecter Telegram, mettre à jour ses coordonnées, et payer. Sur la page de paiement, les liens de retour et de fin renvoient vers votre return_url, et « Gérer l'abonnement » renvoie vers l'espace client. Chacun de ces liens est votre return_url exactement comme vous l'avez envoyée : Abuna n'ajoute aucun paramètre de requête ni identifiant. Mettez ce dont votre page a besoin dans l'URL à la création de la session.

Ce que le client voit dépend du statut de l'abonnement :

  • pending : la première facture avec sa date limite de paiement et un bouton Payer, et un bouton Annuler qui termine l'abonnement tout de suite. Pas de changement de formule.
  • active ou past_due : la formule, les factures, les coordonnées, un bouton Annuler, et les tarifs vers lesquels il peut changer, s'il y en a. Un abonnement past_due affiche aussi sa facture impayée avec un bouton Payer.
  • canceled : la formule, la date de son annulation, les factures, et leurs coordonnées, qu'ils peuvent encore mettre à jour. Rien à payer, et aucun changement de formule ni annulation.
Routes de facturation sur votre serveur
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);

Agissez sur ce que fait le client

Corps du webhook session.completed
{
  "id": "01J9ZQ8J1K2M3N4P5Q6R7S8T9V",
  "type": "session.completed",
  "environment": "test",
  "created_at": 1727600020,
  "data": {
    "session_id": "D1vT7rQf",
    "type": "portal",
    "subscription_id": "x9QbL2sK",
    "customer_id": "Cu5tM8rA",
    "metadata": {
      "user_id": "42"
    },
    "completed_at": 1727600020,
    "subscription": {
      "id": "x9QbL2sK",
      "app_id": "8ddhXCDW",
      "customer_id": "Cu5tM8rA",
      "price_id": "Zt6YbN3q",
      "status": "active",
      "starts_at": 1727600000,
      "activated_at": 1727600100,
      "canceled_at": null,
      "cancel_at": null,
      "current_period_start": 1727600000,
      "current_period_end": 1730192000,
      "ended_reason": null,
      "metadata": {
        "user_id": "42"
      },
      "created_at": 1727600000,
      "manage_token": "8c1f4a7d2e9b3c6f0a5d8e1b4c7f2a9d3e6b"
    }
  }
}

Une session d'espace client se termine quand le client l'ouvre pour la première fois, et session.completed se déclenche alors. Cela ne signifie pas que le client a changé quelque chose. Chaque chose qu'il fait dans l'espace client envoie son propre événement, comme toujours :

  • Changer de formule : subscription.updated, et invoice.created et invoice.paid pour une mise à niveau avec quelque chose à payer.
  • Annuler, ou conserver une annulation programmée : subscription.updated, puis subscription.canceled quand l'abonnement prend fin.
  • Payer une facture : invoice.paid, ou invoice.payment_failed.
  • Mettre à jour ses coordonnées : customer.updated.
Gérer ce qui se passe dans l'espace client
// 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);
}

Quand une session d'espace client expire

Une session d'espace client que personne n'ouvre dans l'heure expire, et Abuna envoie session.expired. Une session ouverte s'est déjà terminée, donc elle n'expire jamais.

Le subscription_page_url de l'abonnement et les liens de l'espace client dans les e-mails d'Abuna continuent de fonctionner comme avant, sans lien de retour vers votre application.

Métadonnées

metadata est un objet de vos propres clés et valeurs. Il peut avoir jusqu'à 20 clés. Chaque clé fait de 1 à 40 caractères et chaque valeur est une chaîne de 500 caractères maximum. Des métadonnées hors de ces limites renvoient 422 avec le code invalid et un élément errors pour metadata. N'y mettez pas de secrets.

L'abonnement qu'une session de paiement crée commence avec les mêmes metadata. Chaque webhook qui porte un subscription_id, comme subscription.paid, invoice.paid ou subscription.canceled, porte aussi les metadata de cet abonnement. Pour les changer plus tard, mettez à jour l'abonnement.

Expiration

Une session de paiement dure 24 heures. Dans la minute environ suivant expires_at, une session ouverte devient expired et Abuna envoie session.expired. Sa page indique alors au client que le paiement a expiré, avec un lien vers votre URL d'annulation. Pour recommencer, créez une nouvelle session. Les autres types ont leurs propres règles : voyez l'expiration d'une session de paiement, d'un changement de formule, d'une annulation et d'une session d'espace client.

Pour expirer une session plus tôt, par exemple quand le client choisit une autre formule sur votre site, appelez POST/v1/apps/{appID}/sessions/{id}/expire.

POST /v1/apps/{appID}/sessions/{id}/expire
curl -X POST https://api.abuna.app/v1/apps/8ddhXCDW/sessions/A4kN1oSs/expire \
  -H "Authorization: Bearer sk_test_..."

Une session déjà completed ou expired renvoie 409 avec le code session_not_open.

Une session se termine au plus une fois

Une session se termine dans l'un de deux états, completed ou expired, et ne change plus ensuite. Vous obtenez un session.completed ou un session.expired pour elle, jamais les deux. Une session expirée ne se termine jamais.

Un client qui part pendant un paiement

Un paiement Mobile Money attend que le client le valide sur son téléphone. S'il ferme la page pendant l'attente, rien n'est perdu : quand il valide, l'abonnement devient active et session.completed arrive quand même. Votre prestataire informe Abuna de la validation via l'URL de rappel du prestataire que vous avez définie dans Connecter un prestataire de paiement.

Abuna n'expire pas une session pendant que son paiement est en attente, même après expires_at. Si ce paiement échoue, la session expire d'elle-même ensuite.

Si vous expirez vous-même une session pendant que son paiement est en attente et que le paiement réussit ensuite, l'abonnement devient active mais la session reste expired.

L'abonnement d'une session expirée

Si le client a rempli ses informations mais n'a pas payé avant l'expiration de la session, l'abonnement pending reste. Abuna a envoyé au client son lien de paiement par e-mail au démarrage. Si le client le paie avant que le délai de paiement de votre application s'épuise, l'abonnement devient active. Vous obtenez subscription.paid avec vos metadata, mais pas de session.completed. S'il ne paie pas, l'abonnement prend fin avec subscription.canceled et la raison checkout_expired.

Pour rattraper ces paiements tardifs, accordez aussi l'accès sur subscription.paid. Basez votre attribution sur subscription_id, pour que recevoir les deux événements n'accorde l'accès qu'une fois.

Réessayez sans risque avec une clé d'idempotence

Une erreur réseau peut cacher si votre requête a créé une session. Pour réessayer sans en créer une seconde, envoyez un en-tête Idempotency-Key. Utilisez une nouvelle valeur aléatoire pour chaque tentative, et la même valeur à chaque nouvelle tentative de celle-ci. Une nouvelle tentative avec la même clé et le même corps dans les 24 heures reçoit à nouveau la première réponse, et aucune seconde session n'est créée.

POST /v1/apps/{appID}/sessions with Idempotency-Key
curl https://api.abuna.app/v1/apps/8ddhXCDW/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c2b9e-4d3a-4c8e-9a7f-2e5b8d1c0a43" \
  -d '{
    "type": "checkout",
    "price_id": "Zt6YbN3q",
    "metadata": {"user_id": "42"}
  }'
Réessayer avec la même clé
import crypto from "node:crypto";

// Make the key once per checkout attempt, and send the same key on every retry.
const idempotencyKey = crypto.randomUUID();

async function createSession() {
  return fetch("https://api.abuna.app/v1/apps/8ddhXCDW/sessions", {
    method: "POST",
    headers: {
      Authorization: "Bearer sk_test_...",
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({ type: "checkout", price_id: "Zt6YbN3q", metadata: { user_id: "42" } }),
  });
}

let response;
for (let attempt = 1; attempt <= 3; attempt++) {
  try {
    response = await createSession();
    if (response.status < 500 && response.status !== 409) break;
  } catch {
    // A network error: retry with the same key.
  }
  await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
}

Les requêtes idempotentes listent ce que renvoie chaque réutilisation d'une clé.

Testez en mode Test

Utilisez l'identifiant de votre application Test et sa clé sk_test_. Le flux est le même qu'en mode Réel, mais l'étape de paiement ne déplace aucun argent. Avec le simulateur Abuna, qui est le choix par défaut, choisissez une issue à l'étape de paiement :

  • Paiement réussi : la session se termine tout de suite.
  • Validation requise, puis réussite : le paiement est d'abord pending, comme une demande Mobile Money, et réussit après environ 10 secondes. La page l'attend.
  • Paiement refusé et Solde insuffisant : le paiement échoue et le client peut réessayer.

Voyez Paiements simulés pour le comportement de chacun. Si vous avez connecté le bac à sable d'un prestataire, l'étape de paiement le propose aussi sous Tester avec. Choisissez-le, et ses numéros de test décident de l'issue. Voyez Numéros de test.

Les sessions d'annulation et d'espace client fonctionnent de la même façon en mode Test, sur vos abonnements Test.

En mode Test, success_url, cancel_url et return_url peuvent être des URL http sur localhost, 127.0.0.1 ou [::1], pour que vous puissiez revenir sur votre propre machine. Cela s'applique aussi à l'URL de succès et à l'URL d'annulation par défaut de votre application Test, pour chaque type de session. Le mode Réel n'accepte que les URL https, et toute autre URL renvoie 422 avec le code invalid et un élément errors avec le code url_invalid. Votre point de terminaison de webhook a toujours besoin d'une URL https publique dans les deux modes, alors exposez votre machine via un tunnel pour recevoir les événements.

Étapes suivantes