[Lavoro a lunga durata, consegna firmata]

Job asincroni

Metti in coda lavori a lunga durata con `POST /v1/jobs`, ricevi un `id`, poi fai polling su `GET /v1/jobs/{id}` oppure ricevi un webhook firmato HMAC al completamento. I kind inizialmente supportati sono `intelligence.enrich.bulk`, `export.bulk`, `import.bulk` e `graph.compute`. Ogni transizione di stato porta lo stesso `trace_id` di G2 per la correlazione end-to-end.

Job asincroni

Mettere in coda un job

`POST /v1/jobs` richiede lo scope `argus:jobs:write` su un bearer token. Il body deve includere `kind` (uno dei kind supportati) e `payload`. I campi opzionali sono `callback_url` (URL https a cui la piattaforma firmerà e farà POST al completamento) e `secrecy_level` (default `standard`; il job eredita il valore e non lo eleva mai durante l’esecuzione). In caso di successo la risposta è 201 con `{ id, kind, status, created_at, trace_id }`. Il job parte in `queued` e il `trace_id` è lo stesso valore che scorre negli header di risposta G2, così puoi correlare la messa in coda con il lavoro a valle.

Ciclo di vita e stati

Un job attraversa una macchina a stati rigorosa. I worker reclamano le righe in coda con `SELECT ... FOR UPDATE SKIP LOCKED`, in modo che ogni job venga consegnato a un solo worker alla volta. Durante l’esecuzione i worker fanno heartbeat ogni 30 secondi e aggiornano `progress_pct`. Un reaper rimette in coda i job in esecuzione il cui heartbeat è più vecchio di 2 minuti, così un worker caduto non blocca mai un job. Gli stati terminali (`succeeded`, `failed`, `cancelled`) vengono scritti esattamente una volta.

Idempotenza

Invia `Idempotency-Key: <uuid>` (UUID v4 consigliato) su `POST /v1/jobs` per rendere la chiamata ripetibile in sicurezza. In una finestra di 24 ore per tenant, i replay con la stessa key restituiscono 200 con lo stesso `id` e body della 201 originale, anche se la richiesta originale ha cambiato campi del payload. Senza l’header, Argus non deduplica; un retry di rete potrebbe mettere in coda due volte. Le key sono scoped per tenant, due tenant possono usare lo stesso UUID senza interferire.

Polling o webhook

Scegli un modello per richiesta. Polling: ometti `callback_url` e chiama `GET /v1/jobs/{id}` finché lo stato non lascia `queued`/`running`, poi chiama una volta `GET /v1/jobs/{id}/result` per l’URL firmato. Webhook: includi `callback_url` e la piattaforma firmerà e farà POST dell’evento di completamento a quell’URL. Il polling è la via più semplice per i batch che hai avviato tu; i webhook sono la scelta giusta quando un sistema esterno necessita del risultato entro pochi secondi dal completamento. Entrambe le modalità usano lo stesso record e lo stesso `trace_id`.

Payload del webhook e verifica della firma

Al completamento, Argus fa POST del body JSON canonico al tuo `callback_url` con gli header `Content-Type: application/json`, `X-Argus-Trace-ID` (lo stesso trace_id di G2), `X-Argus-Job-ID` e `X-Argus-Signature: sha256=<hex>`. La firma è HMAC-SHA256 sul body usando il tuo shared secret registrato. I destinatari DEVONO verificare la firma contro i byte grezzi del body prima di agire sull’evento. Usa un confronto a tempo costante (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node.js) per evitare timing attack. Argus ritenta le consegne fallite con backoff esponenziale fino a 24 ore.

Cancellazione

`DELETE /v1/jobs/{id}` richiede la cancellazione e necessita dello scope `argus:jobs:write`. Un job `queued` o `running` restituisce 204 e passa a `cancelled` (il worker osserva il flag al prossimo boundary di heartbeat). Un job già `cancelled` restituisce 200 (replay idempotente). Un job che ha già raggiunto uno stato terminale di successo (`succeeded` o `failed`) restituisce 409: il lavoro completato non può essere annullato. Se era registrato un webhook, per un job cancellato non viene consegnato il webhook di completamento.

Catalogo dei kind

Quattro kind sono spediti nel primo rilascio. `intelligence.enrich.bulk` arricchisce una lista di profile ID contro i connettori partner. `export.bulk` produce export scoped al tenant (CSV, JSONL, FHIR Bundle) e scrive il risultato su R2; l’endpoint risultato restituisce un URL firmato di breve durata. `import.bulk` ingerisce un bundle fornito da un partner e restituisce risultati per riga. `graph.compute` esegue una query precomputata sul grafo operativo e restituisce il risultato materializzato. I kind futuri sono additivi; i kind esistenti mantengono la forma di payload sotto semver.

Isolamento per tenant

Ogni query sulla tabella jobs include `tenant_id` E `organization_id` dal bearer token. I lookup cross-tenant restituiscono 404 (mai 403), quindi l’esistenza di un job in un altro tenant non viene mai rivelata. Il record eredita `secrecy_level` dalla richiesta e le transizioni di ciclo di vita non lo elevano mai. Ogni cambio di stato scrive una voce di audit con utente, organizzazione, tenant, azione (`jobs.enqueue`, `jobs.start`, `jobs.complete`, `jobs.fail`, `jobs.cancel`, `jobs.read`, `jobs.list`), `resource_id = id`, livello di segretezza e metadati `{ kind, status, progress_pct, trace_id }`.

Ciclo di vita e stati

queued | running | succeeded | failed | cancelled

Mettere in coda un job

# 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"
# }

Stato, risultato e payload del 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
}

Consegna del 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"
}

Verificare 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}"

Pronto a integrare job asincroni?

Apri la reference API per il contratto pubblico o contatta Knogin se hai bisogno di aiuto a definire un kind su misura.