AlchemyCode API docs

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, FormData y Blob globales.
  • 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 fetch nativo; 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.raw y timingSafeEqual.
  • Las descargas se hacen al recibir succeeded, con timeout.
  • Se monitorea 429, 5xx y jobs fallidos.