La API JSON y los hooks: integrar FOSSBilling con el resto de tu stack

Por: Artiko
fossbillingapijsonhookseventoswebhookscsrfapi-keyrate-limitingintegraciones

La API JSON y los hooks: integrar FOSSBilling con el resto de tu stack

En el capítulo 13 montaste un módulo propio con su Service.php, sus controladores y sus tres clases Api/. Ese módulo ya expone endpoints sin que hayas escrito una sola línea de enrutado. Ahora falta la otra mitad del problema: FOSSBilling casi nunca es el único sistema de la casa. Hay un CRM que quiere saber cuándo se da de alta un cliente, un script de aprovisionamiento que necesita listar pedidos activos, un panel de métricas que consume facturación y un canal de Slack donde el equipo espera ver los pedidos nuevos.

Para eso hay exactamente dos mecanismos, y conviene no confundirlos. La API JSON es el canal de pull: tú llamas, FOSSBilling responde. Los hooks son el canal de push: FOSSBilling dispara un evento dentro del proceso y tu código reacciona. La API vive sobre HTTP y tiene autenticación, límites de tasa y códigos de error; los hooks no salen del proceso PHP y su punto débil es completamente distinto: no existen hasta que el cron los descubre.

Este capítulo tiene dos gotchas centrales que valen por sí solos el tiempo de lectura. Uno: la mayoría de errores de negocio de la API llegan con HTTP 200 y el error metido en el cuerpo. Si tu cliente comprueba el status, va a dar por buenas respuestas fallidas. Dos: un hook nuevo no se ejecuta hasta que php cron.php ha corrido al menos una vez después de escribirlo, porque los listeners se persisten en base de datos por reflexión, no se descubren en cada petición.

Todo lo que sigue está verificado contra el código de FOSSBilling 0.8.5, con las diferencias frente a la rama main marcadas donde existen.

Tres roles enrutables por HTTP y el cuarto que no lo es

La API se organiza por rol, y el rol determina a la vez la clase PHP que atiende la llamada y la identidad con la que se ejecuta.

RolRutaIdentidad internaAutenticación
guest/api/guest/...Model_Guestninguna
client/api/client/...Model_Clientsesión + CSRF, o HTTP Basic client:<api_token>
admin/api/admin/...Model_Adminsesión + CSRF, o HTTP Basic admin:<api_token>

La lista es cerrada y está escrita en una regex, en src/modules/Api/Controller/Client.php:

private function registerAllowedRouteRoles(): array
{
    return [
        'role'   => 'guest|client|admin',
        'class'  => '[a-zA-Z0-9_]+',
        'method' => '[a-zA-Z0-9_]+',
    ];
}

Existe un cuarto rol, system, pero no es enrutable por HTTP. Es el proxy interno $di['api_system'], que usan el cron y el receptor de IPN para llamarse a sí mismos con la identidad del “cron admin” (un Model_Admin con system_name = SYSTEM_CRON). No hay puerta de entrada externa hacia él: una petición a /api/system/lo-que-sea cae en la ruta comodín y devuelve Unknown API call :call con código 879, que sí se traduce a HTTP 400.

Esto tiene una consecuencia de diseño que merece la pena entender: cuando el cron ejecuta invoice_batch_generate, está invocando el mismo código que atiende /api/admin/invoice/batch_generate. No hay una “capa de servicio interna” y otra “capa de API”. La API es la capa de aplicación, y el HTTP es solo un transporte más.

Formato de URL /api/{rol}/{modulo}/{accion} y las rutas que registra el core

El patrón es siempre /api/{rol}/{modulo}/{accion}. Ejemplos reales: /api/admin/client/get_list, /api/client/profile/get, /api/guest/system/periods, /api/admin/currency/get_pairs.

El módulo Api registra literalmente cuatro rutas y ninguna más (Box\Mod\Api\Controller\Client::register()):

$app->post('/api/:role/:class/:method', 'post_method', $allowedRouteRoles, static::class);
$app->get('/api/:role/:class/:method',  'get_method',  $allowedRouteRoles, static::class);
$app->get('/api/:page',  'show_error', ['page' => '(.?)+'], static::class);
$app->post('/api/:page', 'show_error', ['page' => '(.?)+'], static::class);

Las dos últimas son la red de seguridad: cualquier cosa bajo /api/ que no case con el patrón de tres segmentos acaba en show_error y devuelve el código 879.

Si tienes la reescritura de URLs desactivada —o estás depurando un nginx que no reenvía bien— la forma directa también funciona, porque el front controller lee la ruta del parámetro _url: curl -s "https://billing.example.com/index.php?_url=/api/guest/system/periods". Si esa forma responde y la limpia no, el problema no está en FOSSBilling sino en las reglas del servidor web, que viste en el capítulo 2.

Solo GET y POST: qué pasa con PUT, DELETE y PATCH

El core enruta exclusivamente GET y POST. No hay rutas registradas para PUT, DELETE ni PATCH bajo /api/, así que una petición con esos verbos no encuentra ruta y termina en error, no en el endpoint que esperabas.

Esto confunde porque el wrapper JavaScript sí expone put, delete y patch. La propia documentación oficial pone el aviso al lado: “Use get or post for core FOSSBilling API routes”. Esos métodos existen para que puedas apuntar el wrapper a un servicio externo, no para el propio FOSSBilling.

La diferencia entre GET y POST es de dónde salen los parámetros:

VerboOrigen de los parámetrosFormatos aceptados
GET$app->getRequest()->query->all()query string
POSTgetPayload()->all()JSON y form-urlencoded

Gotcha: un cuerpo POST con Content-Type: application/json y JSON mal formado no da un 400 del servidor web ni un error genérico, sino Malformed JSON input: :error con código de aplicación 400, que sí mapea a HTTP 400. Es de los pocos casos en que el status HTTP te dice la verdad.

Como regla operativa: usa GET para lecturas (*_get, *_get_list, *_get_pairs) y POST para todo lo que muta estado. No es una imposición del framework —muchos endpoints de escritura aceptan GET— pero sí es lo que hacen los clientes del propio proyecto y lo que evita que una URL de escritura acabe en un log, en un historial o en un prefetch del navegador.

Autenticación por API key: HTTP Basic con usuario literal admin o client

Esta es la sección donde más gente pierde una tarde, así que va en negrita: el usuario de HTTP Basic es literalmente la cadena admin o la cadena client, no tu email. La contraseña es el API token.

API=https://billing.example.com/api
KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# Correcto
curl -s -u "admin:$KEY" "$API/admin/client/get_list?page=1&per_page=25"

# Incorrecto: devuelve error con codigo 203
curl -s -u "[email protected]:$KEY" "$API/admin/client/get_list"

El motivo está en _tryTokenLogin(), que hace un switch sobre el usuario Basic para decidir contra qué tabla busca el token:

case 'client':
    $model = $this->di['db']->findOne('Client', 'api_token = ? AND status = ?',
        [$password, \Model_Client::ACTIVE]);
    ...
case 'admin':
    $model = $this->di['db']->findOne('Admin',
        'api_token = ? AND status = ? AND (system_name IS NULL OR system_name != ?)',
        [$password, \Model_Admin::STATUS_ACTIVE, \Model_Admin::SYSTEM_CRON]);

Fíjate en la tercera condición de la consulta de admin: system_name IS NULL OR system_name != SYSTEM_CRON. El token del cron admin está explícitamente prohibido para autenticarse por API y devuelve el código 205. Si has copiado la clave desde la fila de admin que crea el instalador para las tareas programadas, no vas a entrar por mucho que la clave sea válida: hay que generar una clave de un staff normal.

Dos consecuencias más de este mecanismo:

Uno. Las llamadas autenticadas con API key no requieren token CSRF. El CSRF protege el flujo de navegador con sesión, no el de integración máquina a máquina. Si estás escribiendo un cliente y te aparece CSRF token invalid, es que tu cabecera Basic no está llegando y el core ha caído al camino de sesión.

Dos. Bajo FastCGI —o sea, PHP-FPM, que es el despliegue normal— la cabecera Authorization no llega a PHP salvo que el servidor web la propague. El .htaccess que trae el core lo hace con esta línea:

RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

Si tu instalación va sobre nginx, esa línea no aplica y tienes que asegurarte del equivalente en tu bloque fastcgi_param. El síntoma es inconfundible: la misma llamada funciona desde el servidor con el servidor embebido de PHP y falla con 201 detrás del web server real.

Hay tres endpoints que fuerzan autenticación por sesión aunque envíes Basic (shouldPreferSessionAuth()), y tiene sentido que sea así porque son los que manejan la propia clave: profile_api_key_get, profile_api_key_reset y profile_generate_api_key. No puedes rotar tu clave usando la clave que quieres rotar.

Dónde se genera y se rota la API key de staff y de cliente

RolObtenerRotarUbicación en el panel
Staffadmin/profile/generate_api_keyadmin/profile/api_key_resetPerfil del miembro de staff
Clienteclient/profile/api_key_getclient/profile/api_key_resetÁrea de cliente, perfil

La implementación del lado admin está en Profile\Api\Admin::generate_api_key(). Cada cambio de clave de staff dispara los eventos onBeforeAdminStaffApiKeyChange y onAfterAdminStaffApiKeyChange, que son un buen sitio para engancharse si quieres avisar a alguien cuando una credencial cambia. Tres consecuencias operativas del modelo:

  • Una cuenta de staff por integración, no una clave compartida. Los permisos se evalúan por grupo de staff en el paso 4 del despachador, así que una cuenta dedicada te permite darle a tu script de facturación acceso solo al módulo invoice y a nada más. La matriz de permisos la viste en el capítulo 9.
  • La clave de cliente tiene el alcance de ese cliente, no del sistema. Es la vía correcta para que el área privada de tu propia web consulte facturas de un usuario sin darle credenciales de admin a nada.
  • Rotar la clave invalida inmediatamente todo lo que la usaba: no hay periodo de gracia ni claves múltiples por cuenta.

Sesión de navegador y CSRF: los cuatro sitios donde se busca el token

Cuando la llamada viene de un navegador con sesión abierta —el propio panel de administración, o tu tema del área de cliente— entra en juego _checkCSRFToken(). Busca el token llamado CSRFToken en cuatro sitios, en este orden:

  1. El cuerpo JSON.
  2. Los datos de formulario (request->request).
  3. La query string.
  4. La cabecera X-CSRF-TOKEN.

El valor encontrado se compara con hash_equals() contra $session->get('csrf_token') y, si existe la cookie, también contra la cookie: es un patrón de doble submit. Cualquier fallo produce CSRF token invalid con código 403, que sí mapea a HTTP 403.

Gotcha de versión, importante si estás portando código: el nombre de la cookie cambia entre 0.8.5 y main.

VersiónCookie CSRFNotas
0.8.5csrf_tokenEs lo que documenta la web hoy
main posterior a 0.8.5fossbilling_csrfSe añadió FOSSBilling\Http\CookieNames con CSRF, SESSION, LOCALE y TIMEZONE; csrf_token queda como LEGACY_CSRF

El JavaScript del core ya lee las dos, primero la nueva y luego la antigua. Si escribes JS propio que lee la cookie a mano, replica ese fallback o tu tema se romperá al actualizar. La forma de no tener este problema en absoluto es no leer la cookie: en Twig tienes el global CSRFToken, y los helpers fb_api_form() lo adjuntan solos.

Filtros extra: require_referrer_header, allowed_ips y CSRFPrevention

Más allá de la autenticación, el bloque api de config.php tiene tres interruptores que afectan a todas las llamadas: require_referrer_header (por defecto false), allowed_ips (por defecto array vacío) y CSRFPrevention (por defecto true).

ClaveEfectoCódigo si falla
require_referrer_headerExige una cabecera Referer que empiece por SYSTEM_URL1004
allowed_ipsLista blanca; vacía significa sin restricción1002
CSRFPreventionActiva la comprobación de token en el flujo de sesión403

Los tres códigos de fallo mapean a HTTP 401 los dos primeros y a 403 el tercero, así que aquí sí puedes fiarte del status.

allowed_ips es la medida más efectiva de las tres si tu API solo la consume tu propia infraestructura: una lista blanca corta convierte una clave filtrada en un problema mucho menor. require_referrer_header solo tiene sentido si toda tu superficie es de navegador, porque un cliente HTTP no manda Referer y quedará fuera. Y CSRFPrevention no se toca: la propia configuración de ejemplo advierte que “Disabling this is highly discouraged and opens your instance to a known vulnerability.” El resto del endurecimiento lo verás en el capítulo 16.

Este es el árbol completo de decisión, con los códigos que produce cada rama:

flowchart TD
    A["Peticion a /api/rol/modulo/accion"] --> B{"Hay cabecera Basic?"}
    B -- No --> S["Camino de sesion"]
    B -- Si --> U{"Usuario es admin o client?"}
    U -- No --> E203["Codigo 203 -> HTTP 401"]
    U -- Si --> P{"Password Basic vacia?"}
    P -- Si --> E206["Codigo 206 -> HTTP 401"]
    P -- No --> T{"Token valido y cuenta activa?"}
    T -- No --> E204["Codigo 204 cliente o 205 admin -> HTTP 401"]
    T -- Si --> CR{"Es el cron admin?"}
    CR -- Si --> E205["Codigo 205 -> HTTP 401"]
    CR -- No --> IP
    S --> SS{"Sesion valida del rol pedido?"}
    SS -- No --> E201["Codigo 201 -> HTTP 401"]
    SS -- Si --> CS{"CSRF valido?"}
    CS -- No --> E403["Codigo 403 -> HTTP 403"]
    CS -- Si --> IP
    IP{"IP en allowed_ips?"} -- No --> E1002["Codigo 1002 -> HTTP 401"]
    IP -- Si --> RF{"Referer exigido y correcto?"}
    RF -- No --> E1004["Codigo 1004 -> HTTP 401"]
    RF -- Si --> RL["checkRateLimit"]
    RL --> D["Dispatcher::dispatch"]

El formato de respuesta y el mapeo de código de aplicación a status HTTP

Todas las respuestas las genera FOSSBilling\Http\ApiResponseFactory, y todas llevan las mismas cabeceras: Content-Type: application/json, Cache-Control: no-cache, must-revalidate y un Expires: Mon, 26 Jul 1997 05:00:00 GMT que lleva ahí desde tiempos de BoxBilling.

El cuerpo tiene siempre dos claves, y siempre las dos: en el éxito viaja result y error vale null; en el fallo es al revés.

{ "result": { }, "error": null }
{ "result": null, "error": { "message": "Error description", "code": 123 } }

El status HTTP se decide en ApiResponseFactory::getStatusCode(), y la tabla es literal:

Código de aplicaciónHTTP
201, 202, 203, 204, 205, 206, 1002, 1004401 Unauthorized
403403 Forbidden
740404 Not Found
429429 Too Many Requests
503503 Service Unavailable
701, 879400 Bad Request
cualquier otro, incluidos los no numéricos200 OK
flowchart TD
    A["Excepcion con codigo de aplicacion"] --> B{"Codigo"}
    B -->|201 a 206| C["HTTP 401"]
    B -->|1002 o 1004| C
    B -->|403| D["HTTP 403"]
    B -->|740| E["HTTP 404"]
    B -->|429| F["HTTP 429 mas cabecera Retry-After"]
    B -->|503| G["HTTP 503"]
    B -->|701 o 879| H["HTTP 400"]
    B -->|Sin codigo| I["Se usa 9999"]
    I --> J
    B -->|Cualquier otro| J["HTTP 200 OK con error distinto de null"]

Cuando la excepción no trae código, se usa 9999, que cae en la rama por defecto. Y las excepciones de límite de tasa añaden además la cabecera Retry-After con los segundos que faltan.

Por qué la mayoría de errores llegan con HTTP 200 y cómo comprobarlos bien

La rama por defecto de esa tabla no es un descuido, es el comportamiento normal: la inmensa mayoría de los errores de negocio de FOSSBilling —cliente no encontrado, factura ya pagada, dominio no disponible, TLD en uso, parámetro obligatorio ausente— lanzan InformationException con códigos que no están en la lista, o directamente sin código. Todos ellos salen con HTTP 200 y error != null.

Regla dura para cualquier integrador: comprueba el campo error, nunca el status. Un cliente que hace if (response.ok) va a tratar como éxito una respuesta que dice literalmente que el cliente no existe.

En bash, la comprobación correcta:

resp=$(curl -s -u "admin:$KEY" "$API/admin/client/get?id=999999")
echo "$resp" | jq -e '.error == null' >/dev/null || {
  echo "ERROR $(echo "$resp" | jq -r '.error.code'): $(echo "$resp" | jq -r '.error.message')"
  exit 1
}

Y si quieres ver a la vez el status real y el cuerpo, para diagnosticar cuál de las dos capas está fallando:

curl -s -o /tmp/out.json -w 'HTTP %{http_code}\n' -u "admin:$KEY" "$API/admin/currency/get_default"
jq . /tmp/out.json

En PHP, el patrón mínimo de un cliente correcto:

function fbApi(string $base, string $role, string $key, string $endpoint, array $params = []): mixed
{
    $ch = curl_init("$base/api/$role/$endpoint");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPAUTH       => CURLAUTH_BASIC,
        CURLOPT_USERPWD        => "$role:$key",   // usuario literal admin o client
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS     => json_encode($params, JSON_THROW_ON_ERROR),
        CURLOPT_TIMEOUT        => 30,
    ]);
    $body = curl_exec($ch);
    curl_close($ch);

    $decoded = json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR);

    // El status HTTP NO es la fuente de verdad. Lo es el campo error.
    if ($decoded['error'] !== null) {
        throw new RuntimeException($decoded['error']['message'], (int) ($decoded['error']['code'] ?? 9999));
    }

    return $decoded['result'];
}

Esa es la forma del cliente que usa la suite de pruebas del propio proyecto: tests/Helpers/Api.php hace CURLOPT_USERPWD => "$role:$apiKey" con CURLAUTH_BASIC y deduce el rol del prefijo del endpoint.

Catálogo de códigos de error del core, del 201 al 9999

Estos códigos vienen del núcleo y son estables entre módulos. Los de los adaptadores de pago, servidores y registradores son otra numeración distinta, que viste en los capítulos 10 y 11.

CódigoSignificadoDónde se origina
201Authentication Failed: sin sesión válida o falta PHP_AUTH_USERControlador de la API
202Falta PHP_AUTH_PWControlador de la API
203El usuario Basic no coincide con el rol de la URL_tryTokenLogin()
204Token de cliente inválido o cliente no activo_tryTokenLogin()
205Token de admin inválido, admin inactivo, o es el cron admin_tryTokenLogin()
206Contraseña Basic vacíaControlador de la API
252Aviso del directorio de extensiones sobre actualizaciónExtensionManager
400JSON de entrada malformadoControlador de la API
403CSRF token invalid, o falta de permiso de staffCSRF / Staff::hasPermission()
429Límite de peticiones superadoRateLimiter
503Mantenimiento activo o finalización de actualización pendienteBox_App / UpdateFinalization
701Unknown API call :call: rol no válidoDespachador
710Method :method must contain underscoreDispatcher::dispatch() paso 1
714Invalid module nameDespachador
715FOSSBilling module :mod is not installed/activatedDespachador paso 3
725You do not have access to the :mod moduleDespachador paso 4
730La clase Api no extiende FOSSBilling\Api\AbstractApiDespachador paso 6
740:type API call :method does not exist in module :moduleDespachador pasos 5 y 8
746Error del directorio de extensionesExtensionManager
879Unknown API call :call: ruta bajo /api/ no reconocidaRuta comodín
1002Unauthorized IPFiltro allowed_ips
1004Invalid request. Make sure request origin is :fromFiltro de Referer
5897Falta el manifest.json del móduloFOSSBilling\Module
5898El módulo no tiene clase Service.phpFOSSBilling\Module
9999Fallback cuando la excepción no trae códigoApiResponseFactory

Un detalle útil de la familia 7xx: 710, 715, 725, 730 y 740 son errores tuyos de integración, no del sistema. Cada uno señala un paso concreto del despachador, así que el número te dice exactamente dónde se rompió la resolución de la llamada. El 740, además, es el único que llega con HTTP 404, así que un 404 en la API significa siempre “ese método no existe”, nunca “esa URL no existe” (eso es el 879 con 400).

Paginación: page, per_page y el techo de 500

Todos los endpoints *_get_list aceptan los mismos dos parámetros, definidos en FOSSBilling\PaginationOptions:

public const int MAX_PER_PAGE     = 500;
public const int DEFAULT_PER_PAGE = 100;
ParámetroPor defectoMáximoComportamiento fuera de rango
page1sin techopage < 1 lanza “Page number (page) must be a positive integer.”
per_page100500per_page > 500 lanza “The number of items per page (per_page) is too large. Please specify a smaller number.”

Gotcha: un valor no entero no da error, se ignora en silencio y se usa el valor por defecto. La validación es filter_var(..., FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE): per_page=todos no falla, simplemente te devuelve 100 registros. Un entero válido pero fuera de rango sí lanza InformationException (y, por lo dicho arriba, llega con HTTP 200).

La forma de la respuesta paginada es idéntica en todos los endpoints: dentro de result vienen pages, page, per_page, total y list, con pages a 0 cuando el total es 0.

{ "result": { "pages": 3, "page": 1, "per_page": 25, "total": 63, "list": [ { "id": 1 } ] }, "error": null }

Un recorrido completo se escribe con pages, no adivinando cuándo list viene vacía:

page=1
while :; do
  resp=$(curl -s -u "admin:$KEY" "$API/admin/client/get_list?page=$page&per_page=500")
  echo "$resp" | jq -e '.error == null' >/dev/null || { echo "$resp" | jq -r '.error.message'; break; }
  echo "$resp" | jq -c '.result.list[]'
  [ "$page" -ge "$(echo "$resp" | jq -r '.result.pages')" ] && break
  page=$((page + 1))
done

Con 500 por página vas a chocar antes con el límite de tasa que con el de paginación: 1000 peticiones por hora contra 500 registros cada una son 500.000 filas, más que suficiente para una sincronización nocturna.

Rate limiting: las políticas, la selección por llamada y la cabecera Retry-After

El limitador usa symfony/rate-limiter con caché de sistema de archivos en PATH_CACHE, namespace rate_limit. Se configura en el bloque rate_limiter de config.php, con tres claves: enabled, whitelist_ips y policies, donde cada política se sobreescribe con la forma ['policy' => 'fixed_window', 'limit' => 5, 'interval' => '1 hour'].

Las políticas que afectan directamente a la API son cuatro:

PolíticaTipoLímiteIntervaloCuándo se aplica
api_guesttoken_bucket10060 segundosCualquier llamada con rol guest
api_authenticated_accounttoken_bucket10001 horaSesión autenticada; sujeto client:<id> o admin:<id>
api_authenticated_iptoken_bucket10001 horaEl resto de llamadas autenticadas; sujeto = la IP
api_loginfixed_window101 horaLos endpoints staff_login y client_login

La selección la hace Api\Controller\Client::checkRateLimit() en ese orden exacto: primero mira si el método es staff_login o client_login y aplica api_login; después, si el rol es guest, aplica api_guest; después, si hay sesión autenticada, aplica api_authenticated_account con el sujeto por identidad; y en cualquier otro caso, api_authenticated_ip con la IP como sujeto.

Este último punto es el que muerde en producción. Una integración que autentica con API key no tiene sesión, así que cae en api_authenticated_ip: el cupo de 1000 por hora es por IP, no por clave. Tres servicios distintos corriendo en el mismo VPS comparten el mismo cubo. Y si estás detrás de un proxy inverso mal declarado, todos tus clientes comparten la IP del proxy y se agotan el cupo entre ellos; la configuración de trusted_proxies está en el capítulo 3 y es un prerrequisito para que el limitador funcione con sentido.

Cuando se agota el cupo, la respuesta trae código 429, HTTP 429 y la cabecera Retry-After con los segundos restantes; un cliente correcto la lee (curl -D para volcar cabeceras) y espera ese tiempo antes de reintentar, en vez de aplicar un backoff inventado.

La salida rápida para una integración interna es meter la IP del consumidor en rate_limiter.whitelist_ips. La salida ordenada es respetar Retry-After y espaciar el trabajo. La salida mala es subir los límites globales, porque los mismos cubos protegen el login y el alta de clientes contra ataques.

El despachador por dentro: los diez pasos de Dispatcher::dispatch()

Toda llamada, venga de HTTP, del cron, de un IPN o de una plantilla Twig, termina en el mismo sitio: FOSSBilling\Api\Dispatcher::dispatch($identity, 'modulo_metodo', $data). Estos son los diez pasos, en orden, con el código que produce cada fallo:

  1. El nombre del método debe contener un guion bajo. Si no, código 710.
  2. Se parte por el primer guion bajo: $mod = strtolower(primer_segmento), el resto es el método. Los guiones bajos siguientes se conservan, así que client_get_list es módulo client, método get_list.
  3. Se comprueba que la extensión esté activa. Si no, código 715.
  4. Si el rol es admin, se comprueba Staff::hasPermission($identity, $mod). Si no, código 725.
  5. Se construye el nombre de clase '\Box\Mod\' . ucfirst($mod) . '\Api\\' . ucfirst($role). Si no existe, código 740.
  6. Se valida que la clase extienda AbstractApi. Si no, código 730.
  7. Se inyectan di, mod, identity, ip y el Service del módulo en la instancia.
  8. Se comprueba que el método exista, o que la clase tenga __call. Si no, código 740.
  9. validateRequiredParams() evalúa el atributo #[RequiredParams].
  10. normalizeArguments() ajusta los argumentos a la firma real del método.

El paso 10 explica un detalle que desconcierta al escribir endpoints: si tu método no declara parámetros, el despachador no le pasa el array de datos; y si el primero es opcional y solo hay [[]], respeta el valor por defecto. Por eso conviven en el core firmas como public function get_list(array $data): array, public function message() y public function mis_notas(array $data = []): array, todas válidas.

La secuencia completa de una llamada con clave de API, desde el curl hasta el JSON:

sequenceDiagram
    participant CU as curl -u admin KEY
    participant NX as nginx mas PHP-FPM
    participant IX as src/index.php
    participant AC as Box_AppClient con API_MODE
    participant API as Api Controller Client
    participant DI as Api Dispatcher
    participant MOD as Box Mod Client Api Admin
    participant RF as ApiResponseFactory

    CU->>NX: GET /api/admin/client/get_list
    NX->>IX: FastCGI mas HTTP_AUTHORIZATION propagado
    IX->>AC: primer segmento es api -> define API_MODE
    Note over AC: ini_set display_errors 0 para no romper el JSON
    AC->>API: ruta /api/:role/:class/:method
    API->>API: _tryTokenLogin busca por api_token
    API->>API: _checkCSRFToken se omite con Basic
    API->>API: checkRateLimit elige api_authenticated_ip
    API->>DI: dispatch identity client_get_list data
    DI->>MOD: get_list data tras validar permisos y parametros
    MOD-->>DI: array paginado
    DI-->>API: resultado
    API->>RF: create resultado
    RF-->>CU: JSON result mas error null con HTTP 200

Escribir tus propios endpoints con AbstractApi y #[RequiredParams]

Un endpoint nuevo es un método público en Api/Admin.php, Api/Client.php o Api/Guest.php de tu módulo. No hay que registrar nada: el nombre del método es el nombre del endpoint, y la clase determina el rol.

AbstractApi te da estas piezas:

MiembroContenido
$this->di y getDi()El contenedor Pimple completo
getService()La instancia de Service.php de tu propio módulo
getMod()El objeto FOSSBilling\Module
getIdentity()Model_Admin, Model_Client o Model_Guest según el rol
getIp()La IP del llamante
checkPermissions(string $module, ?string $key = null, mixed $constraint = null)Atajo a Staff que pasa la identidad, por lo que funciona también desde cron e IPN

Ese último matiz importa: si compruebas permisos con $this->di['mod_service']('Staff')->checkPermissionsAndThrowException(...) sin identidad, el mismo código puede comportarse distinto cuando lo llama el cron. checkPermissions() de AbstractApi no tiene ese problema.

La validación de parámetros es declarativa, con el atributo FOSSBilling\Validation\Api\RequiredParams:

<?php

declare(strict_types=1);

namespace Box\Mod\Miextension\Api;

use FOSSBilling\PaginationOptions;
use FOSSBilling\Validation\Api\RequiredParams;

class Admin extends \FOSSBilling\Api\AbstractApi
{
    /** GET|POST /api/admin/miextension/get_list */
    public function get_list(array $data): array
    {
        $this->checkPermissions('miextension', 'view');
        $sql = 'SELECT id, client_id, title, created_at FROM mod_miextension_note ORDER BY id DESC';

        return $this->getDi()['pager']->getPaginatedResultSet($sql, [], PaginationOptions::fromArray($data));
    }

    /** POST /api/admin/miextension/create */
    #[RequiredParams(['title' => 'Falta el titulo'])]
    public function create(array $data): int
    {
        $this->checkPermissions('miextension', 'manage');

        $this->getDi()['db']->exec(
            'INSERT INTO mod_miextension_note (title, body, created_at, updated_at) VALUES (:t, :b, NOW(), NOW())',
            ['t' => $data['title'], 'b' => $data['body'] ?? null]
        );

        return (int) $this->getDi()['db']->getCell('SELECT LAST_INSERT_ID()');
    }
}

Dispatcher::validateRequiredParams() lanza InformationException con tu mensaje si el parámetro falta, si es cadena vacía tras trim(), o si es empty y no numérico. Esa última condición es la que salva el caso id=0: un cero numérico no se considera ausente.

Para listados nuevos usa paginateDoctrineQuery() en lugar de getPaginatedResultSet(); el AGENTS.md del repositorio lo dice explícitamente. El ejemplo de arriba usa la vía legada porque el módulo del capítulo 13 crea su tabla con SQL crudo.

La superficie guest: catálogo completo de endpoints públicos y sus riesgos

Este es el punto de seguridad más importante del capítulo. Cualquier método público de Api/Guest.php es accesible desde Internet sin autenticación. El README del example-module oficial no se anda con rodeos:

“Don’t provide confidential data over these endpoints. Anybody over the internet will be able to access these information, including bots.”

Esta es la superficie que expone el core en 0.8.5, extraída de grep 'public function' src/modules/*/Api/Guest.php:

MóduloEndpoints bajo /api/guest/<modulo>/
Antispamrecaptcha
Cartget, reset, set_currency, get_currency, apply_promo, remove_promo, remove_item, add_item
Clientcreate, login, reset_password, update_password, required, custom_fields, is_email_validation_required
Cookieconsentmessage
Cronrun
Currencyget_pairs, get, format
Extensionis_on, settings, languages
Formbuilderget
Invoiceget, gateways, payment, funds_enabled, pdf
Newsget_list, get
Productget_list, get_pairs, get, category_get_list, category_get_pairs
Serviceapikeycheck
Servicedomaintlds, pricing, check, can_be_transferred
Servicehostingfree_tlds
Servicelicensecheck
Stafflogin, update_password, passwordreset
Supportticket_create, ticket_get, ticket_close, ticket_reply, guest_tickets_enabled, public_tickets_enabled, helpdesk_get_pairs, kb_enabled, kb_suggestions_enabled, kb_article_views_enabled, kb_article_get_list, kb_article_get, kb_category_get_list, kb_category_get
Systemcompany, default_country, countries, param, periods, phone_codes, period_title, paginator, current_url, template_exists, locale, timezones, get_pending_messages

Estos endpoints son también la vía legítima para construir un frontal propio: un catálogo de productos en Astro o Next puede consumir guest/product/get_list y guest/currency/get_pairs sin credenciales ninguna.

Tres cambios de versión que rompen integraciones existentes:

Uno. 0.8.0 eliminó guest/system/version. Ya no se divulga la versión a peticiones no autenticadas, y en Twig guest.system_version deja de funcionar. Es un endurecimiento deliberado: la versión exacta es información de reconocimiento. Si tu monitorización consultaba ese endpoint, hay que moverla a una llamada admin autenticada.

Dos. En la misma 0.8.0, guest/system/phone_codes ya no acepta el parámetro country, y guest/support/ticket_create pasó a exigir name, email, subject y message vía RequiredParams. Un formulario de contacto propio que solo enviaba email y message empezó a fallar en esa versión.

Tres. 0.8.2 hizo obligatorio el parámetro hash en guest/cron/run. La URL sin hash devuelve 403. El valor se consulta y se regenera en System → Cron del panel. Si disparabas el cron con un curl a esa URL desde un monitor externo, hay que actualizar la URL; y si el hash se regeneró, la llamada silenciosamente dejó de ejecutar el cron, con todo lo que eso arrastra: facturas sin generar, pedidos sin suspender, correo sin enviar y —volveremos a ello enseguida— hooks sin registrar.

Consumir la API desde Twig y desde el wrapper JavaScript

Hay dos formas de llamar a la API sin escribir un cliente HTTP, y las dos son las que usa el propio panel.

Desde Twig, sin HTTP

Los proxies guest, client y admin están inyectados como globals de Twig. guest está siempre; client y admin son null si no hay sesión del rol correspondiente.

{{ admin.support_ticket_get_list({ 'status': 'active' }) }}
{{ client.order_get({ 'id': 1 }) }}
{{ guest.currency_get_pairs() }}
{{ admin.extension_config_get({ 'ext': 'mod_seo' }) }}

La sintaxis es {{ rol.modulo_endpoint(parametros) }}, con el mismo nombre modulo_metodo que recibe el despachador. Esto no es una petición HTTP: se resuelve en PHP dentro del mismo request, no pasa por el router, no consume límite de tasa y no necesita CSRF. Es la vía correcta para renderizar datos en una plantilla; el capítulo 15 entra en el sistema de plantillas completo.

Desde el navegador, con el wrapper JS

El fuente vive en frontend/core/api.ts y se compila a src/public/assets/js/api.js. Expone window.FOSSBilling.api, con alias API:

API.{admin|client|guest}.{get|post|put|delete|patch}(endpoint, params, onSuccess, onError, showSpinner)
ParámetroDescripción
endpointRelativo al rol, por ejemplo "client/get_list"
paramsObjeto con los parámetros
onSuccessCallback con el result ya decodificado
onErrorCallback con { message, code }
showSpinnerBooleano, por defecto true; usa la clase CSS .spinner-border

El timeout por defecto es de 30000 ms. Para cargarlo en un tema propio:

{{ 'js/fossbilling.js'|public_asset_url|script_tag }}
{{ 'js/api.js'|public_asset_url|script_tag }}

Y si tu tema no reutiliza el JS de los temas incluidos, hay que inicializar los bindings a mano con API._apiForm(); y API._apiLink(); dentro de un DOMContentLoaded, o los helpers Twig de la sección siguiente no harán nada.

Los helpers Twig que evitan escribir JS

FOSSBilling\Twig\Extension\ApiExtension registra el filtro |api_url y las funciones fb_api, fb_api_form y fb_api_link. Generan el atributo data-fb-api que el wrapper intercepta.

OpciónAplica aEfecto
messageformularios y enlacesToast de éxito
redirectformularios y enlacesRedirige a la URL tras el éxito
reloadformularios y enlacesRecarga la página
callbackformularios y enlacesNombre de una función global de window que recibe el resultado
paramsenlacesParámetros extra fusionados en la petición
modalenlacestype entre confirm, danger o prompt, más title, label y key
<form action="{{ 'profile/update'|api_url }}" {{ fb_api_form({ message: 'Saved'|trans }) }}>
  <input type="text" name="first_name">
  <button type="submit">{{ 'Save'|trans }}</button>
</form>

<a href="{{ 'client/delete'|api_url({ id: client.id }, 'admin') }}"
   {{ fb_api_link({ modal: { type: 'confirm', title: 'Are you sure?'|trans }, redirect: 'client'|url }) }}>
  {{ 'Delete'|trans }}
</a>

fb_api_form() hace cuatro cosas por ti: añade method="post", serializa el formulario, adjunta el token CSRF automáticamente y deshabilita los botones mientras la petición está en vuelo. Un modal de tipo prompt exige la clave key: el valor que teclee el usuario se envía con ese nombre de parámetro.

Hooks: listeners persistidos en extension_meta y el gotcha del cron

FOSSBilling no tiene un event dispatcher convencional que escanee clases en cada petición. Persiste la lista de listeners en base de datos, en la tabla extension_meta, y la consulta al disparar cada evento.

ColumnaValor
extensionmod_hook
rel_typemod
rel_idNombre del módulo que aporta el listener
meta_keylistener
meta_valueNombre del evento, que es también el nombre del método

El sistema funciona en dos tiempos completamente separados:

flowchart LR
    subgraph Registro["Registro: una vez en cron o al activar"]
      A["Hook Service batchConnect"] --> B["Reflection sobre Service.php de cada modulo activo"]
      B --> C{"Metodo publico con primer parametro Box_Event?"}
      C -- Si --> D["INSERT en extension_meta con meta_value igual al nombre del metodo"]
      C -- No --> E["Se ignora"]
    end

    subgraph Disparo["Disparo: en cada peticion"]
      F["events_manager fire con event onX"] --> G["SELECT listeners WHERE meta_value = onX"]
      G --> H["Box_EventDispatcher connect"]
      H --> I["notify con Box_Event"]
      I --> J["Service onX event"]
    end

Box_EventManager::fire() hace cuatro cosas en ese orden: escribe en el canal de log event a nivel debug con el payload, construye el Box_Event con el contenedor inyectado, conecta desde la base de datos los listeners del evento y los de GLOBAL_LISTENER_NAME, y devuelve $e->getReturnValue().

Los cuatro requisitos exactos de un listener

Hook\Service::canBeConnected(\ReflectionMethod $method) es implacable y solo mira tres cosas, pero hay una cuarta condición práctica:

  1. El método debe estar en Service.php del módulo. Ni en el controlador, ni en las clases Api/.
  2. Debe ser public.
  3. Su primer parámetro debe estar tipado como \Box_Event.
  4. En la práctica se declaran static: el dispatcher los conecta como [$s::class, $event], es decir, con llamada estática. Un método no estático se registraría igual en la base de datos y fallaría al invocarse.

El nombre del método es el nombre del evento. No hay tabla de mapeo ni configuración.

El gotcha del cron

hook_batch_connect es la primera tarea que ejecuta Cron\Service::runCrons(). La documentación oficial lo dice sin adornos:

“FOSSBilling discovers hook methods when cron jobs run. If you add a new hook handler in a module, cron must run at least once before the hook is registered.”

Además de en el cron, Hook\Service::batchConnect($nombre) se dispara al activar una extensión, vía el propio hook onAfterAdminActivateExtension. Así que hay dos caminos: activar/reactivar la extensión, o correr el cron.

El diagnóstico: fuerza el descubrimiento con php /ruta/fossbilling/cron.php y comprueba que la fila existe.

SELECT rel_id, meta_value FROM extension_meta
WHERE extension = 'mod_hook' AND rel_type = 'mod' AND meta_key = 'listener'
ORDER BY rel_id, meta_value;

Lo mismo por API, con los permisos view, manage_hooks o trigger_hooks del módulo Hook: curl -s -u "admin:$KEY" "$API/admin/hook/get_list" | jq '.result.list'.

Hay un mecanismo de limpieza que también sorprende: _disconnectUnavailable() borra automáticamente los listeners de módulos sin Service.php, de métodos que ya no existen y de módulos desinstalados no core. Si renombras un método de hook y no vuelves a correr el cron, tienes una fila apuntando a un método inexistente; cuando el cron corra, esa fila desaparece y el listener nuevo se registra. Es autocurativo, pero solo en la siguiente vuelta del cron.

El objeto Box_Event

src/library/Box/Event.php implementa ArrayAccess e InjectionAwareInterface:

MétodoUso
getSubject()El sujeto del evento, a menudo el servicio que lo dispara, o null
getName()Nombre del evento
getParameters(): arrayEl payload completo
$event['clave']Acceso directo a un parámetro; lanza InvalidArgumentException si no existe
setReturnValue($v) y getReturnValue()Lo que devuelve fire()
setProcessed(bool) y isProcessed()Marca de procesado
getDi()El contenedor DI: por aquí accede el listener a todo lo demás

El acceso por ArrayAccess lanza excepción si la clave no existe, así que en un listener conviene ir por getParameters() con ?? en vez de $event['id'] a pelo, salvo que estés seguro de que el parámetro siempre viene.

Cuántos eventos hay y cuáles desaparecieron

Un grep sobre la rama main con el patrón 'event' => '...' devuelve 130 eventos verificados, más onEveryEvent. Ese último es especial: Box_EventManager::GLOBAL_LISTENER_NAME vale 'onEveryEvent', y un módulo que declare ese método recibe absolutamente todos los eventos del sistema. Es la mejor herramienta de descubrimiento que hay, y también la peor idea si la dejas en producción escribiendo a un servicio externo.

Las familias grandes son extensiones (onBefore/onAfterAdmin*Extension), clientes, pedidos, facturas, transacciones, tickets, staff, sistema y cliente. La convención es rígida: casi todo viene en pareja onBeforeX / onAfterX.

Ruptura de 0.8.3, importante si vienes de 0.7.x: al unificar los tickets públicos se eliminaron ocho eventos.

Evento eliminado en 0.8.3Sustituto
onBeforeGuestPublicTicketOpenonBeforeClientOpenTicket
onAfterGuestPublicTicketOpenonAfterClientOpenTicket
onAfterGuestPublicTicketCloseonAfterClientCloseTicket
onAfterGuestPublicTicketReplyonAfterClientReplyTicket
onBeforeAdminPublicTicketOpenonBeforeAdminOpenTicket
onAfterAdminPublicTicketOpenonAfterAdminOpenTicket
onAfterAdminPublicTicketCloseonAfterAdminCloseTicket
onAfterAdminPublicTicketReplyonAfterAdminReplyTicket

Un listener que escuche un evento eliminado no da error: simplemente no se ejecuta nunca. Es un fallo silencioso, y por eso onEveryEvent con el logger es la forma rápida de comprobar qué se dispara de verdad en tu versión.

Escribir listeners útiles: abortar con onBefore*, no lanzar en onAfter* y disparar eventos propios

La diferencia entre las dos familias no es estilística, es funcional.

Un onBefore* puede abortar la acción lanzando una excepción. Usa FOSSBilling\InformationException para que el mensaje sea visible al usuario:

/** Se ejecuta ANTES del alta de cliente: puede abortar la operacion. */
public static function onBeforeClientSignUp(\Box_Event $event): void
{
    $email      = $event->getParameters()['email'] ?? '';
    $bloqueados = ['mailinator.com', 'tempmail.com', 'guerrillamail.com'];
    $dominio    = strtolower(substr(strrchr($email, '@') ?: '', 1));

    if (in_array($dominio, $bloqueados, true)) {
        throw new \FOSSBilling\InformationException('No se permiten direcciones de correo temporales.', [], 403);
    }
}

Un onAfter* nunca debe dejar escapar una excepción. Estás dentro del flujo de checkout del cliente: si tu webhook a Slack falla porque la red se cayó, y la excepción sube, rompes la compra. Envuélvelo todo:

/** Se ejecuta DESPUES de que un cliente cree un pedido. */
public static function onAfterClientOrderCreate(\Box_Event $event): void
{
    $di      = $event->getDi();
    $params  = $event->getParameters();
    $webhook = $di['mod_config']('miextension')['webhook_url'] ?? null;

    if (empty($webhook)) {
        return;
    }

    try {
        $order = $di['db']->load('ClientOrder', $params['id']);
        $di['http_client']->request('POST', $webhook, [
            'json'    => ['content' => sprintf('Nuevo pedido #%s - %s', $order->id, $order->title)],
            'timeout' => 5,
        ]);
    } catch (\Throwable $e) {
        // NUNCA dejar escapar la excepcion: romperia el checkout del cliente.
        $di['logger']->setChannel('miextension')->error('Webhook fallo: ' . $e->getMessage());
    }
}

Tres detalles de ese listener que no son opcionales: el timeout de 5 segundos —el cliente está esperando en el navegador—, el \Throwable en el catch en vez de \Exception, y el uso de $di['http_client'], que es el HttpClientInterface de Symfony ya configurado con el User-Agent del sistema, en vez de un curl a mano.

Depurar qué se dispara

El canal de log event registra en nivel debug cada disparo con su payload, así que con el logging en debug ya tienes la traza sin escribir nada. Si quieres el detalle en tu propio canal, un onEveryEvent de una línea lo hace:

public static function onEveryEvent(\Box_Event $event): void
{
    $event->getDi()['logger']->setChannel('miextension')
        ->debug('evento=' . $event->getName(), $event->getParameters() ?? []);
}

Déjalo mientras desarrollas y quítalo después: recibe los 130 eventos, y a volumen real es ruido y coste.

Disparar tus propios eventos

Tu módulo puede publicar su propia superficie de extensión con una llamada:

$this->di['events_manager']->fire([
    'event'   => 'onAfterMiextensionNoteCreate',
    'subject' => $this,
    'params'  => ['id' => $id, 'title' => $data['title']],
]);

fire() devuelve $event->getReturnValue(), que cualquier listener puede fijar con $event->setReturnValue(...). Eso convierte el mecanismo en algo más que notificaciones: puedes usarlo como punto de extensión donde un tercero altera el resultado de tu cálculo. Si haces eso, documenta el contrato —qué pones en params, qué esperas de setReturnValue()— porque no hay tipado que lo imponga.

Y respeta la convención de nombres del core: onBefore u onAfter, seguido del área (Admin, Client, Guest), seguido del nombre de la acción. No es obligatorio, pero es lo que hace que otro desarrollador entienda tu evento sin leer tu código.

Errores comunes y diagnóstico

SíntomaCausa probableComprobaciónSolución
Authentication Failed con código 201No llega la cabecera Basic a PHPcurl -v y revisar si el servidor propaga AuthorizationAñadir E=HTTP_AUTHORIZATION en .htaccess, o el fastcgi_param equivalente en nginx
Código 203 con una clave correctaEstás usando el email como usuario BasicRevisar el -u del comando-u admin:KEY, con el literal admin o client
Código 205 con una clave válidaEs la clave del cron adminSELECT id, email, system_name FROM adminGenerar la clave desde el perfil de un staff normal
CSRF token invalid desde un scriptLa petición cayó al camino de sesiónComprobar que la Basic llegaAutenticar con API key; con clave no se exige CSRF
CSRF token invalid desde tu tema tras actualizarLa cookie cambió de csrf_token a fossbilling_csrfInspeccionar las cookies en el navegadorUsar fb_api_form() o el global CSRFToken, no leer la cookie a mano
Todo parece funcionar pero los datos no cambianEl cliente comprueba el status y no el cuerpojq '.error' sobre la respuestaComprobar siempre .error != null; el status es 200 en casi todos los errores
HTTP 404 en una llamada de APICódigo 740: el método no existe en ese módulo y rolRevisar el nombre del método en Api/<Rol>.phpCorregir el nombre; recordar que 404 nunca significa “URL mal formada”
Código 879 y HTTP 400La ruta no tiene tres segmentos, o el rol no es guest/client/adminRevisar la URLUsar /api/{rol}/{modulo}/{accion}; system no es enrutable
Código 710El nombre del método no lleva guion bajoRevisar la llamada al despachadorToda llamada es modulo_metodo
Código 715La extensión no está activaPanel admin → ExtensionsActivar el módulo
Código 725 en una integraciónLa cuenta de staff no tiene acceso al móduloStaff → Groups → PermissionsDar permiso al grupo de esa cuenta
Malformed JSON inputJSON inválido en el cuerpo POSTjq . <<< "$payload" antes de enviarCorregir el JSON, o usar form-urlencoded
per_page grande devuelve 100 registrosValor no entero, ignorado en silencioRevisar el tipo del parámetroEnviar un entero; el techo es 500
HTTP 429 con Retry-AfterLímite de tasa agotadoCabecera Retry-After de la respuestaRespetar la espera, o añadir la IP a rate_limiter.whitelist_ips
Todos los clientes reciben 429 a la vezEl limitador ve la IP del proxy inversotail data/log/security/security-$(date +%F).logConfigurar security.trusted_proxies antes de tocar límites
HTTP 503 en toda la API adminFinalización de actualización pendienteEntrar al panelIr a la finalización de la actualización del sistema
HTTP 503 en la API públicaModo mantenimiento activomaintenance_mode.enabled en config.phpPonerlo a false, o añadir la IP a maintenance_mode.allowed_ips
guest.system_version roto tras actualizarEndpoint eliminado en 0.8.0Release notes de 0.8.0Consultar la versión por API admin autenticada
guest/cron/run devuelve 403Desde 0.8.2 exige el parámetro hashSystem → Cron en el panelAñadir el hash a la URL del monitor externo
El hook nunca se ejecutaLos listeners se descubren al correr el cronSELECT * FROM extension_meta WHERE extension='mod_hook' AND meta_key='listener'php cron.php una vez, o reactivar la extensión
El hook está en la base de datos pero no correNo es public, no es static, o el primer parámetro no está tipado \Box_EventRevisar la firma en Service.phpCorregir la firma y volver a correr el cron
El listener desaparece de la base de datos_disconnectUnavailable() limpia métodos inexistentesComparar el nombre del método con meta_valueRenombrar consistentemente y correr el cron
Un hook heredado de 0.7.x no se disparaEvento eliminado en 0.8.3Tabla de sustituciones de este capítuloMigrar al evento equivalente
El checkout falla desde que añadiste un hookUn onAfter* está dejando escapar una excepciónCanal event en nivel debugEnvolver el cuerpo del listener en try/catch (\Throwable)

Lo que queda operativo

Tienes ahora los dos canales de integración cerrados. Del lado de la API: los tres roles enrutables y por qué system no lo es, la autenticación Basic con el usuario literal, el CSRF con sus cuatro sitios de búsqueda y su cambio de cookie entre 0.8.5 y main, el catálogo de códigos con su mapeo a HTTP y —lo que más tiempo te va a ahorrar— la certeza de que el status 200 no significa éxito y hay que mirar el campo error. Del lado de los hooks: el registro por reflexión persistido en extension_meta, los cuatro requisitos de firma, el descubrimiento atado al cron y la asimetría entre onBefore*, que puede abortar, y onAfter*, que nunca debe lanzar.

Con esto, tu instancia deja de ser una isla: un script externo puede leer y escribir con una clave de alcance controlado, tu módulo puede reaccionar a lo que pasa dentro, y tienes la tabla de diagnóstico para cuando cualquiera de los dos caminos falle en silencio.

Lo que falta es la capa que ve el cliente. En el capítulo 15 entras en los temas Twig: el orden de resolución de plantillas que te permite sobreescribir cualquier vista sin tocar el core, el sistema de widgets con sus slots, el sandbox de las plantillas de correo y la internacionalización con gettext, incluido el estado real del español.