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-curlyext-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.
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.
<?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-Aftercuando existe. Un 422 o un 403 no se reintenta. CURLOPT_TIMEOUTde 45 s: suficiente parawait=true(25 s) y corto para no colgar workers.json_encodeconJSON_THROW_ON_ERROR: evita enviar cuerpos inválidos.- Multipart sin fijar
Content-Type: cURL genera el boundary; fijarlo rompe la carga. waitForJobcon 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.
<?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);
}
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:
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ódigo | Acción en PHP |
|---|---|
VALIDATION_ERROR | Revisa $body['errors'] y corrige el campo. |
AUTH_REQUIRED / API_KEY_INVALID | Revisa ALCHEMY_API_KEY; no reintentes. |
SCOPE_FORBIDDEN | Crea una llave con el scope indicado en required_scope. |
CREDENTIAL_MISSING | Carga e.firma o credenciales del portal; el detalle dice cuáles. |
FIEL_EXPIRED | Carga una e.firma vigente. |
QUOTA_EXCEEDED | Espera el corte o solicita ampliación. |
RATE_LIMITED | Respeta Retry-After; el cliente ya reintenta. |
JOB_NOT_READY | El job sigue en curso; vuelve a consultarlo. |
PORTAL_UNAVAILABLE | El 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.
<?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.