AlchemyCode API docs

Integración en PHP

Guía completa con cURL nativo, sin Composer ni dependencias: cliente reutilizable, alta de empresa, carga de e.firma por multipart, consulta asíncrona con polling, descarga de archivos y verificación de webhooks.

Requisitos

  • PHP 8.1 o superior con ext-curl y ext-json (vienen en la mayoría de instalaciones).
  • Acceso HTTPS saliente a api.434alchemycode.com.
  • Una API key en variable de entorno; nunca en el código ni en el repositorio.
Shell
export ALCHEMY_API_KEY="ck_live_7f3a91c2m4p8q1w7x5n3v9b6t0"
export FIEL_PASSWORD="********"
php -v
php -m | grep -E 'curl|json'

Cliente reutilizable

Guarda este archivo como AlchemyClient.php. Concentra autenticación, parseo de problem+json, reintentos con backoff y el polling del job.

PHPAlchemyClient.php
<?php
declare(strict_types=1);

/**
 * Cliente mínimo de AlchemyCode API en PHP puro.
 * Requiere PHP 8.1+, ext-curl y ext-json. Sin Composer ni dependencias.
 */

final class AlchemyApiException extends RuntimeException
{
    public function __construct(
        public readonly string $errorCode,
        public readonly int $status,
        public readonly bool $retryable,
        public readonly string $requestId,
        string $message,
    ) {
        parent::__construct($message, $status);
    }
}

final class AlchemyClient
{
    private string $apiKey;
    private string $baseUrl;

    public function __construct(string $apiKey, string $baseUrl = 'https://api.434alchemycode.com/v1')
    {
        if ($apiKey === '') {
            throw new InvalidArgumentException('La API key está vacía.');
        }
        $this->apiKey = $apiKey;
        $this->baseUrl = rtrim($baseUrl, '/');
    }

    /**
     * Petición JSON con reintentos para 429/5xx y errores de red.
     */
    public function request(string $method, string $path, ?array $json = null, int $attempt = 1): array
    {
        $ch = curl_init($this->baseUrl . $path);
        $headers = [
            'Authorization: Bearer ' . $this->apiKey,
            'Accept: application/json',
        ];
        if ($json !== null) {
            $headers[] = 'Content-Type: application/json';
        }

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT        => 45,
        ]);
        if ($json !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($json, JSON_THROW_ON_ERROR));
        }

        $raw    = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $errno  = curl_errno($ch);
        $error  = curl_error($ch);
        curl_close($ch);

        if ($errno !== 0 || $raw === false) {
            if ($attempt <= 3) {
                usleep(500_000 * (2 ** ($attempt - 1)));
                return $this->request($method, $path, $json, $attempt + 1);
            }
            throw new RuntimeException("Error de red cURL {$errno}: {$error}", $errno);
        }

        $body = json_decode((string) $raw, true);
        if (!is_array($body)) {
            throw new RuntimeException("Respuesta no JSON (HTTP {$status}).");
        }

        if (($status === 429 || $status >= 500) && $attempt <= 3) {
            $retryAfter = (int) ($body['retry_after'] ?? 0);
            $wait       = $retryAfter > 0 ? $retryAfter : (int) (2 ** ($attempt - 1));
            sleep(max(1, $wait));
            return $this->request($method, $path, $json, $attempt + 1);
        }

        if ($status >= 400) {
            throw new AlchemyApiException(
                (string) ($body['code'] ?? 'UNKNOWN'),
                $status,
                (bool) ($body['retryable'] ?? false),
                (string) ($body['request_id'] ?? 'n/a'),
                (string) ($body['detail'] ?? $body['title'] ?? 'Error de AlchemyCode API'),
            );
        }

        return $body;
    }

    /**
     * Carga la e.firma de una empresa (multipart). No reintentar a ciegas:
     * un reintento con archivos consume tiempo y puede duplicar la carga.
     */
    public function uploadFiel(string $companyId, string $cerPath, string $keyPath, string $password): array
    {
        $ch = curl_init($this->baseUrl . "/companies/{$companyId}/credentials/sat");
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_HTTPHEADER     => [
                'Authorization: Bearer ' . $this->apiKey,
                'Accept: application/json',
            ],
            CURLOPT_POSTFIELDS => [
                'cer'      => new CURLFile($cerPath, 'application/x-x509-ca-cert', basename($cerPath)),
                'key'      => new CURLFile($keyPath, 'application/octet-stream', basename($keyPath)),
                'password' => $password,
            ],
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT        => 60,
        ]);

        $raw    = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $errno  = curl_errno($ch);
        $error  = curl_error($ch);
        curl_close($ch);

        if ($errno !== 0 || $raw === false) {
            throw new RuntimeException("Error de red cURL {$errno}: {$error}");
        }

        $body = json_decode((string) $raw, true);
        if ($status >= 400) {
            throw new AlchemyApiException(
                (string) ($body['code'] ?? 'UNKNOWN'),
                $status,
                (bool) ($body['retryable'] ?? false),
                (string) ($body['request_id'] ?? 'n/a'),
                (string) ($body['detail'] ?? 'Error al cargar la e.firma'),
            );
        }
        return $body;
    }

    /**
     * Polling con backoff suave: 2 s, 3 s, 5 s… hasta el timeout.
     */
    public function waitForJob(string $jobId, int $timeoutSeconds = 180): array
    {
        $deadline = time() + $timeoutSeconds;
        $interval = 2;

        while (time() < $deadline) {
            $job = $this->request('GET', '/jobs/' . $jobId);
            if (!in_array($job['status'], ['queued', 'running'], true)) {
                return $job;
            }
            sleep($interval);
            $interval = min($interval + 1, 5);
        }

        throw new RuntimeException("El job {$jobId} no terminó en {$timeoutSeconds} s.");
    }
}

Puntos de diseño que importan en producción:

  • Reintentos sólo para 429 y 5xx, con Retry-After cuando existe. Un 422 o un 403 no se reintenta.
  • CURLOPT_TIMEOUT de 45 s: suficiente para wait=true (25 s) y corto para no colgar workers.
  • json_encode con JSON_THROW_ON_ERROR: evita enviar cuerpos inválidos.
  • Multipart sin fijar Content-Type: cURL genera el boundary; fijarlo rompe la carga.
  • waitForJob con backoff suave: no satura el rate limit cuando el portal tarda.

Flujo completo

Este script crea (o recupera) la empresa, carga la e.firma, solicita la constancia, espera el job y descarga el PDF. Es el esqueleto de un proceso batch confiable.

PHPconsulta.php
<?php
declare(strict_types=1);

require __DIR__ . '/AlchemyClient.php';

$client = new AlchemyClient(getenv('ALCHEMY_API_KEY'));

try {
    // 1. Empresa (si ya existe, la API responde 409 COMPANY_DUPLICATE con el id).
    try {
        $company = $client->request('POST', '/companies', [
            'rfc'          => 'OMX2503255F9',
            'legal_name'   => 'OMNICONMX, S.A. de C.V.',
            'tax_regime'   => '601',
            'external_ref' => 'OMNICOM-001',
        ]);
        $companyId = $company['id'];
    } catch (AlchemyApiException $e) {
        if ($e->errorCode !== 'COMPANY_DUPLICATE') {
            throw $e;
        }
        // Recupera el id desde el listado por RFC.
        $list = $client->request('GET', '/companies?q=OMX2503255F9');
        $companyId = $list['data'][0]['id'];
    }
    echo "Empresa: {$companyId}\n";

    // 2. e.firma (sólo si la empresa no la tiene cargada).
    $credentials = $client->request('GET', "/companies/{$companyId}/credentials");
    if (($credentials['fiel']['configured'] ?? false) !== true) {
        $fiel = $client->uploadFiel(
            $companyId,
            __DIR__ . '/omnicom.cer',
            __DIR__ . '/omnicom.key',
            getenv('FIEL_PASSWORD'),
        );
        echo "e.firma: {$fiel['status']} hasta {$fiel['valid_to']}\n";
    }

    // 3. Consulta asíncrona con idempotencia.
    $idempotencyKey = bin2hex(random_bytes(16));
    $job = $client->request('POST', '/sat/constancias', [
        'company_id'  => $companyId,
        'include_pdf' => true,
        'wait'        => false,
    ]);
    echo "Job {$job['id']} en estado {$job['status']}\n";

    // 4. Espera el resultado.
    $job = $client->waitForJob($job['id'], 180);
    if ($job['status'] !== 'succeeded') {
        fwrite(STDERR, "Job {$job['status']}: " . json_encode($job['error']) . "\n");
        exit(1);
    }

    echo "RFC: {$job['result']['rfc']}\n";
    echo "Régimen: {$job['result']['regimen_fiscal']['descripcion']}\n";

    // 5. Descarga el PDF (la URL firmada expira en 15 minutos).
    foreach ($job['files'] as $file) {
        $ch = curl_init('https://api.434alchemycode.com' . $file['url']);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 60,
        ]);
        $pdf    = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        if ($status !== 200 || $pdf === false) {
            throw new RuntimeException("No se pudo descargar {$file['name']} (HTTP {$status}).");
        }
        $target = __DIR__ . '/' . $file['name'];
        file_put_contents($target, $pdf);
        echo "Archivo: {$target} (" . strlen($pdf) . " bytes)\n";
    }
} catch (AlchemyApiException $e) {
    fwrite(STDERR, sprintf(
        "Error %s (HTTP %d, request_id %s): %s\n",
        $e->errorCode,
        $e->status,
        $e->requestId,
        $e->getMessage(),
    ));
    exit(2);
}
El Idempotency-Key debe ser el mismo si reintentas la misma operación de negocio. En el ejemplo se genera uno nuevo por ejecución; en un worker persistente guárdalo junto al registro del job.

El objeto job completo, sus estados y la expiración de archivos se explican en Trabajos asíncronos.

Manejo de errores

Toda respuesta 4xx/5xx es application/problem+json. El campo code es el contrato; detail es para humanos. Este patrón cubre los casos típicos:

PHP
try {
    $result = $client->request('POST', '/sat/opiniones', ['company_id' => $companyId]);
} catch (AlchemyApiException $e) {
    switch ($e->errorCode) {
        case 'CREDENTIAL_MISSING':
        case 'FIEL_EXPIRED':
            // La empresa necesita credenciales: avisa en el panel, no reintentes.
            break;
        case 'QUOTA_EXCEEDED':
            // Sin unidades: espera el corte mensual o solicita ampliación.
            break;
        case 'PORTAL_UNAVAILABLE':
        case 'PROVIDER_TIMEOUT':
            // Reintenta más tarde; el job asíncrono ya reintenta solo.
            break;
        default:
            error_log("[{$e->errorCode}] {$e->getMessage()} request_id={$e->requestId}");
    }
    // Registra $e->requestId para soporte.
}
CódigoAcción en PHP
VALIDATION_ERRORRevisa $body['errors'] y corrige el campo.
AUTH_REQUIRED / API_KEY_INVALIDRevisa ALCHEMY_API_KEY; no reintentes.
SCOPE_FORBIDDENCrea una llave con el scope indicado en required_scope.
CREDENTIAL_MISSINGCarga e.firma o credenciales del portal; el detalle dice cuáles.
FIEL_EXPIREDCarga una e.firma vigente.
QUOTA_EXCEEDEDEspera el corte o solicita ampliación.
RATE_LIMITEDRespeta Retry-After; el cliente ya reintenta.
JOB_NOT_READYEl job sigue en curso; vuelve a consultarlo.
PORTAL_UNAVAILABLEEl portal no responde; reintenta con backoff.

Verificación de webhooks en PHP

El receptor debe leer el cuerpo crudo con php://input y comparar con hash_equals. No uses $_POST: pierde el cuerpo exacto y la firma deja de coincidir.

PHPwebhook.php
<?php
declare(strict_types=1);

$secret = getenv('ALCHEMY_WEBHOOK_SECRET');           // whsec_...
$raw    = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_ALCHEMY_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $chunk) {
    [$k, $v] = array_pad(explode('=', trim($chunk), 2), 2, '');
    $parts[$k] = $v;
}

$timestamp = (int) ($parts['t'] ?? 0);
$signature = (string) ($parts['v1'] ?? '');

if ($timestamp === 0 || $signature === '' || abs(time() - $timestamp) > 300) {
    http_response_code(400);
    exit('Firma inválida o expirada');
}

$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Firma incorrecta');
}

$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
// Encola y responde rápido: AlchemyCode reintenta si tardas más de 10 s.
if (($event['type'] ?? '') === 'job.succeeded') {
    error_log("Job {$event['data']['job_id']} listo");
}

http_response_code(200);
header('Content-Type: application/json');
echo '{"received":true}';

Los reintentos, la ventana de timestamp y el formato exacto de la firma están en Webhooks.

Checklist antes de producción

  • La API key vive en variable de entorno, no en el código.
  • El cliente distingue errores reintentables de errores de negocio.
  • Los jobs se persisten antes de responder al usuario.
  • Existe un límite de concurrencia (4–8) en los workers.
  • El webhook valida firma y timestamp sobre el cuerpo crudo.
  • Las URLs de archivos se descargan al recibir succeeded, no antes.
  • Hay alertas de cuota, jobs fallidos y latencia.