[Trabajo de larga duración, entrega firmada]

Trabajos asíncronos

Encola trabajo de larga duración con `POST /v1/jobs`, recibe un `id` y luego haz polling con `GET /v1/jobs/{id}` o recibe un webhook firmado con HMAC al completar. Los kinds soportados inicialmente son `intelligence.enrich.bulk`, `export.bulk`, `import.bulk` y `graph.compute`. Cada transición de estado lleva el mismo `trace_id` de G2 para correlación de extremo a extremo.

Trabajos asíncronos

Encolar un trabajo

`POST /v1/jobs` requiere el scope `argus:jobs:write` sobre un bearer token. El cuerpo debe incluir `kind` (uno de los kinds soportados) y `payload`. Los campos opcionales son `callback_url` (URL https a la que la plataforma firmará y hará POST al completar) y `secrecy_level` (por defecto `standard`; el trabajo hereda el valor y nunca lo eleva durante la ejecución). En éxito la respuesta es 201 con `{ id, kind, status, created_at, trace_id }`. El trabajo empieza en estado `queued` y el `trace_id` es el mismo valor que fluye por los headers de respuesta de G2, para que puedas correlacionar el encolado con el trabajo posterior.

Ciclo de vida y estados

Un trabajo recorre una máquina de estados estricta. Los workers reclaman filas en cola con `SELECT ... FOR UPDATE SKIP LOCKED` para que cada trabajo se entregue a un único worker. Mientras se ejecuta, los workers hacen heartbeat cada 30 segundos y actualizan `progress_pct`. Un reaper vuelve a poner en cola los trabajos cuyo heartbeat tiene más de 2 minutos, de modo que un worker caído nunca deja un trabajo bloqueado. Los estados terminales (`succeeded`, `failed`, `cancelled`) se escriben exactamente una vez.

Idempotencia

Envía `Idempotency-Key: <uuid>` (se recomienda UUID v4) en `POST /v1/jobs` para que la llamada sea reintentable con seguridad. Dentro de una ventana de 24 horas por tenant, las repeticiones con la misma key devuelven 200 con el mismo `id` y cuerpo que la 201 original, incluso si la solicitud original cambia algún campo del payload. Sin el header, Argus no deduplica; un reintento de red puede encolar dos veces. Las keys están aisladas por tenant, así que dos tenants pueden usar el mismo UUID sin interferir.

Polling vs webhooks

Elige uno u otro modelo por solicitud. Polling: omite `callback_url` y llama a `GET /v1/jobs/{id}` hasta que el estado salga de `queued`/`running`, luego llama a `GET /v1/jobs/{id}/result` una vez para obtener la URL firmada. Webhooks: incluye `callback_url` y la plataforma firmará y hará POST del evento de finalización a esa URL. Polling es el camino más simple para batchs que tú mismo iniciaste; los webhooks son la elección correcta cuando un sistema externo necesita el resultado a los segundos de completarse. Ambos modos usan el mismo registro y el mismo `trace_id`.

Payload de webhook y verificación de firma

Al completar, Argus hace POST del cuerpo JSON canónico a tu `callback_url` con los headers `Content-Type: application/json`, `X-Argus-Trace-ID` (el mismo trace_id de G2), `X-Argus-Job-ID` y `X-Argus-Signature: sha256=<hex>`. La firma es HMAC-SHA256 sobre el cuerpo de la solicitud usando tu secreto compartido registrado. Los receptores DEBEN verificar la firma contra los bytes en bruto del cuerpo antes de actuar sobre el evento. Usa una comparación de tiempo constante (`hmac.compare_digest` en Python, `crypto.timingSafeEqual` en Node.js) para evitar ataques de tiempo. Argus reintenta entregas fallidas con backoff exponencial hasta 24 horas.

Cancelación

`DELETE /v1/jobs/{id}` solicita la cancelación y requiere el scope `argus:jobs:write`. Un trabajo `queued` o `running` devuelve 204 y transita a `cancelled` (el worker observa el flag en el siguiente boundary de heartbeat). Un trabajo ya `cancelled` devuelve 200 (replay idempotente). Un trabajo que ya alcanzó un estado terminal de éxito (`succeeded` o `failed`) devuelve 409: el trabajo completado no se puede deshacer. Si se registró un webhook, no se entrega webhook de finalización para un trabajo cancelado.

Catálogo de kinds

En la primera entrega se publican cuatro kinds. `intelligence.enrich.bulk` enriquece una lista de profile IDs contra los conectores partner. `export.bulk` produce exports con scope tenant (CSV, JSONL, FHIR Bundle) y escribe el resultado en R2; el endpoint de resultado devuelve una URL firmada de corta duración. `import.bulk` ingiere un bundle suministrado por un partner y devuelve resultados por fila. `graph.compute` ejecuta una consulta precomputada sobre el grafo operacional y devuelve el resultado materializado. Los kinds futuros son aditivos; los kinds existentes conservan su forma de payload bajo semver.

Aislamiento por tenant

Cada consulta a la tabla jobs incluye `tenant_id` Y `organization_id` del bearer token. Las búsquedas cruzadas devuelven 404 (nunca 403), por lo que la existencia de un trabajo en otro tenant nunca se revela. El registro hereda `secrecy_level` de la solicitud y las transiciones de ciclo de vida nunca lo elevan. Cada cambio de estado escribe una entrada de auditoría con el usuario, organización, tenant, acción (`jobs.enqueue`, `jobs.start`, `jobs.complete`, `jobs.fail`, `jobs.cancel`, `jobs.read`, `jobs.list`), `resource_id = id`, nivel de secreto y metadatos `{ kind, status, progress_pct, trace_id }`.

Ciclo de vida y estados

queued | running | succeeded | failed | cancelled

Encolar un trabajo

# Enqueue an async job
curl -X POST https://api.knogin.com/v1/jobs \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8b9d8a2e-4a8c-4a7e-8a2e-4a8c4a7e8a2e" \
  -d '{
    "kind": "intelligence.enrich.bulk",
    "payload": { "profile_ids": ["p1", "p2"] },
    "callback_url": "https://partner.example.com/argus-webhook",
    "secrecy_level": "standard"
  }'

# 201 Created
# {
#   "id": "01HFXY...",
#   "kind": "intelligence.enrich.bulk",
#   "status": "queued",
#   "created_at": "2026-05-08T12:00:00Z",
#   "trace_id": "0af7651916cd43dd8448eb211c80319c"
# }

Estado, resultado y payload de webhook

# GET /v1/jobs/{id} while running
{
  "id": "01HFXY...",
  "kind": "intelligence.enrich.bulk",
  "status": "running",
  "progress_pct": 42,
  "created_at": "2026-05-08T12:00:00Z",
  "updated_at": "2026-05-08T12:00:30Z",
  "completed_at": null,
  "error": null,
  "trace_id": "0af7651916cd43dd8448eb211c80319c"
}

# GET /v1/jobs/{id}/result after success
{
  "id": "01HFXY...",
  "result_ref": "r2://argus-jobs/results/01HFXY.json",
  "signed_url": "https://r2-presigned-url..."
}

# GET /v1/jobs?kind=intelligence.enrich.bulk&status=running&limit=50
{
  "jobs": [ /* job objects */ ],
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiLi4uIn0=",
  "total_count": 1234
}

Entrega de webhook

# Argus -> partner.example.com/argus-webhook
POST /argus-webhook HTTP/1.1
Host: partner.example.com
Content-Type: application/json
X-Argus-Trace-ID: 0af7651916cd43dd8448eb211c80319c
X-Argus-Job-ID: 01HFXY...
X-Argus-Signature: sha256=<hex of HMAC-SHA256(canonical_json_body, partner_secret)>

{
  "id": "01HFXY...",
  "kind": "intelligence.enrich.bulk",
  "status": "succeeded",
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "result_ref": "r2://argus-jobs/results/01HFXY.json"
}

Verificar la firma del webhook

// Node.js (Express): verify X-Argus-Signature on the raw body
import crypto from 'node:crypto';

function verifyArgusSignature(rawBody, headerValue, sharedSecret) {
  const expectedHex = crypto
    .createHmac('sha256', sharedSecret)
    .update(rawBody)
    .digest('hex');
  const expected = Buffer.from(`sha256=${expectedHex}`, 'utf8');
  const provided = Buffer.from(headerValue ?? '', 'utf8');
  if (provided.length !== expected.length) return false;
  return crypto.timingSafeEqual(provided, expected);
}
# Python (Flask): canonical-body verification
import hmac, hashlib, json

def verify_argus_signature(raw_body: bytes, header_value: str, shared_secret: bytes) -> bool:
    expected = "sha256=" + hmac.new(shared_secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_value or "")

# Argus computes the signature over the canonical JSON body it sent:
# canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
# sig = hmac.new(secret, canonical, hashlib.sha256).hexdigest()
# header = f"sha256={sig}"

¿Listo para integrar trabajos asíncronos?

Abre la referencia API para el contrato público o contacta a Knogin si necesitas ayuda para definir un kind a medida.