[Long-running work, signed delivery]

Async jobs

Enqueue long-running work with `POST /v1/jobs`, get back an `id`, then poll `GET /v1/jobs/{id}` or receive an HMAC-signed webhook on completion. Initial supported kinds are `intelligence.enrich.bulk`, `export.bulk`, `import.bulk`, and `graph.compute`. Every state transition carries the same `trace_id` from G2 for end-to-end correlation.

Async jobs

Enqueue a job

`POST /v1/jobs` requires the `argus:jobs:write` scope on a bearer token. The body must include `kind` (one of the supported job kinds) and `payload`. Optional fields are `callback_url` (https URL the platform will sign and POST to on completion) and `secrecy_level` (defaults to `standard`; the job inherits the value and never elevates it during execution). On success the response is 201 with `{ id, kind, status, created_at, trace_id }`. The job starts in status `queued` and the `trace_id` is the same value that flows through G2 response headers, so you can correlate the enqueue call with downstream work.

Lifecycle and statuses

A job moves through a strict status machine. Workers claim queued rows with `SELECT ... FOR UPDATE SKIP LOCKED` so each job is delivered to one worker at a time. While running, workers heartbeat every 30 seconds and update `progress_pct`. A reaper requeues running jobs whose heartbeat is older than 2 minutes, so a crashed worker never strands a job. Terminal states (`succeeded`, `failed`, `cancelled`) are written exactly once.

Idempotency

Send `Idempotency-Key: <uuid>` (UUID v4 recommended) on `POST /v1/jobs` to make the call safely retryable. Within a 24-hour window per tenant, replays of the same key return 200 with the same `id` and body as the original 201, even if the original request changed payload fields. Without the header, Argus does not deduplicate; a transient network retry could enqueue twice. Keys are scoped per tenant, so two tenants can use the same UUID without interfering.

Polling vs webhooks

Choose either model per request. Polling: omit `callback_url` and call `GET /v1/jobs/{id}` until status leaves `queued`/`running`, then call `GET /v1/jobs/{id}/result` once for the signed URL. Webhooks: include `callback_url` and the platform signs and POSTs the completion event to that URL. Polling is the simplest path for batch jobs you initiated yourself; webhooks are the right choice when an external system needs the result within seconds of completion. Both modes use the same job record and the same `trace_id`.

Webhook payload and signature verification

On completion, Argus POSTs the canonical JSON body to your `callback_url` with headers `Content-Type: application/json`, `X-Argus-Trace-ID` (the same trace_id from G2), `X-Argus-Job-ID`, and `X-Argus-Signature: sha256=<hex>`. The signature is HMAC-SHA256 over the request body using your registered shared secret. Receivers MUST verify the signature against the raw bytes of the body before acting on the event. Use a constant-time comparison (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node.js) to avoid timing attacks. Argus retries failed deliveries with exponential backoff for up to 24 hours.

Cancellation

`DELETE /v1/jobs/{id}` requests cancellation and requires the `argus:jobs:write` scope. A `queued` or `running` job returns 204 and transitions to `cancelled` (the worker observes the flag at the next heartbeat boundary). An already-`cancelled` job returns 200 (idempotent replay). A job that has already reached a terminal-success status (`succeeded` or `failed`) returns 409: completed work cannot be undone. If a webhook was registered, no completion webhook is delivered for a cancelled job.

Job kinds catalogue

Four job kinds ship in the initial release. `intelligence.enrich.bulk` enriches a list of profile IDs against partner connectors. `export.bulk` produces tenant-scoped exports (CSV, JSONL, FHIR Bundle) and writes the result to R2; the result endpoint returns a short-lived signed URL. `import.bulk` ingests a partner-supplied bundle and returns per-row outcomes. `graph.compute` runs a precomputed query against the operational graph and returns the materialised result. Future kinds are additive; existing kinds keep their payload shape under semver.

Tenant isolation

Every query on the jobs table includes `tenant_id` AND `organization_id` from the bearer token. Cross-tenant lookups return 404 (never 403), so the existence of a job in another tenant is never disclosed. The job record inherits `secrecy_level` from the request and lifecycle transitions never elevate it. Every state change writes an audit entry capturing the user, organization, tenant, action (`jobs.enqueue`, `jobs.start`, `jobs.complete`, `jobs.fail`, `jobs.cancel`, `jobs.read`, `jobs.list`), `resource_id = id`, secrecy level, and metadata `{ kind, status, progress_pct, trace_id }`.

Lifecycle and statuses

queued | running | succeeded | failed | cancelled

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

Status, result, and webhook payloads

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

Webhook delivery

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

Verify the webhook signature

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

Ready to wire async jobs into your integration?

Open the API reference for the public contract or talk to Knogin if you need help shaping a custom job kind.