Skip to content

Ingestion API

Browsers post CSP reports to HeaderHawk directly. You never call this endpoint from your own code — you name it in a report-uri or report-to directive and the browser does the rest. It is documented here because it is worth knowing what the browser is sending and what a given status code means when you are checking whether reporting works.

Everything below is derived from the OpenAPI contract the endpoint is built from, and a test fails if the two drift apart.

POST https://ingest.headerhawk.com/csp/{ingestCode}

ingestCode is the 8-character base62 identifier assigned when you create a site. Copy the full URL from the site’s setup panel rather than assembling it; it is the value every guide in these docs writes as YOUR_INGEST_CODE.

The endpoint detects the format from the body, so a browser that sends a CSP report as application/json is handled the same as one that uses the registered type.

Format Content-Type Sent by
CSP Level 2 application/csp-report report-uri directive
Reporting API application/reports+json report-to directive
Script hashes application/reports+json report-to, 'report-sample' with hash collection enabled

A single object under a csp-report key. document-uri, violated-directive and original-policy are required; the rest are optional and browser-dependent.

{
"csp-report": {
"document-uri": "https://example.com/page",
"referrer": "https://google.com/",
"violated-directive": "script-src",
"effective-directive": "script-src",
"original-policy": "default-src 'self'; script-src 'self'; report-uri /csp",
"disposition": "report",
"blocked-uri": "https://evil.com/script.js",
"status-code": 200
}
}

An array of report envelopes. body and type are required, and type is csp-violation for a policy violation. Inside body, documentURL, effectiveDirective and originalPolicy are required.

[
{
"age": 10,
"type": "csp-violation",
"url": "https://example.com/page",
"user_agent": "Mozilla/5.0...",
"body": {
"documentURL": "https://example.com/page",
"blockedURL": "https://evil.com/script.js",
"disposition": "enforce",
"effectiveDirective": "script-src",
"originalPolicy": "default-src 'self'; script-src 'self'"
}
}
]

Browsers batch csp-violation and csp-hash reports into one array. Mixed arrays are split and each type routed independently, so a request carrying both is handled as two pipelines, not rejected. See Script Integrity Monitoring for what the csp-hash type collects.

Status Meaning
204 Violation reports accepted. No body.
202 Script-hash reports accepted. Body carries accepted and correlationId.
400 invalid_path or invalid_format — see below.
404 not_found — no site is accepting reports for this code.
429 rate_limited or quota_exceeded. Carries Retry-After in whole seconds.

Every response carries an X-Correlation-ID header. Quote it when asking about a specific request; it is what traces that request through the pipeline.

Error bodies are uniform:

{
"error": "invalid_format",
"message": "Unknown report format"
}

A 204 is returned when the reports were accepted and when every report in the request was discarded because its document-uri did not match the site’s registered domain. The two are deliberately indistinguishable, so the endpoint cannot be used to enumerate the domains a tenant monitors.

This is the single most common reason reporting looks broken: a site registered as example.com discards reports from www.example.com, and the browser sees success either way. The site’s setup panel counts anything dropped for this reason — check it there, not in the response.

404 does not distinguish unknown from deactivated

Section titled “404 does not distinguish unknown from deactivated”

An unrecognised code and a code belonging to a deactivated site both return not_found, for the same reason: distinct statuses would let anyone spraying the 8-character keyspace confirm which codes belong to real customers. An owner who deactivated their own site already knows they did.

202 does not mean “every hash was recorded”

Section titled “202 does not mean “every hash was recorded””

accepted counts the reports published for processing, and may be 0 because sampling dropped the report, because the site’s sample rate is 0, or because the documentURL did not match the registered domain. The response does not distinguish them.

  • 100 reports per request. A request carrying more is truncated, not rejected — a 2xx means “accepted”, not “all of it stored”, and a browser that cannot tell a 400 from a 204 would simply retry.
  • Per-source rate limiting, applied before the ingest code is looked up, so it bounds a caller probing for valid codes as well as one flooding a known one. Exceeding it returns 429 with error: "rate_limited".
  • Monthly tenant quota. Past the hard limit, ingestion returns 429 with error: "quota_exceeded".

The endpoint accepts a hand-rolled report, which is the quickest way to confirm your ingest code reaches a live site. Use a document-uri on your registered domain, or it will be discarded and you will still get a 204.

Terminal window
cat > report.json <<'JSON'
{
"csp-report": {
"document-uri": "https://example.com/page",
"violated-directive": "script-src",
"original-policy": "default-src 'self'; report-uri /csp",
"blocked-uri": "https://evil.com/script.js"
}
}
JSON
curl -i -X POST https://ingest.headerhawk.com/csp/YOUR_INGEST_CODE \
-H "Content-Type: application/csp-report" \
--data-binary @report.json

A 204 with an X-Correlation-ID header means the code resolved to a site. The report appears in the dashboard once processing completes.

GET https://ingest.headerhawk.com/health

Returns 200 with {"status":"healthy","timestamp":"..."}. It touches no database and is there for uptime monitoring.