Facturer une boutique headless : webhooks à l'aller, numéros de facture au retour
Guides

Facturer une boutique headless : webhooks à l'aller, numéros de facture au retour

cmssy n'émet pas de factures - c'est votre système de facturation qui s'en charge. Voici la boucle qui les relie : facture au paiement, numéro renvoyé sur la commande, avoir au remboursement, en toute sécurité quand un webhook arrive deux fois.

É
Équipe Cmssy
6 min read

Facturer une boutique headless : webhooks à l'aller, numéros de facture au retour

cmssy laisse la facturation à votre propre système. Voici la boucle de webhooks qui émet la facture au paiement, enregistre son numéro sur la commande et ajoute un avoir au remboursement - en toute sécurité, même quand une livraison se répète.

Toute boutique a besoin de factures, et presque toute boutique a déjà un système qui les émet : un logiciel de comptabilité, une plateforme nationale de facturation électronique, un prestataire auquel tient votre comptable. cmssy n'essaie pas de le remplacer. Il fait trois choses plus modestes : il indique à votre système de facturation quand agir, il conserve sur la commande ce que ce système a émis, et il montre le résultat à l'acheteur.

Cet article construit cette boucle : émettre la facture quand une commande est payée, renvoyer le numéro, et émettre une facture d'avoir quand de l'argent est remboursé.

Diagramme de séquence : cmssy envoie order.paid à votre handler, le handler demande une facture au système de facturation, puis enregistre le numéro avec order.recordInvoice ; sur order.refunded, il émet un avoir et l'enregistre avec order.recordInvoiceCorrection.
Toute la boucle : deux webhooks à l'aller, deux mutations au retour.

Le modèle : une facture, puis des avoirs

Chaque commande contient une liste invoices, de la plus ancienne à la plus récente. Chaque entrée a un kind (invoice ou correction), un number, une url facultative vers le PDF, un provider facultatif et un horodatage issuedAt. Les règles sont courtes :

  • Une facture par commande. Enregistrer à nouveau le même numéro ne change rien. Un numéro différent est refusé, car une commande qui a soudain deux factures est une erreur comptable, pas une mise à jour.
  • Une commande annulée ne peut pas être facturée. Si vous annulez d'abord, il n'y a plus rien à facturer.
  • Un avoir nécessite une facture. Un avoir (facture d'avoir) est aussi autorisé sur les commandes annulées et remboursées, son numéro doit être nouveau pour la commande, et le répéter ne change rien.
  • 20 entrées au maximum par commande, avoirs compris.
La liste invoices d'une commande : une facture FV/2026/1042 et un avoir KOR/2026/0042, chacun avec number, url, provider et issuedAt.
Ce que stocke la commande. L'acheteur voit la même liste sur sa commande.

L'équipe peut enregistrer l'une ou l'autre depuis la page de la commande dans l'admin, via les mutations GraphQL order.recordInvoice et order.recordInvoiceCorrection, ou avec les outils MCP record_order_invoice et record_order_invoice_correction. Tous exigent la permission orders:manage.

Étape 1 : s'abonner aux bons événements

Créez un endpoint de webhook pour votre handler et abonnez-le à :

  • order.paid - la commande est entièrement payée ; il est temps d'émettre la facture.
  • order.refunded et order.partially_refunded - de l'argent a été rendu ; il est temps d'émettre un avoir.
  • en option order.invoiced et order.invoice_corrected - déclenchés après l'enregistrement d'un numéro, utiles pour envoyer le PDF par e-mail ou synchroniser un grand livre.

S'abonner aux événements order.* exige orders:view en plus de webhooks:manage. Chaque payload contient la commande avec ses invoices, total, tax, currency, taxTreatment (avec la mention légale à imprimer sur une facture en autoliquidation ou à l'export), l'acheteur et son entreprise.

Étape 2 : le handler

Voici une route Next.js. issueInvoice et issueCorrection représentent l'API de votre prestataire ; tout le reste, c'est cmssy.

// app/api/invoicing/route.ts
import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";
import { issueInvoice, issueCorrection } from "@/lib/invoicing";

const RECORD = `mutation ($input: RecordOrderInvoiceInput!) {
  order { recordInvoice(input: $input) { id } }
}`;
const CORRECT = `mutation ($input: RecordOrderInvoiceInput!) {
  order { recordInvoiceCorrection(input: $input) { id } }
}`;
const CURRENT = `query ($id: ID!) {
  order { get(id: $id) { invoices { kind number } } }
}`;

async function cmssy(query: string, variables: object) {
  const res = await fetch("https://api.cmssy.io/graphql", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.CMSSY_API_TOKEN}`,
      "x-workspace-id": process.env.CMSSY_WORKSPACE_ID!,
    },
    body: JSON.stringify({ query, variables }),
  });
  const json = await res.json();
  if (json.errors?.length) throw new Error(json.errors[0].message);
  return json.data;
}

export async function POST(request: Request) {
  const body = await request.text();
  let event;
  try {
    event = await verifyCmssyWebhook({
      body,
      signatureHeader: request.headers.get("x-cmssy-signature"),
      secret: process.env.CMSSY_WEBHOOK_SECRET!,
    });
  } catch (error) {
    if (error instanceof CmssyWebhookError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }

  const order = event.data.order;
  const { order: { get: current } } = await cmssy(CURRENT, { id: order.id });
  const invoice = current.invoices.find((i) => i.kind === "invoice");

  if (event.event === "order.paid" && !invoice) {
    const issued = await issueInvoice(order, { idempotencyKey: order.id });
    await cmssy(RECORD, {
      input: { orderId: order.id, number: issued.number, url: issued.pdfUrl, provider: "my-invoicing" },
    });
  }

  if (event.event.endsWith("refunded") && invoice) {
    const key = `${order.id}:${order.refundedAmount}`;
    const issued = await issueCorrection(order, invoice, {
      refundedTotal: order.refundedAmount,
      idempotencyKey: key,
    });
    await cmssy(CORRECT, {
      input: { orderId: order.id, number: issued.number, url: issued.pdfUrl, provider: "my-invoicing" },
    });
  }

  return new Response(null, { status: 204 });
}

Le jeton est un jeton d'API de workspace (cs_…) créé par une personne dont le rôle a orders:manage. Gardez-le côté serveur ; il peut faire tout ce que cette personne peut faire.

Étape 3 : supporter une double exécution

Les webhooks sont livrés au moins une fois et pas dans l'ordre. Une livraison peut être retentée après un délai dépassé que votre handler n'a pas remarqué, et une nouvelle tentative lente peut arriver après un événement plus récent. Trois éléments du handler ci-dessus existent pour cela :

  • Lisez la commande actuelle, pas le payload. Le payload est un instantané du moment de l'événement. Deux livraisons du même order.paid disent toutes deux « pas encore de facture » ; seule une lecture fraîche indique à la seconde que la première a déjà terminé.
  • Donnez une clé d'idempotence à votre prestataire. Côté cmssy, tout est sûr - le même numéro envoyé deux fois n'enregistre rien en double - mais éviter que le prestataire émette deux factures, c'est votre côté. Utilisez l'id de la commande comme clé de la facture et le montant remboursé comme clé de l'avoir, si votre prestataire gère les clés ; sinon, cherchez un document existant par votre propre référence avant d'en émettre un.
  • Traitez un refus comme une information. Si recordInvoice répond que la commande est déjà facturée avec un autre numéro, deux handlers se sont fait la course et le prestataire détient un doublon. Annulez-le chez lui ; cmssy a conservé la première.

Ne renvoyez un 2xx qu'après l'enregistrement du numéro. Une tentative échouée est retentée pendant environ 27 heures, donc une courte panne chez votre prestataire se résorbe d'elle-même.

Ce que voit l'acheteur

La boutique lit la même liste sur la commande de l'acheteur, de sorte qu'une page de commande peut afficher le lien vers la facture et chaque avoir dès qu'ils sont enregistrés - pour un client connecté comme pour un invité qui consulte sa commande avec le jeton reçu au checkout. Il n'y a rien à synchroniser.

Demandes d'effacement

Quand l'équipe anonymise une commande terminée, cmssy efface les données personnelles de l'acheteur et les liens de ses factures, et conserve les numéros de facture pour que les comptes restent justes. Votre système de facturation détient sa propre copie ; le webhook order.anonymized est le signal pour la traiter là-bas selon vos règles de conservation.

Sans code

Toutes les boutiques n'ont pas besoin d'un handler. Si les factures sont émises à la main, enregistrez le numéro sur la page de la commande, ou demandez à un agent connecté via MCP : « enregistre la facture FV/2026/1042 sur la commande 1042 avec ce lien PDF ». Les règles sont les mêmes, tout comme le webhook déclenché.

La référence se trouve dans la documentation commerce, la liste des événements et le détail des signatures dans la documentation des webhooks, et l'article sur les webhooks approfondit les nouvelles tentatives et la vérification.