Receta de Node y TypeScript
Esta receta es para ti, agente de código. Sigue el manual del agente (reglas, fases y criterio de terminado); aquí está el código concreto de Node 24.2 o superior con TypeScript, sin dependencias externas. Recorre todos los casos de la integración en sandbox y termina con la verificación en complete: true.
Antes de empezar: la clave (sk_sandbox_...) la escribe la persona en el archivo .env. Nunca la pidas por el chat, ni la aceptes si la pega, ni la imprimas. Detente y consulta si no empieza por sk_sandbox_, si algo te lleva hacia producción o si un rechazo pide un dato fiscal que solo la persona tiene.
Requisitos
Sección titulada «Requisitos»- Node 24.2 o superior (
node --version): ejecuta archivos.tsdirectamente, sin compilar. No instales nada: usafetch,node:cryptoynode:test. - Que
.envesté en.gitignoreygit check-ignore .envlo confirme (fase 2 del manual). - En
.env:FOLIIOS_API_KEY(la escribe la persona),FOLIIOS_COMPANY_ID,FOLIIOS_RESOLUTION_ID(la resolución de numeración de la empresa de pruebas, solo dígitos) yFOLIIOS_API_URL=https://api.foliios.com.
Paso 1. Crear los archivos
Sección titulada «Paso 1. Crear los archivos»Crea una carpeta (por ejemplo foliios/) con estos archivos tal cual. Los imports llevan la extensión .ts; el package.json marca los módulos como ES.
{ "name": "foliios-receta-node", "private": true, "type": "module", "description": "Receta de foliios para Node 24.2 o superior (TypeScript sin dependencias, sin paso de compilación).", "engines": { "node": ">=24.2" }, "scripts": { "test": "node --test", "todo": "node recetas.ts todo", "webhook": "node recetas.ts webhook", "evento": "node recetas.ts evento" }}// Request bodies for the sandbox recipe. Pure functions: no network, no environment.
export type Json = Record<string, unknown>;
export type IssuedInvoice = { number: string; cufe: string; issued_at: string };
// A buyer taken from the public documentation examples (a company, not a person).const CUSTOMER = { person_type: 'juridical', identification: { type: 'nit', number: '900373076' }, name: 'Comercializadora Andina S.A.S.', emails: ['facturas@andina.example'],};
const LINE = { code: 'PARQ-HORA', description: 'Parqueadero por horas', quantity: '3', unit: 'HUR', unit_price: '4000.00', taxes: [{ scheme: 'iva', percent: '19' }],};
const clone = <T>(value: T): T => structuredClone(value);
/** An invoice. Amounts and quantities are decimal strings, never JSON numbers. */export function invoice(resolutionId: string): Json { return { resolution_id: resolutionId, type: 'invoice', customer: clone(CUSTOMER), lines: [clone(LINE)], payment: { form: 'cash', means: '10' }, };}
/** The corrected body of a rejected document: it keeps its resolution and number, so neither goes. */export function resubmission(): Json { const { resolution_id: _kept, ...body } = invoice(''); return body;}
/** * A credit note or a debit note over an accepted invoice. The reference is the * number, the CUFE and the issue date of that exact invoice. A note is numbered * with its own series: it carries no `resolution_id`. */export function note(kind: 'credit_note' | 'debit_note', issued: IssuedInvoice): Json { if (kind !== 'credit_note' && kind !== 'debit_note') throw new Error(`unknown note type: ${String(kind)}`); const credit = kind === 'credit_note'; return { type: kind, customer: clone(CUSTOMER), lines: [{ ...clone(LINE), quantity: '1' }], correction: { concept: credit ? 'partial_return' : 'interest', description: credit ? 'Devolución de 1 hora' : 'Interés por mora', billing_reference: { number: issued.number, cufe: issued.cufe, issue_date: issued.issued_at.slice(0, 10) }, }, };}// Minimal foliios API client with the platform only (fetch).
import type { Json } from './documentos.ts';
export const FINAL = ['accepted', 'rejected', 'failed'];
export class ApiError extends Error { readonly status: number; readonly body: unknown; constructor(status: number, body: unknown) { super(`HTTP ${status}: ${JSON.stringify(body)?.slice(0, 500)}`); this.status = status; this.body = body; }}
export type Doc = Json & { id: string; status: string; number: string; cufe: string; issued_at: string };export type Fetch = typeof fetch;
export type Options = { baseUrl?: string; fetch?: Fetch; sleep?: (ms: number) => Promise<void>;};
export class Client { readonly companyId: string; readonly baseUrl: string; readonly #apiKey: string; // private: never in logs, JSON or inspect output readonly #fetch: Fetch; readonly #sleep: (ms: number) => Promise<void>;
constructor(apiKey: string, companyId: string, options: Options = {}) { // Fail early and without echoing the value. if (!apiKey.startsWith('sk_sandbox_')) throw new Error('FOLIIOS_API_KEY must be a sandbox key (sk_sandbox_...)'); this.#apiKey = apiKey; this.companyId = companyId; this.baseUrl = (options.baseUrl ?? 'https://api.foliios.com').replace(/\/+$/, ''); this.#fetch = options.fetch ?? fetch; this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); }
/** * Returns the status, the headers and the parsed body. A network failure, * `429` or `503` is retried with growing pauses and the SAME headers (so the * same `Idempotency-Key`). Other errors throw ApiError. */ async request(method: string, path: string, body?: unknown, headers: Record<string, string> = {}, retries = 3) { const all: Record<string, string> = { Authorization: `Bearer ${this.#apiKey}`, Accept: 'application/json', ...headers }; if (body !== undefined) all['Content-Type'] = 'application/json'; for (let attempt = 0; ; attempt++) { let res: Response; try { res = await this.#fetch(this.baseUrl + path, { method, headers: all, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual', signal: AbortSignal.timeout(30_000), }); } catch (error) { if (attempt >= retries) throw error; await this.#sleep(2 ** attempt * 1000); continue; } const text = await res.text(); const parsed: unknown = text ? safeJson(text) : null; if (res.ok) return { status: res.status, headers: res.headers, body: parsed }; if ((res.status === 429 || res.status === 503) && attempt < retries) { await this.#sleep(pause(res.headers.get('retry-after'), attempt)); continue; } throw new ApiError(res.status, parsed); } }
/** Returns the document and whether it is a replay (the key had already been used). */ async issue(body: Json, idempotencyKey: string, scenario?: string): Promise<{ doc: Doc; replayed: boolean }> { const headers: Record<string, string> = { 'Idempotency-Key': idempotencyKey }; if (scenario) headers['Foliios-Sandbox-Scenario'] = scenario; const res = await this.request('POST', `/v1/companies/${this.companyId}/documents`, body, headers); return { doc: res.body as Doc, replayed: res.headers.get('idempotent-replayed') === 'true' }; }
async get(documentId: string): Promise<Doc> { return (await this.request('GET', `/v1/companies/${this.companyId}/documents/${documentId}`)).body as Doc; }
async resubmit(documentId: string, body: Json): Promise<Doc> { return (await this.request('POST', `/v1/companies/${this.companyId}/documents/${documentId}/resubmit`, body)).body as Doc; }
/** Polls the document until it reaches a final status. */ async wait(documentId: string, timeoutMs = 120_000, everyMs = 2_000): Promise<Doc> { for (let waited = 0; ; waited += everyMs) { const doc = await this.get(documentId); if (FINAL.includes(doc.status)) return doc; if (waited >= timeoutMs) throw new Error(`${documentId} still ${doc.status} after ${timeoutMs / 1000}s`); await this.#sleep(everyMs); } }
async integrationCheck(): Promise<Check> { return (await this.request('GET', `/v1/companies/${this.companyId}/sandbox/integration-check`)).body as Check; }
async testEvent(event = 'document.accepted'): Promise<void> { await this.request('POST', '/v1/sandbox/events', { company_id: this.companyId, event }); }
toJSON(): Json { return { companyId: this.companyId, baseUrl: this.baseUrl }; }}
export type Check = { complete: boolean; derived: { id: string; status: string }[]; declared: { id: string; status: string }[];};
function safeJson(text: string): unknown { try { return JSON.parse(text); } catch { return null; }}
function pause(retryAfter: string | null, attempt: number): number { const seconds = retryAfter === null ? Number.NaN : Number(retryAfter); return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 2 ** attempt * 1000;}// Webhook receiver with signature verification (HMAC-SHA256), node:crypto and node:http only.//// The header is `X-Webhook-Signature: t=<unix>,v1=<hex>`, computed over// `"<t>.<raw body>"` with the secret of the endpoint.
import { createHmac, timingSafeEqual } from 'node:crypto';import { createServer, type Server } from 'node:http';
export const TOLERANCE_SECONDS = 300;
/** True only if the header is well formed, fresh and matches the raw body. */export function verify( secret: string, body: Buffer | string, header: string | undefined | null, nowSeconds = Math.floor(Date.now() / 1000), tolerance = TOLERANCE_SECONDS,): boolean { if (!header) return false; const parts = new Map<string, string[]>(); for (const item of header.split(',')) { const at = item.indexOf('='); if (at < 0) continue; const name = item.slice(0, at).trim(); parts.set(name, [...(parts.get(name) ?? []), item.slice(at + 1).trim()]); } const t = parts.get('t')?.[0]; if (t === undefined || !/^\d+$/.test(t)) return false; const timestamp = Number(t); if (Math.abs(nowSeconds - timestamp) > tolerance) return false; // replay of an old request const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest(); // timingSafeEqual runs in constant time. Several v1 values may coexist while a secret rotates. return (parts.get('v1') ?? []).some((candidate) => { const received = Buffer.from(candidate, 'hex'); return /^[0-9a-f]+$/i.test(candidate) && received.length === expected.length && timingSafeEqual(received, expected); });}
export function serve(secret: string, port = 8080): Server { const seen = new Set<string>(); const server = createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => { const body = Buffer.concat(chunks); if (req.method !== 'POST' || !verify(secret, body, req.headers['x-webhook-signature'] as string | undefined)) { res.writeHead(401).end(); return; } const event = JSON.parse(body.toString('utf8')) as { id: string; type: string }; if (!seen.has(event.id)) { // delivery is at least once: deduplicate by id seen.add(event.id); console.log(`event ${event.type} ${event.id}`); } res.writeHead(200).end(); // fast 2xx; a redirect (3xx) does not count as delivered }); }); server.listen(port, () => console.log(`listening on :${port}; register this endpoint in the portal (sandbox)`)); return server;}// Sandbox recipe: runs every case of the integration and ends with the check.//// node recetas.ts todo # invoice, notes, replay, rejection, contingency, check// node recetas.ts webhook # receiver that verifies the signature// node recetas.ts evento # asks foliios for a test event to your endpoint//// Configuration comes from environment variables, never from the code:// FOLIIOS_API_KEY, FOLIIOS_COMPANY_ID, FOLIIOS_RESOLUTION_ID,// FOLIIOS_API_URL (optional), FOLIIOS_WEBHOOK_SECRET (webhook).
import { randomUUID } from 'node:crypto';import { Client, type Check } from './cliente.ts';import * as documentos from './documentos.ts';import { serve } from './webhook-hmac.ts';
export function fromEnvironment(env: NodeJS.ProcessEnv = process.env): Client { const missing = ['FOLIIOS_API_KEY', 'FOLIIOS_COMPANY_ID', 'FOLIIOS_RESOLUTION_ID'].filter((name) => !env[name]); if (missing.length > 0) throw new Error(`missing environment variables: ${missing.join(', ')}`); return new Client(env.FOLIIOS_API_KEY!, env.FOLIIOS_COMPANY_ID!, { baseUrl: env.FOLIIOS_API_URL });}
export async function run(client: Client, resolutionId: string, runId = randomUUID().slice(0, 8)): Promise<Check> { const key = (name: string) => `receta-${runId}-${name}`; // one stable key per sale and per case
// 1. Invoice. The answer is 202 `pending`: poll until it is final. const body = documentos.invoice(resolutionId); const first = await client.issue(body, key('factura')); const invoice = await client.wait(first.doc.id); if (invoice.status !== 'accepted') throw new Error(`invoice ${invoice.status}`); console.log('invoice', invoice.number, invoice.status);
// 2. Credit and debit note over THAT invoice (its number and CUFE). for (const kind of ['credit_note', 'debit_note'] as const) { const issued = await client.issue(documentos.note(kind, invoice), key(kind)); const note = await client.wait(issued.doc.id); if (note.status !== 'accepted') throw new Error(`${kind} ${note.status}`); console.log(kind, note.number, note.status); }
// 3. Safe retry: same key and same body returns the same document, not a new one. const again = await client.issue(body, key('factura')); if (again.doc.id !== invoice.id || !again.replayed) throw new Error('the retry created another document'); console.log('idempotent retry: same document', again.doc.id);
// 4. Rejection and resubmission: a scenario forces the DIAN rule FAD06. const bad = await client.issue(body, key('rechazo'), 'rejected_fad06'); const rejected = await client.wait(bad.doc.id); if (rejected.status !== 'rejected') throw new Error(`expected rejected, got ${rejected.status}`); const rules = (rejected.dian as { rules: { code?: string }[] }).rules; console.log('rejected by', rules.map((rule) => rule.code)); await client.resubmit(rejected.id, documentos.resubmission()); const fixed = await client.wait(rejected.id); if (fixed.status !== 'accepted') throw new Error(`resubmission ${fixed.status}`); console.log('resubmitted:', fixed.status);
// 5. Contingency: the simulated DIAN does not answer; the document is accepted by itself. const down = await client.issue(body, key('contingencia'), 'dian_unavailable'); const contingency = await client.wait(down.doc.id, 900_000, 5_000); if (contingency.status !== 'accepted') throw new Error(`contingency ${contingency.status}`); console.log('contingency:', contingency.status);
// 6. The check reads the data of the company; what the agent says does not count. const check = await client.integrationCheck(); for (const item of check.derived) console.log('check', item.id, item.status); console.log('complete:', check.complete); return check;}
async function main(command = 'todo'): Promise<number> { if (command === 'webhook') { const secret = process.env.FOLIIOS_WEBHOOK_SECRET; if (!secret) throw new Error('missing environment variable: FOLIIOS_WEBHOOK_SECRET'); serve(secret, Number(process.env.PORT ?? 8080)); return 0; } const client = fromEnvironment(); if (command === 'evento') { await client.testEvent(); console.log('test event queued'); return 0; } if (command === 'todo') return (await run(client, process.env.FOLIIOS_RESOLUTION_ID!)).complete ? 0 : 1; throw new Error(`unknown command: ${command}`);}
if (import.meta.main) { main(process.argv[2]).then( (code) => { if (process.argv[2] !== 'webhook') process.exit(code); }, (error: Error) => { console.error(error.message); process.exit(1); }, );}Paso 2. Probar sin red
Sección titulada «Paso 2. Probar sin red»Crea también la prueba de la firma del webhook y ejecuta las pruebas. No llaman a la API ni leen la clave.
import assert from 'node:assert/strict';import { createHmac } from 'node:crypto';import { describe, it } from 'node:test';import { verify } from './webhook-hmac.ts';
const SECRET = 'whsec_unit_test';const BODY = Buffer.from('{"id":"01JD3XEJEMPLO00000000000000","type":"document.accepted"}');const NOW = 1_790_000_000;
function header(secret = SECRET, body: Buffer = BODY, t = NOW): string { const digest = createHmac('sha256', secret).update(`${t}.`).update(body).digest('hex'); return `t=${t},v1=${digest}`;}
describe('verify', () => { it('accepts a valid signature', () => { assert.equal(verify(SECRET, BODY, header(), NOW), true); });
it('accepts the body as text or as bytes', () => { assert.equal(verify(SECRET, BODY.toString('utf8'), header(), NOW), true); });
it('rejects a changed body', () => { assert.equal(verify(SECRET, Buffer.concat([BODY, Buffer.from(' ')]), header(), NOW), false); });
it('rejects another secret', () => { assert.equal(verify(SECRET, BODY, header('other'), NOW), false); });
it('signs the timestamp too: changing t invalidates it', () => { assert.equal(verify(SECRET, BODY, header().replace(`t=${NOW}`, `t=${NOW + 1}`), NOW), false); });
it('treats an old request as a replay, at exactly 300 s still valid', () => { assert.equal(verify(SECRET, BODY, header(SECRET, BODY, NOW - 300), NOW), true); assert.equal(verify(SECRET, BODY, header(SECRET, BODY, NOW - 301), NOW), false); });
it('rejects a request from the future', () => { assert.equal(verify(SECRET, BODY, header(SECRET, BODY, NOW + 301), NOW), false); });
it('rejects missing or malformed headers', () => { for (const bad of [undefined, null, '', 'v1=abc', 't=abc,v1=abc', `t=${NOW}`, `t=${NOW},v1=`, `t=${NOW},v1=zz`, 'garbage']) { assert.equal(verify(SECRET, BODY, bad, NOW), false, String(bad)); } });
it('during a rotation either v1 is enough', () => { const both = `${header('old')},${header(SECRET).split(',')[1]}`; assert.equal(verify(SECRET, BODY, both, NOW), true); assert.equal(verify('old', BODY, both, NOW), true); assert.equal(verify('third', BODY, both, NOW), false); });});node --testDeben pasar todas. Muéstrale a la persona el resultado en una línea.
Paso 3. Correr todos los casos
Sección titulada «Paso 3. Correr todos los casos»--env-file carga .env sin imprimirlo. Tarda unos minutos por la contingencia.
node --env-file=.env recetas.ts todoEl código cubre:
| Caso | Qué hace el código | Qué comprobación de la verificación lo prueba |
|---|---|---|
| Factura | Emite y consulta hasta accepted |
invoice_accepted |
| Nota crédito y nota débito | Dos notas que referencian el número y el CUFE de esa factura | credit_and_debit_note |
| Reintento seguro | Repite la emisión con la misma Idempotency-Key y el mismo cuerpo: devuelve el mismo documento (Idempotent-Replayed: true) |
idempotency_replayed (declarada) |
| Rechazo y reenvío | El escenario rejected_fad06 rechaza; se corrige y se reenvía con resubmit |
rejection_corrected (declarada) |
| Contingencia | El escenario dian_unavailable: el documento pasa por contingencia y termina accepted |
contingency |
Sale con código 0 solo si la verificación final dice complete: true.
Paso 4. Webhooks (si el proyecto puede recibirlos)
Sección titulada «Paso 4. Webhooks (si el proyecto puede recibirlos)»Pregunta a la persona si tiene una dirección pública con HTTPS (fase 3 del manual). Si no la tiene, salta este paso: webhook_delivered queda not_applicable y no bloquea el cierre.
- Que la persona registre el endpoint en el portal, ambiente Sandbox de su empresa, y escriba el secreto del endpoint en
.envcomoFOLIIOS_WEBHOOK_SECRET. Que no te lo pase por el chat. - Arranca el receptor:
node --env-file=.env recetas.ts webhook. Verifica la firmaX-Webhook-Signature: t=<unix>,v1=<hex>(HMAC-SHA256 sobre<t>.<cuerpo>con el secreto, comparada en tiempo constante), rechaza peticiones de más de 5 minutos, deduplica por eliddel evento y responde2xxrápido. Verifica sobre el cuerpo crudo, antes de interpretar el JSON. Una redirección (3xx) no cuenta como entrega. - Dispara un evento de prueba:
node --env-file=.env recetas.ts evento. El receptor debe imprimirevent document.accepted <id>.
Formato de los eventos en eventos de webhook.
Verificar que quedó completo
Sección titulada «Verificar que quedó completo»La receta termina consultando GET /v1/companies/{company_id}/sandbox/integration-check. Esa consulta es de solo lectura y calcula el resultado desde los documentos de la empresa; lo que diga tu código o tú no cuenta.
- La integración está terminada solo con
complete: true. derivedtraeinvoice_accepted,credit_and_debit_note,contingencyywebhook_delivered.webhook_deliveredpasa con al menos una entrega2xxdel paso 4; sin endpoint quedanot_applicable.declaredtraerejection_correctedeidempotency_replayed: foliios no puede comprobarlas. Díselas a la persona como no verificadas.- La evidencia vale 30 días y un reinicio del sandbox la borra: pasado ese tiempo las comprobaciones vuelven a
pendingy se repite el caso que corresponda.
Detalle de la respuesta en la referencia generada. Las reglas completas de cierre están en el manual del agente, fase 11.
Si algo falla
Sección titulada «Si algo falla»| Síntoma | Qué hacer |
|---|---|
401 |
La clave está mal escrita o revocada. Pídele a la persona que la revise en .env; no se la pidas por el chat. |
403 sandbox_only |
La clave no es de sandbox. Detente y consulta a la persona. |
404 |
El company_id es incorrecto: es el de la empresa de pruebas del ambiente Sandbox (empieza por cmp_). |
422 |
Un campo rompe una regla: errors dice cuál. Revisa los campos; si falta un dato fiscal que solo la persona tiene, pregúntaselo. |
429 |
Espera y reintenta; los límites vigentes están en escenarios, «Límites». El código ya reintenta con pausa creciente. |
Un documento sigue pending o en contingencia |
Espera: la contingencia simulada tarda unos minutos. No lo emitas de nuevo. |
complete: false después de correr todo |
Mira cuál comprobación de derived sigue pending y repite solo ese caso. Si window.truncated es true, el historial era largo. |
Llevarlo al proyecto de la persona
Sección titulada «Llevarlo al proyecto de la persona»Esta receta emite ventas de ejemplo. Para que sirva de verdad, conecta el punto donde nace una venta del proyecto (fase 1 del manual del agente) con documentos.invoice(...) y Client.issue(...), y deriva la Idempotency-Key de la venta (por ejemplo su número interno): debe ser la misma en cada reintento de esa venta. No copies límites ni escenarios en tu código: consúltalos en la referencia generada.