Integración en Node.js
Integración con fetch nativo (Node 18+): sin dependencias, con manejo de
problem+json, reintentos, carga de e.firma con FormData, polling y verificación de
webhooks con crypto.
Requisitos
- Node.js 20+ (recomendado) o 18.13+; usa
fetch,FormDatayBlobglobales. - API key en
process.env.ALCHEMY_API_KEY. - Si usas TypeScript,
@types/node; no se requiere SDK.
Shell
node --version # >= 20
export ALCHEMY_API_KEY="ck_live_7f3a91c2m4p8q1w7x5n3v9b6t0"
export FIEL_PASSWORD="********"
Cliente reutilizable
Node.jsAlchemyClient.js
// AlchemyClient.js · Node.js 20+ (fetch, FormData y Blob nativos)
const BASE = 'https://api.434alchemycode.com/v1';
export class AlchemyError extends Error {
constructor(message, { status, code, retryable, requestId } = {}) {
super(message);
this.name = 'AlchemyError';
this.status = status;
this.code = code;
this.retryable = retryable;
this.requestId = requestId;
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class AlchemyClient {
constructor(apiKey) {
if (!apiKey) throw new Error('Falta ALCHEMY_API_KEY');
this.apiKey = apiKey;
}
async request(method, path, { json, form } = {}, attempt = 1) {
const headers = {
Authorization: `Bearer ${this.apiKey}`,
Accept: 'application/json',
};
if (json !== undefined) headers['Content-Type'] = 'application/json';
const response = await fetch(BASE + path, {
method,
headers,
body: form ?? (json === undefined ? undefined : JSON.stringify(json)),
});
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if ((response.status === 429 || response.status >= 500) && attempt <= 3) {
const retryAfter = Number(response.headers.get('retry-after') ?? 0);
await sleep(retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** (attempt - 1));
return this.request(method, path, { json, form }, attempt + 1);
}
if (!response.ok) {
throw new AlchemyError(
`[${data.code ?? 'UNKNOWN'}] ${data.detail ?? response.statusText}`,
{
status: response.status,
code: data.code,
retryable: data.retryable ?? false,
requestId: data.request_id,
},
);
}
return data;
}
async uploadFiel(companyId, { cerPath, keyPath, password }) {
const { readFileSync } = await import('node:fs');
const form = new FormData();
form.append('cer', new Blob([readFileSync(cerPath)], { type: 'application/x-x509-ca-cert' }), 'omnicom.cer');
form.append('key', new Blob([readFileSync(keyPath)], { type: 'application/octet-stream' }), 'omnicom.key');
form.append('password', password);
// No fijes Content-Type: fetch genera el boundary de multipart.
return this.request('POST', `/companies/${companyId}/credentials/sat`, { form });
}
async waitForJob(jobId, { timeoutMs = 180_000, intervalMs = 2_000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const job = await this.request('GET', `/jobs/${jobId}`);
if (!['queued', 'running'].includes(job.status)) return job;
await sleep(intervalMs);
}
throw new Error(`El job ${jobId} no terminó en ${timeoutMs / 1000} s`);
}
}
Reintentar un POST con
FormData no siempre es seguro: el cuerpo puede haberse consumido. Envía Idempotency-Key y reintenta sólo peticiones JSON; para archivos, deja que el operador repita la carga.Flujo completo
Node.jsconsulta.mjs
import { writeFile } from 'node:fs/promises';
import { AlchemyClient, AlchemyError } from './AlchemyClient.js';
const client = new AlchemyClient(process.env.ALCHEMY_API_KEY);
async function main() {
let companyId;
try {
const company = await client.request('POST', '/companies', {
json: {
rfc: 'OMX2503255F9',
legal_name: 'OMNICONMX, S.A. de C.V.',
tax_regime: '601',
external_ref: 'OMNICOM-001',
},
});
companyId = company.id;
} catch (error) {
if (!(error instanceof AlchemyError) || error.code !== 'COMPANY_DUPLICATE') throw error;
const list = await client.request('GET', '/companies?q=OMX2503255F9');
companyId = list.data[0].id;
}
const credentials = await client.request('GET', `/companies/${companyId}/credentials`);
if (!credentials.fiel.configured) {
const fiel = await client.uploadFiel(companyId, {
cerPath: './omnicom.cer',
keyPath: './omnicom.key',
password: process.env.FIEL_PASSWORD,
});
console.log(`e.firma ${fiel.status}, vence ${fiel.valid_to}`);
}
const job = await client.request('POST', '/sat/constancias', {
json: { company_id: companyId, include_pdf: true, wait: false },
});
console.log(`Job ${job.id}: ${job.status}`);
const finished = await client.waitForJob(job.id, { timeoutMs: 180_000 });
if (finished.status !== 'succeeded') {
throw new Error(`Job ${finished.status}: ${JSON.stringify(finished.error)}`);
}
console.log(finished.result.rfc, finished.result.regimen_fiscal.descripcion);
for (const file of finished.files) {
const download = await fetch(`https://api.434alchemycode.com${file.url}`);
if (!download.ok) throw new Error(`Descarga ${file.name}: HTTP ${download.status}`);
const buffer = Buffer.from(await download.arrayBuffer());
await writeFile(file.name, buffer);
console.log(`${file.name}: ${buffer.length} bytes`);
}
}
main().catch((error) => {
console.error(error.message, { code: error.code, requestId: error.requestId });
process.exitCode = 1;
});
Manejo de errores
Node.js
try {
await client.request('POST', '/sat/opiniones', { json: { company_id: companyId } });
} catch (error) {
if (!(error instanceof AlchemyError)) throw error;
switch (error.code) {
case 'CREDENTIAL_MISSING':
case 'FIEL_EXPIRED':
// Falta configuración de la empresa: avisa, no reintentes.
break;
case 'QUOTA_EXCEEDED':
// Sin unidades: espera el corte o pide ampliación.
break;
case 'RATE_LIMITED':
case 'PORTAL_UNAVAILABLE':
case 'PROVIDER_TIMEOUT':
// Reintenta con backoff; el cliente ya lo hizo 3 veces.
break;
default:
console.error(error.code, error.message, error.requestId);
}
}Verificación de webhooks
Necesitas el cuerpo crudo. Con Express, registra el parser con express.raw sólo para
esa ruta, antes de express.json().
Node.jswebhook.mjs
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
const SECRET = process.env.ALCHEMY_WEBHOOK_SECRET;
app.post('/webhooks/alchemy', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('x-alchemy-signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
const timestamp = Number(parts.t);
const signature = parts.v1 ?? '';
if (!timestamp || !signature || Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) {
return res.status(400).json({ error: 'firma expirada' });
}
const expected = createHmac('sha256', SECRET).update(`${timestamp}.`).update(req.body).digest();
const received = Buffer.from(signature, 'hex');
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
return res.status(401).json({ error: 'firma inválida' });
}
const event = JSON.parse(req.body.toString('utf8'));
res.status(200).json({ received: true }); // responde rápido (< 2 s)
if (event.type === 'job.succeeded') {
console.log(`Job ${event.data.job_id} listo para ${event.data.service}`);
}
});
app.listen(3000, () => console.log('webhook en :3000'));
Checklist antes de producción
- Node 20+ y
fetchnativo; sin dependencias innecesarias. - La API key se lee del entorno y nunca se registra en logs.
- Los jobs se persisten en tu base antes de continuar.
- Hay un límite de concurrencia y una cola para picos.
- El webhook usa
express.rawytimingSafeEqual. - Las descargas se hacen al recibir
succeeded, con timeout. - Se monitorea
429,5xxy jobs fallidos.