Facturar en una tienda headless: webhooks de ida, números de factura de vuelta
Guías

Facturar en una tienda headless: webhooks de ida, números de factura de vuelta

cmssy no emite facturas: lo hace tu sistema de facturación. Este es el ciclo que los conecta: factura al cobrar, el número de vuelta en el pedido, factura rectificativa al reembolsar, de forma segura aunque un webhook llegue dos veces.

E
Equipo Cmssy
6 min read

Facturar en una tienda headless: webhooks de ida, números de factura de vuelta

cmssy deja la facturación a tu propio sistema. Este es el ciclo de webhooks que emite la factura al cobrar, registra el número en el pedido y añade una factura rectificativa al reembolsar, de forma segura aunque una entrega se repita.

Toda tienda necesita facturas, y casi toda tienda ya tiene un sistema que las emite: un programa de contabilidad, una plataforma nacional de facturación electrónica, un proveedor por el que insiste tu gestoría. cmssy no intenta sustituirlo. Hace tres cosas más pequeñas: le dice a tu sistema de facturación cuándo actuar, guarda en el pedido lo que ese sistema emitió y le muestra el resultado al comprador.

Este artículo construye ese ciclo: emitir la factura cuando se paga un pedido, devolver el número y emitir una factura rectificativa cuando se reembolsa dinero.

Diagrama de secuencia: cmssy envía order.paid a tu handler, el handler pide una factura al sistema de facturación y después registra el número con order.recordInvoice; con order.refunded emite una factura rectificativa y la registra con order.recordInvoiceCorrection.
Todo el ciclo: dos webhooks de ida, dos mutaciones de vuelta.

El modelo: una factura y después rectificativas

Cada pedido guarda una lista invoices, de la más antigua a la más reciente. Cada entrada tiene un kind (invoice o correction), un number, una url opcional al PDF, un provider opcional y una marca de tiempo issuedAt. Las reglas son pocas:

  • Una factura por pedido. Registrar de nuevo el mismo número no cambia nada. Un número distinto se rechaza, porque un pedido que de repente tiene dos facturas es un error contable, no una actualización.
  • Un pedido cancelado no se puede facturar. Si cancelas primero, no hay nada que facturar.
  • Las rectificativas necesitan una factura. Una factura rectificativa también se permite en pedidos cancelados y reembolsados, su número debe ser nuevo para el pedido y repetirla no cambia nada.
  • Como máximo 20 entradas por pedido, rectificativas incluidas.
La lista invoices de un pedido: una factura FV/2026/1042 y una factura rectificativa KOR/2026/0042, cada una con number, url, provider e issuedAt.
Lo que guarda el pedido. El comprador ve la misma lista en su pedido.

El equipo puede registrar cualquiera de las dos desde la página del pedido en el admin, mediante las mutaciones GraphQL order.recordInvoice y order.recordInvoiceCorrection, o con las herramientas MCP record_order_invoice y record_order_invoice_correction. Todas requieren el permiso orders:manage.

Paso 1: suscríbete a los eventos adecuados

Crea un endpoint de webhook para tu handler y suscríbelo a:

  • order.paid - el pedido está totalmente pagado; es hora de emitir la factura.
  • order.refunded y order.partially_refunded - se devolvió dinero; es hora de una factura rectificativa.
  • opcionalmente order.invoiced y order.invoice_corrected - se disparan después de registrar un número, útiles para enviar el PDF por email o sincronizar un libro contable.

Suscribirse a eventos order.* requiere orders:view además de webhooks:manage. Cada payload lleva el pedido con sus invoices, total, tax, currency, taxTreatment (con la mención legal que debe figurar en una factura con inversión del sujeto pasivo o de exportación), el comprador y su empresa.

Paso 2: el handler

Esta es una ruta de Next.js. issueInvoice e issueCorrection representan la API de tu proveedor; todo lo demás es 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 });
}

El token es un API token del workspace (cs_…) creado por alguien cuyo rol tiene orders:manage. Mantenlo en el servidor; puede hacer todo lo que puede hacer esa persona.

Paso 3: que sea seguro ejecutarlo dos veces

Los webhooks se entregan al menos una vez y sin orden garantizado. Una entrega puede reintentarse tras un timeout que tu handler no notó, y un reintento lento puede llegar después de un evento más reciente. Tres cosas del handler de arriba existen para eso:

  • Lee el pedido actual, no el payload. El payload es una instantánea del momento del evento. Dos entregas del mismo order.paid dicen ambas "todavía sin factura"; solo una lectura reciente le dice a la segunda que la primera ya terminó.
  • Dale a tu proveedor una clave de idempotencia. El lado de cmssy es seguro - el mismo número dos veces no registra nada dos veces -, pero que el proveedor no emita dos facturas depende de tu lado. Usa como clave el id del pedido para la factura y el importe reembolsado para la rectificativa, si tu proveedor admite claves; si no, busca un documento existente por tu propia referencia antes de emitir uno.
  • Trata un rechazo como información. Si recordInvoice responde que el pedido ya está facturado con otro número, dos handlers compitieron y el proveedor tiene un duplicado. Anula el duplicado allí; cmssy conservó la primera.

Devuelve un 2xx solo después de registrar el número. Un intento fallido se reintenta durante unas 27 horas, así que una caída breve de tu proveedor se resuelve sola.

Lo que ve el comprador

La tienda lee la misma lista en el pedido del comprador, así que una página de pedido puede enlazar la factura y cada rectificativa en cuanto se registran - para un cliente con sesión iniciada y para un invitado que consulta su pedido con el token del checkout. No hay nada que sincronizar.

Solicitudes de supresión

Cuando el equipo anonimiza un pedido finalizado, cmssy borra los datos personales del comprador y los enlaces de sus facturas, y conserva los números de factura para que las cuentas sigan cuadrando. Tu sistema de facturación tiene su propia copia; el webhook order.anonymized es la señal para gestionarla allí según tus reglas de conservación.

Sin código

No todas las tiendas necesitan un handler. Si las facturas se emiten a mano, registra el número en la página del pedido o pídeselo a un agente conectado por MCP: "registra la factura FV/2026/1042 en el pedido 1042 con este enlace al PDF". Las reglas son las mismas, y también el webhook que se dispara.

La referencia está en la documentación de commerce, la lista de eventos y los detalles de las firmas en la documentación de webhooks, y el artículo sobre webhooks profundiza en los reintentos y la verificación.