Automatizar facturas electrónicas en Panamá con webhooks (2026)
Automatizar facturas electrónicas en Panamá con webhooks
Tres patrones para automatizar facturación electrónica en Panamá con la API de Conteo. Código en TypeScript.
Event-driven: factura al completar una venta
Tu sistema emite un evento cuando se cierra una venta. Ese evento dispara la factura.
// types.ts
interface SaleCompletedEvent {
saleId: string;
customerId: string;
customerRuc: string;
customerName: string;
items: Array<{
description: string;
quantity: number;
unitPrice: number; // USD, sin ITBMS
}>;
completedAt: string;
}
interface ConteoInvoiceResponse {
invoice_id: string;
authorization_number: string;
cufe: string;
qr: string;
total: number;
status: "completed" | "pending" | "failed";
}
// invoice-handler.ts
const CONTEO_API_URL = "https://api.conteo.me/v1";
const ITBMS_RATE = 0.07;
function buildInvoicePayload(event: SaleCompletedEvent) {
const subtotal = event.items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
const itbms = parseFloat((subtotal * ITBMS_RATE).toFixed(2));
const total = parseFloat((subtotal + itbms).toFixed(2));
return {
receptor: {
ruc: event.customerRuc, // p. ej. "8-730-241"
nombre: event.customerName,
},
items: event.items.map((item) => ({
descripcion: item.description,
cantidad: item.quantity,
precio_unitario: item.unitPrice,
itbms: parseFloat((item.unitPrice * ITBMS_RATE).toFixed(2)),
})),
subtotal,
itbms,
total,
referencia_externa: event.saleId, // para idempotencia
};
}
export async function handleSaleCompleted(
event: SaleCompletedEvent
): Promise<ConteoInvoiceResponse> {
const payload = buildInvoicePayload(event);
const response = await fetch(`${CONTEO_API_URL}/invoices`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.CONTEO_API_KEY}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new InvoiceError(error.code, error.message, event.saleId);
}
const result: ConteoInvoiceResponse = await response.json();
// Persiste el resultado en tu base de datos
await db.sales.update(event.saleId, {
invoice_id: result.invoice_id,
invoice_status: result.status,
cufe: result.cufe,
authorization_number: result.authorization_number,
});
return result;
}
result.authorization_number es el número asignado por el SFEP. result.cufe es el identificador único de la factura en el SFEP. Guarda ambos. Para obtener el PDF del CAFE, haz una llamada separada al endpoint DownloadPDF de G-Force Gateway con los identificadores del documento. El PDF viene en base64 en el campo document. La referencia completa esta en docs.conteo.me.
Batch: facturación por lotes al final del día
Un job programado consulta los pedidos sin facturar y los envía en bloque.
// batch-invoicing.ts
interface Order {
id: string;
customerRuc: string;
customerName: string;
items: Array<{
description: string;
quantity: number;
unitPrice: number;
}>;
}
async function runDailyInvoiceBatch(): Promise<void> {
const pendingOrders: Order[] = await db.orders.findAll({
where: { invoiced: false, date: today() },
});
if (pendingOrders.length === 0) return;
const results = await Promise.allSettled(
pendingOrders.map((order) =>
handleSaleCompleted({
saleId: order.id,
customerId: order.id,
customerRuc: order.customerRuc,
customerName: order.customerName,
items: order.items,
completedAt: new Date().toISOString(),
})
)
);
const failed: string[] = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
const order = pendingOrders[i];
if (result.status === "fulfilled") {
await db.orders.update(order.id, { invoiced: true });
} else {
failed.push(order.id);
await db.invoiceErrors.insert({
order_id: order.id,
error_code: result.reason?.code ?? "UNKNOWN",
error_message: result.reason?.message,
created_at: new Date(),
});
}
}
if (failed.length > 0) {
await alertOncall(`Batch falló para ${failed.length} órdenes: ${failed.join(", ")}`);
}
}
Si el job termina con errores, necesitas alertas explícitas. Un batch silencioso que falla deja un día entero sin facturar.
Scheduled: facturas recurrentes
Para retainers y contratos mensuales: el cron corre el día acordado y genera las facturas sin intervención manual.
// recurring-invoicing.ts
interface Subscription {
id: string;
customerRuc: string;
customerName: string;
description: string;
monthlyAmount: number; // USD, sin ITBMS
billingDayOfMonth: number;
}
// Cron: "0 8 * * *" — corre cada día a las 8am
async function runRecurringInvoices(): Promise<void> {
const today = new Date().getDate();
const dueSubscriptions: Subscription[] = await db.subscriptions.findAll({
where: { active: true, billingDayOfMonth: today },
});
for (const sub of dueSubscriptions) {
const periodKey = `${sub.id}-${year()}-${month()}`; // p. ej. "sub_001-2026-04"
// Evita duplicados si el job corre dos veces
const existing = await db.invoices.findOne({ referencia_externa: periodKey });
if (existing) continue;
await handleSaleCompleted({
saleId: periodKey,
customerId: sub.id,
customerRuc: sub.customerRuc, // p. ej. "J-8-56789-1"
customerName: sub.customerName,
items: [
{
description: sub.description,
quantity: 1,
unitPrice: sub.monthlyAmount,
},
],
completedAt: new Date().toISOString(),
});
}
}
El campo referencia_externa con periodKey es la clave: si el job corre dos veces en el mismo mes, Conteo devuelve DUPLICATE_DOCUMENT en lugar de crear una segunda factura.
Recibir el resultado con webhooks
Para facturas donde el SFEP tarda en procesar, Conteo devuelve status: "pending" y notifica a tu endpoint cuando el documento está listo.
Payload de ejemplo:
{
"event": "invoice.completed",
"data": {
"invoice_id": "FE001-00142",
"authorization_number": "SFEP-2026-A1B2C3D4E5",
"cufe": "FE0120000249610000000142...",
"total": 160.50,
"receptor_ruc": "8-730-241"
}
}
Tu handler:
// webhook-handler.ts
import { createHmac } from "crypto";
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expected = createHmac("sha256", secret)
.update(payload)
.digest("hex");
return `sha256=${expected}` === signature;
}
export async function handleConteoWebhook(req: Request): Promise<Response> {
const rawBody = await req.text();
const signature = req.headers.get("x-conteo-signature") ?? "";
if (!verifyWebhookSignature(rawBody, signature, process.env.CONTEO_WEBHOOK_SECRET!)) {
return new Response("Unauthorized", { status: 401 });
}
const event = JSON.parse(rawBody);
if (event.event === "invoice.completed") {
const { invoice_id, authorization_number, cufe } = event.data;
// ON CONFLICT DO NOTHING — el mismo evento puede llegar dos veces
await db.invoices.upsert(
{ invoice_id },
{ status: "completed", authorization_number, cufe }
);
await notifyCustomer(invoice_id);
}
return new Response("OK", { status: 200 });
}
Siempre verifica el header x-conteo-signature. Conteo reintenta con backoff exponencial si tu endpoint no responde: 1 minuto, 5 minutos, 30 minutos, 2 horas. Haz tu handler idempotente para tolerar el mismo evento dos veces.
Manejo de errores
| Código | Causa | Qué hacer |
|---|---|---|
| INVALID_RUC | RUC del receptor con formato incorrecto o inexistente | Valida el formato antes de llamar a la API. Patrón: [tipo]-[número]-[dígito] (p. ej. 8-730-241). No llegues a la API con un RUC que no pasa validación local. |
| DUPLICATE_DOCUMENT | Ya existe una factura con ese referencia_externa | No reintentar. Consultar GET /v1/invoices/{id} para obtener el documento existente. |
| SFEP_UNAVAILABLE | El sistema del SFEP está en mantenimiento o caído | Reintentable. Backoff: 30 segundos, 2 minutos, 10 minutos. Si falla los tres intentos, mover a dead letter queue. |
| CERTIFICATE_EXPIRED | El certificado de emisor autorizado venció | No reintentable hasta renovar. Configurar alertas a 30 y 7 días antes del vencimiento. |
| INVALID_PAYLOAD | Campo requerido faltante o tipo incorrecto | No reintentable. Revisar el payload contra la documentación. Escalar a revisión manual. |
| RATE_LIMITED | Demasiadas llamadas por segundo | Reintentable. Esperar 1 segundo antes del próximo intento. En batch, limitar a 3-5 requests simultáneos. |
Implementación de retry con dead letter queue:
class InvoiceError extends Error {
constructor(
public code: string,
message: string,
public saleId: string
) {
super(message);
this.name = "InvoiceError";
}
isRetryable(): boolean {
return ["SFEP_UNAVAILABLE", "TIMEOUT", "RATE_LIMITED"].includes(this.code);
}
}
async function invoiceWithRetry(
event: SaleCompletedEvent,
maxAttempts = 3
): Promise<ConteoInvoiceResponse> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await handleSaleCompleted(event);
} catch (err) {
if (!(err instanceof InvoiceError)) throw err;
if (!err.isRetryable() || attempt === maxAttempts) {
await db.deadLetterQueue.insert({
payload: event,
error_code: err.code,
error_message: err.message,
created_at: new Date(),
});
throw err;
}
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("unreachable");
}
No silencies los errores de facturación
Un catch vacío o un log que nadie lee en producción es un agujero de compliance. Cada factura fallida que no se reintenta ni escala a revisión manual es una venta sin respaldo legal.
La dead letter queue puede ser una tabla en tu base de datos. Lo importante es que un job revise entradas con más de dos horas de antigüedad y alerte.
Integra la API de Conteo en tu pipeline
Documentación completa, ejemplos de código y soporte directo para desarrolladores que integran facturación electrónica en Panamá.
Ver la documentaciónPreguntas frecuentes
Preguntas frecuentes
- ¿Puedo emitir facturas en batch o la API solo acepta una a la vez?
- La API acepta documentos de uno en uno. Para batch, envía en secuencia con un pequeño delay entre llamadas. Puedes paralelizar con un pool de concurrencia limitada (3-5 requests simultáneos es razonable para no saturar el rate limit).
- ¿Cómo garantizo que no emito la misma factura dos veces si el sistema falla a mitad del proceso?
- Usa el campo referencia_externa con el ID único de tu venta u orden. Conteo detecta referencias duplicadas y devuelve DUPLICATE_DOCUMENT en lugar de crear otra factura. Guarda el invoice_id de Conteo en tu base de datos tan pronto como lo recibes, antes de hacer cualquier otra operación.
- ¿Qué pasa si el webhook no llega?
- Puedes consultar el estado de cualquier factura vía GET /v1/invoices/{invoice_id}. Un job de reconciliación que consulte facturas en estado pending cada hora es suficiente para no quedarte con registros huérfanos.
- ¿Cómo manejo el ITBMS para productos o servicios exentos?
- Incluye el campo itbms_rate con valor 0 para los items exentos. Algunos servicios profesionales y exportaciones tienen tasas distintas al 7% estándar. Consulta la tabla de tasas en la documentación para los códigos correctos.