Agent API: HMAC-authenticated endpoints between the proxy and the control plane

The GraphWarden proxy communicates with the control plane over four HMAC-authenticated endpoints: /sync, /heartbeat, /audit, and /metrics. This page documents each endpoint, the HMAC signing convention, rate limits, and a complete worked example that reproduces the signature with any HMAC-SHA256 library.

Agent API contract — HMAC-SHA256, hex-lowercase

Prerequisites

- A proxy API key and HMAC secret issued by the GraphWarden Admin app (unique per proxy instance) - An HMAC-SHA256 implementation — the standard library on any modern runtime (.NET `HMACSHA256`, PHP `hash_hmac`, Python `hmac`, Node `crypto.createHmac`, Go `crypto/hmac`) - A clock synchronised to within ±300 seconds (5 minutes) of the control-plane server time

The four endpoints

Every endpoint lives under the control-plane base URL https://{app-admin-host}/api/v1/agent/. The base URL is configured via RuleSync:SaasBaseUrl and Audit:IngestionUrl on the proxy (see the environment variables reference).

Endpoint Method Purpose Rate limit
/api/v1/agent/sync GET Proxy fetches current rulesets, app identities, and metadata from the control plane. Not rate-limited individually
/api/v1/agent/heartbeat POST Proxy signals liveness and reports version, uptime, loaded-rule count, and check statuses. Not rate-limited individually
/api/v1/agent/audit POST Proxy ships audit event batches to Elasticsearch via the control plane. 100 batches/min per org, 60 batches/min per proxy instance
/api/v1/agent/metrics POST Proxy reports performance counters (requests evaluated, allowed, blocked, filtered, latency). Not rate-limited individually

The /audit rate limits are applied by the ThrottleAuditBatch middleware on the control plane. Breaching either limit returns HTTP 429 with a Retry-After header; the two limits are configurable via AUDIT_RATE_LIMIT_ORG and AUDIT_RATE_LIMIT_PROXY.

Request signing convention

Every request — regardless of method — carries three headers:

Header Value
X-Api-Key Cleartext API key identifying the proxy instance.
X-Timestamp Current Unix time in seconds (UTC).
X-Hmac-Signature Hex-lowercase SHA256 signature of the canonical payload.

The canonical payload concatenates the timestamp, a literal dot, and the raw request body:

payload = "{X-Timestamp}.{body}"
signature = HMAC-SHA256(hmac_secret, payload)
result = hex(signature).lowercase()

For GET requests, the body is empty — the canonical payload reduces to "{timestamp}." with nothing after the dot.

The control plane enforces a ±300 second skew tolerance on X-Timestamp. Requests outside that window are rejected with HTTP 401 even when the signature is otherwise well-formed. Clock drift is the leading cause of field-reported authentication failures; treat NTP sync as a prerequisite, not an optimisation.

The case of the hex digest matters: the control plane compares signatures case-sensitively against the hex-lowercase representation. Emitting an uppercase signature is rejected as a mismatch.

Worked example: hand-verifiable

The example below fixes every input so a reader or an automated test can reproduce the signature with any HMAC-SHA256 library. The exact same numbers are asserted by the feature test that accompanies this page — if either drifts, the test fails.

Example inputs (reproduce the signature with these)

Field Value
Endpoint GET /api/v1/agent/sync
X-Timestamp (Unix) 1745064000
Body (empty — this is a GET)
HMAC secret sk_example_key_for_docs_only

Canonical payload

Because the method is GET, the body is empty and the canonical payload is the timestamp plus a literal dot:

1745064000.

Computing the signature

Any HMAC-SHA256 implementation reproduces the same hex digest. The PHP one-liner below is the same one the feature test runs:

echo hash_hmac('sha256', '1745064000.', 'sk_example_key_for_docs_only');

The equivalent in bash with openssl:

printf '%s' '1745064000.' | openssl dgst -sha256 -hmac 'sk_example_key_for_docs_only' -hex

The expected signature — 64 hex characters, lowercase — is:

1f783cff87e072de68de97218bc53bef4eb5b42459085ec37b7eef8f7cc8891b

Resulting headers

With all three inputs set, the proxy sends:

X-Api-Key: sk_example_key_for_docs_only
X-Timestamp: 1745064000
X-Hmac-Signature: 1f783cff87e072de68de97218bc53bef4eb5b42459085ec37b7eef8f7cc8891b
Authorization: HMAC key-id=sk_example_key_for_docs_only, signature=1f783cff87e072de68de97218bc53bef4eb5b42459085ec37b7eef8f7cc8891b

The Authorization header line is redundant with the three X-* headers and is included only for operators whose HTTP tooling shows Authorization lines by default. The control plane reads the X-* triple.

For a POST request, replace the empty body with the exact JSON the proxy sends — no whitespace changes, no re-serialisation. The payload becomes "{timestamp}.{serialised_body}" and the signature is recomputed over that concatenation.

Rate limits per endpoint

Endpoint Scope Default Configuration key
/audit Per org 100 batches/min AUDIT_RATE_LIMIT_ORG
/audit Per proxy instance 60 batches/min AUDIT_RATE_LIMIT_PROXY
/sync, /heartbeat, /metrics Global No per-call rate limit applied n/a

When the proxy breaches either /audit limit, the control plane responds with HTTP 429 and a Retry-After header giving the number of seconds before the next batch will be accepted. The proxy queues the batch to local retention (Audit:LocalRetentionPath) and retries on the next Audit:ReplayIntervalSeconds tick.

Error responses

Status Meaning Remediation
200 OK Request accepted (applies to /sync, /heartbeat, /metrics). None.
202 Accepted Audit batch accepted (applies to /audit). A body field duplicate: true indicates idempotent replay. None.
400 Bad Request Payload shape invalid — missing batch ID, cross-org event mix, malformed JSON. Inspect the batch before send; check event-level GWOrgId uniformity.
401 Unauthorized Signature mismatch OR timestamp outside ±300s OR API key not recognised. Verify clock sync, re-derive the signature with the documented payload, confirm the API key.
403 Forbidden API key valid but not authorised for the target tenant. Re-issue the proxy credentials from the Admin app.
409 Conflict Audit batch ID already processed beyond the 48h idempotency window. Generate a new batch ID for subsequent retries.
413 Payload Too Large Audit batch exceeds the 5 MB size limit. Reduce Audit:MaxBatchSize to 500 events or lower.
429 Too Many Requests Either audit rate limit breached. Honour Retry-After; local retention queues the batch until the window reopens.

Troubleshooting

The control plane returns 401 even though the signature computation matches in a local script

The leading cause is clock drift. Check the proxy host's NTP status and compare X-Timestamp sent against the control-plane server time. A ±300 second window sounds forgiving; a host drifted by six minutes from UTC falls outside it silently. Enable ntp (or w32tm on Windows) and restart the proxy.

The local signature matches one library but differs from another

The canonical payload must be bytes, not a string with implicit encoding. Confirm the HMAC library receives ASCII bytes for the timestamp, a literal . (0x2E), and the exact body bytes with no trailing newline. Some libraries default to UTF-16 or append a CRLF; either changes every hex byte of the output. The PHP hash_hmac call above emits the canonical reference.

The Authorization-header key-id is rejected but the X-Api-Key header works

The control plane reads only the three X-* headers. The Authorization line is a human-readable mirror and is not parsed. Operators who forward requests through an intermediate proxy that rewrites Authorization but preserves X-* headers encounter this scenario; rely on the X-* headers as the source of truth.

The signature is correct bytewise but rendered uppercase

The control plane compares hex-lowercase. Many language standard libraries emit uppercase by default (bin2hex + strtoupper, BitConverter.ToString on .NET). Pipe through .ToLower() or equivalent before sending. The reference PHP snippet outputs lowercase; match its shape.

The body bytes appear identical but the POST signature mismatches

A common cause: the proxy serialises the body with a trailing newline or a pretty-printer, while the local test script serialises without it. Compute the signature over the exact bytes the HTTP library will transmit. Hash the body before and after the HTTP client touches it; the Content-Length header reveals any mismatch.

For the detailed ruleset YAML format the /sync response returns, see the rule schema reference.

Last reviewed:

Last reviewed: .