API reference
Dispatch API
The one endpoint your agents call. Every request field, every response shape, every status code, and the pattern for handling each one.
POST /v1/dispatch
Send a governed request. Arcus evaluates the five enforcement gates, records the decision, and — if allowed — delivers the payload to target asynchronously.
This is the only endpoint an API key can reach.
Authentication#
Authorization: Bearer ark_live_xxxxxxxx
Content-Type: application/jsonA missing, malformed, unknown or revoked key returns 401 before any enforcement runs.
Request body#
{
"target": "https://api.internal.example.com/webhook/triage",
"from": "intake-agent",
"to": "triage-service",
"message": "Patient reports severe pain in the lower left molar.",
"data": {
"priority": "urgent",
"caseId": "C-4471"
}
}| Field | Type | Required | Notes |
|---|---|---|---|
target | string (URL) | Yes | Absolute destination URL. Matched against your policies. |
message | string | Yes | Human-readable content or instruction. Scanned by DLP. |
from | string | No | Declared sender agent id. Checked against the key's binding. |
to | string | No | Logical recipient label, for your own attribution. Not used for routing. |
data | object | No | Arbitrary structured fields. Scanned by DLP and evaluated by guardrails. |
target#
Must be an absolute URL. It is matched against your policy set — default-deny, so an unlisted destination is refused with 403.
It is also scanned by DLP, which catches the common mistake of putting a credential in a query string.
from#
The declared sender. Everything downstream reasons about it: policies match on it, guardrails scope to it, rate-limit counters key on it, baselines are learned per agent.
If the key is bound to an agentId, from must equal it or the request is rejected 401 and recorded as dispatch.spoof_rejected. If from is omitted, the key's binding is used; if the key has no binding either, only a wildcard policy can match.
Always send it, and always bind the key. See identity binding.
to#
A free label — triage-service, billing-queue. It appears in your logs and audit events for attribution. It does not affect routing; target alone decides where the request goes.
data#
Any JSON object. This is where guardrails do their work — data.amount, data.command, data.items[*].price are all guardrail-addressable paths. DLP walks it recursively to a depth of 12, checking both field names and values.
Keep it to what the receiver needs. A config object serialised wholesale is the most common source of a DLP refusal.
Responses#
Allowed — 202#
{
"ok": true,
"transferLogId": "clx8m2p4k0001abcd",
"queued": true
}Passed all five gates. Enqueued for delivery. transferLogId identifies the record in /v1/admin/logs and in your audit chain.
Allowed but flagged — 202#
{
"ok": true,
"transferLogId": "clx8m2p4k0007abcd",
"queued": true,
"flagged": true,
"findings": [
{
"ruleId": "clx9n3q5l0004efgh",
"label": "Watch protected tables",
"field": "query",
"path": "data.query",
"condition": "contains_keyword",
"expected": ["patients", "billing_accounts"],
"observed": "SELECT * FROM patients WHERE id = 42",
"severity": "medium",
"reason": "data.query contains the keyword \"patients\""
}
]
}A flag_only guardrail matched. Delivered anyway — the finding is recorded as dispatch.flagged_by_parameter. Treat flagged: true as a signal to log or alert on your side, not as a failure.
Held for approval — 202#
{
"ok": true,
"status": "PENDING_APPROVAL",
"transferLogId": "clx8m2p4k0008abcd",
"queued": false
}A REQUIRE_APPROVAL policy or a hold_for_approval guardrail held it. The payload is retained; nothing is delivered until a person decides, and holds do not expire. See Approval workflow.
Branch on `queued`, not on `202`
Three different outcomes return 202. An agent that treats 202 as "sent" will report success for a request sitting in an approval queue.
Bad credential or identity mismatch — 401#
{ "error": "Invalid API key" }{ "error": "Agent identity mismatch" }The first is a missing, malformed, unknown or revoked key. The second is from not matching the key's binding — recorded as dispatch.spoof_rejected. Neither is retryable.
Policy denial — 403#
{
"error": "No policy allows intake-agent -> https://evil.example.com/exfil",
"status": "BLOCKED_BY_POLICY",
"transferLogId": "clx8m2p4k0003abcd",
"policyId": null
}policyId: null means nothing matched — default-deny. A populated policyId means a policy matched and its action was BLOCK. See Policy engine.
DLP refusal — 422#
{
"error": "Payload blocked by DLP",
"status": "BLOCKED_BY_DLP",
"transferLogId": "clx8m2p4k0005abcd",
"violations": ["PASSWORD_FIELD", "AWS_ACCESS_KEY"],
"matchCount": 2,
"detailAvailableOn": "pro"
}violations names the categories on every plan. With the dlp.configure capability, matches additionally carries the field paths. The matched value is never returned, on any plan. See Data loss prevention.
Guardrail block — 422#
{
"error": "Blocked by parameter guardrail",
"status": "BLOCKED_BY_PARAMETER",
"transferLogId": "clx8m2p4k0006abcd",
"severity": "critical",
"findings": [
{
"ruleId": "clx9n3q5l0001efgh",
"label": "No destructive commands",
"field": "data.command",
"path": "data.command",
"condition": "matches_destructive",
"expected": "critical",
"observed": "DROP TABLE patients",
"severity": "critical",
"reason": "data.command matches DROP TABLE / DATABASE / SCHEMA (critical)"
}
]
}findings lists every matching rule, not just the one that decided. severity is the highest among them. See Parameter guardrails.
Quarantined — 429#
{
"error": "Rate spike: 61 dispatches in 60s (limit 60) — quarantined for 300s",
"status": "THREAT_DETECTED",
"retryAfter": 300
}Also sets the Retry-After header. retryAfter is the live remaining TTL, so it counts down on subsequent attempts. Honour it — retrying sooner produces another refusal and another audit event. See Threat detection.
Invalid body — 400#
{ "error": "target: Invalid url" }The field and the problem. Not retryable without changing the request.
Queue unavailable — 202#
{
"ok": true,
"transferLogId": "clx8m2p4k0009abcd",
"queued": false,
"error": "Queue unavailable"
}The decision was made and recorded, but enqueueing timed out (2 s) — Redis is unreachable. The transfer log row is written as FAILED with Queue unavailable: … rather than the request being silently dropped.
202 with queued: false and no PENDING_APPROVAL status is the signature. Nothing was delivered and nothing will retry automatically — resend once Redis is back.
Status code summary#
| Code | Meaning | Retry? |
|---|---|---|
202 | Accepted — check queued and status | — |
400 | Malformed body | No, fix the request |
401 | Bad key, or identity mismatch | No |
403 | Policy refused the destination | No, fix the policy |
422 | DLP or a guardrail refused the content | No, fix the payload |
429 | Quarantined | After Retry-After |
500 | Server error | Yes, with backoff |
What your target receives#
The validated dispatch payload, as JSON, with content-type: application/json:
{
"target": "https://api.internal.example.com/webhook/triage",
"from": "intake-agent",
"to": "triage-service",
"message": "Patient reports severe pain in the lower left molar.",
"data": { "priority": "urgent", "caseId": "C-4471" }
}target is included, so a receiver can distinguish a governed delivery from a direct call.
| Delivery behaviour | Value |
|---|---|
| Attempts | 3 |
| Backoff | Exponential from 1 s |
| Per-attempt timeout | 10 s |
| Success | Any 2xx |
| Failure | Non-2xx, timeout or connection error |
Return 2xx quickly, and be idempotent
Any non-2xx is retried up to three times. There are no idempotency keys, so your endpoint must tolerate seeing the same body more than once — put a deduplication id in data and check it. Acknowledge before slow work if that work can exceed 10 seconds.
Complete example#
import os
import time
import requests
class ArcusRefused(RuntimeError):
"""Arcus made a decision. Not retryable."""
class ArcusQuarantined(RuntimeError):
def __init__(self, seconds: int):
super().__init__(f"quarantined for {seconds}s")
self.seconds = seconds
class Arcus:
def __init__(self, agent: str):
self.agent = agent
self.url = os.environ["ARCUS_URL"].rstrip("/")
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {os.environ['ARCUS_KEY']}",
"Content-Type": "application/json",
})
def dispatch(self, target, message, data=None, to=None, retries=2):
body = {"target": target, "from": self.agent, "message": message, "data": data or {}}
if to:
body["to"] = to
for attempt in range(retries + 1):
r = self.session.post(f"{self.url}/v1/dispatch", json=body, timeout=15)
payload = r.json()
if r.status_code == 202:
return {
"id": payload["transferLogId"],
"held": payload.get("status") == "PENDING_APPROVAL",
"queued": payload.get("queued", False),
"flagged": payload.get("flagged", False),
"findings": payload.get("findings", []),
}
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", payload.get("retryAfter", 300)))
raise ArcusQuarantined(wait)
if r.status_code >= 500 and attempt < retries:
time.sleep(2 ** attempt)
continue
# 400 / 401 / 403 / 422 — a decision, or a broken request. Do not retry.
raise ArcusRefused(
f"{r.status_code} {payload.get('status', '')}: {payload.get('error')}"
)
raise ArcusRefused("exhausted retries on server error")
arcus = Arcus("intake-agent")
try:
result = arcus.dispatch(
target="https://api.internal.example.com/webhook/triage",
message="Patient reports severe pain in the lower left molar.",
data={"priority": "urgent", "caseId": "C-4471"},
)
if result["held"]:
print(f"awaiting approval: {result['id']}")
elif result["flagged"]:
print(f"delivered with {len(result['findings'])} finding(s): {result['id']}")
else:
print(f"delivered: {result['id']}")
except ArcusQuarantined as e:
print(f"back off {e.seconds}s — something is looping")
except ArcusRefused as e:
print(f"refused: {e}") # policy, DLP or guardrail. Fix the cause, do not retry.Practical notes#
Batch inside one dispatch where you can. One dispatch carrying 500 records costs one against the rate limit; 500 dispatches cost 500. Guardrail paths like data.records[*].id exist for this shape.
One agent id per logical role. Policies, guardrails, baselines, rate limits and logs are all attributed by agent. Splitting import-worker-1..3 gives each its own 60/minute budget and its own audit trail.
Do not send the key in the payload. DLP recognises ark_live_ and will refuse the dispatch — correct, but an avoidable incident.
Do not call this from a browser. CORS permits only the dashboard origin, and an ark_live_ key in client-side JavaScript is a published key.