Customer API v1
Every request, signed.
A control plane for your systems, sources, findings and scans. Each call carries a bearer token and an Ed25519 signature, and each key can make 60 requests per minute. It never proxies your database and never returns row data.
- Base URL
https://secureraven.app/api/v1- Signing
- Ed25519 over a canonical v2 message. No HMAC and no bearer-only mode.
- Limits
- 60 requests per minute per key, 300 per organization. Bodies up to 256 KiB.
- Keys
- Minted in Settings → API Keys after an MFA step-up. Shown once.
- Not in v1
- SQL, row data, webhooks and SDKs.
v2versionGETmethod/api/v1/findingspathname1792159520timestamp0c2b6d4e…6c15nonceapplication/jsoncontent-typee3b0c442…b855sha256(body)9b1f4e27…6f45token id2schema
Ed25519Signed with your sr_sign_… private key. Secure Raven never stores it.
x-raven-signature8e21b7f0c94a1d6e5b03…a9d4c27e3c7f
- ±300 s clock window
- 1 use per nonce
- 64 bytes · 128 hex
On this page9 sections
Authentication
Every /api/v1 request needs a bearer token and an Ed25519 request signature. Owners and admins mint keys in the dashboard under Settings → API Keys, after a fresh MFA step-up. Minting shows three values once.
| Value | Format | What Secure Raven keeps |
|---|---|---|
| Bearer token | sr_live_…, or sr_probe_… for a probe key | A SHA-256 hash only. |
| Token id | A UUID | The id of the key. |
| Private signing key | sr_sign_ and the base64url of the PKCS#8 DER Ed25519 private key, 64 characters | Nothing. Only the 32-byte public key. |
Secure Raven cannot show or recover a private key. If one is lost or exposed, revoke the key and mint a new one. Revocation is final. A key’s scopes and public key never change after mint, and its expiry can only move earlier.
Headers
| Header | Value | Rule |
|---|---|---|
Authorization | Bearer sr_live_… | The bearer token. Bearer-only requests are refused. |
x-raven-timestamp | Unix seconds, digits only | Within ±300 seconds of server time. |
x-raven-nonce | 8 to 128 characters: A–Z a–z 0–9 . _ ~ - | Unique per key. A replayed nonce is refused. |
x-raven-token-id | The token id shown at mint | Must match the key the bearer token belongs to. |
x-raven-signature | 128 hex characters | The 64-byte Ed25519 signature over the canonical message. |
x-raven-schema | 2 | Optional. Any other value is refused. |
Signing a request
Join nine values with a vertical bar, then sign the UTF-8 bytes with the private key.
v2|METHOD|pathname|timestamp|nonce|content-type|sha256(body)|tokenId|2- The pathname is the URL path without the query string:
/api/v1/findings. - GET, HEAD and DELETE sign an empty body, so their digest is always
e3b0c442…b855. - Sign the content type as
application/json, even on GET. A request sent with any other content type is refused. sha256(body)is the lowercase hex digest of the exact body bytes you send.- The organization comes from the key. Never send
tenant_id. - There is no HMAC mode, no legacy v1 and no bearer-only mode.
In Node.js 18 or later
import { createHash, createPrivateKey, randomUUID, sign } from "node:crypto";
// The three values shown once when the key was minted
const token = process.env.RAVEN_BEARER;
const tokenId = process.env.RAVEN_TOKEN_ID;
const raw = process.env.RAVEN_SIGNING_KEY.slice("sr_sign_".length);
const key = createPrivateKey({
key: Buffer.from(raw, "base64url"),
format: "der",
type: "pkcs8",
});
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomUUID();
// GET, HEAD and DELETE sign an empty body
const body = "";
const digest = createHash("sha256").update(body).digest("hex");
const message = [
"v2", "GET", "/api/v1/findings", timestamp, nonce,
"application/json", digest, tokenId, "2",
].join("|");
const signature = sign(null, Buffer.from(message), key).toString("hex");
// The query string is not part of the signed pathname
const res = await fetch("https://secureraven.app/api/v1/findings?status=open", {
headers: {
authorization: `Bearer ${token}`,
"x-raven-timestamp": timestamp,
"x-raven-nonce": nonce,
"x-raven-token-id": tokenId,
"x-raven-signature": signature,
},
});With OpenSSL 3 instead
# OpenSSL 3. Ed25519 signs a file, not a pipe.
umask 077
printf '%s' "${RAVEN_SIGNING_KEY#sr_sign_}" | tr '_-' '/+' | base64 -d \
| openssl pkey -inform DER -out key.pem
printf '%s' "$canonical" > msg.txt
signature="$(openssl pkeyutl -sign -inkey key.pem -rawin -in msg.txt \
| od -An -v -tx1 | tr -d ' \n')"
rm -f key.pem msg.txtExample: open high findings
A signed GET and the response. Fields are the minimum needed: no details dump, no key previews, no rows. Lists hold at most 100 rows, newest first.
GET /api/v1/findings?status=open&severity=high HTTP/1.1
Host: secureraven.app
Authorization: Bearer sr_live_••••••••••••••••
X-Raven-Timestamp: 1792159520
X-Raven-Nonce: 0c2b6d4e-5f71-4a9c-b3e8-71d2f09a6c15
X-Raven-Token-Id: 9b1f4e27-63d0-4c8a-a5e2-7d10c3b86f45
X-Raven-Schema: 2
X-Raven-Signature: 8e21b7f0c94a1d6e5b03…a9d4c27e3c7f{
"findings": [
{
"id": "3f0c9a62-7b1e-4d0a-9c55-2e81b6f4d7a0",
"severity": "high",
"category": "headers.hsts_missing",
"title": "Missing Strict-Transport-Security header",
"remediation": "Add: Strict-Transport-Security: max-age=31536000; includeSubDomains; preload",
"status": "open",
"created_at": "2026-10-16T14:02:41.482913+00:00",
"updated_at": "2026-10-16T14:02:41.482913+00:00",
"system_id": "b6d1e2a4-0f3c-4e9b-8a71-2c5d9e0f1a36"
}
]
}Endpoints
Fourteen routes under https://secureraven.app/api/v1. The scope column names what the key needs.
Systems
The applications and sites you inspect.| Route | Scope | What it does |
|---|---|---|
GET/api/v1/systems | findings:read | List systems, newest first: id, name, external_id, environment, kind and approved. |
POST/api/v1/systems | sources:write | Create a system. |
Sources
Websites and databases attached to a system. Credentials never come back.| Route | Scope | What it does |
|---|---|---|
GET/api/v1/sources | findings:read | List sources: id, name, kind, host, status, system_id, scan_authorized_at and connection_mode. |
POST/api/v1/sources | sources:write | Create a draft source. A draft never takes a secret. |
DELETE/api/v1/sources/:id | sources:write | Disconnect a source and erase its credentials. |
POST/api/v1/sources/:id/risk-accept | sources:write | Accept the source’s current risk statement. |
POST/api/v1/sources/:id/credential | sources:write, and sources:privileged for privileged kinds | Submit a credential once. It is never echoed. Storing database credentials is off in production today, and privileged kinds return privileged_connections_disabled. |
POST/api/v1/sources/:id/test | sources:write | Test the connection. A passing test never authorizes a scan, Watch or Shield. |
POST/api/v1/sources/:id/authorize-scan | sources:write | Authorize safe_all_surfaces and verify_finding scans for this source. |
Findings
Titles, severity, status and remediation. Never row data, and never the details that could hold a key preview.| Route | Scope | What it does |
|---|---|---|
GET/api/v1/findings | findings:read | List findings, newest first. Filter with severity, status and category; category=hipaa matches every HIPAA Hawk finding. |
GET/api/v1/findings/:id | findings:read | Read one finding. |
Scans
Scan sessions and their verdicts. Never worker logs.| Route | Scope | What it does |
|---|---|---|
GET/api/v1/scans | findings:read | List scan sessions: profile, status, verdict, and how many runs were planned and succeeded. |
GET/api/v1/scans/:id | findings:read | Read one scan, with each run’s Raven and status. |
POST/api/v1/scans | scans:write | Enqueue a safe_all_surfaces scan for exactly one system_id or source_id, with an idempotency_key of at least 8 characters. 201 when it is created, 200 when the same request is reused. |
{
"system_id": "b6d1e2a4-0f3c-4e9b-8a71-2c5d9e0f1a36",
"idempotency_key": "release-2026-10-16"
}Scopes
A dashboard key gets ingest, findings:read, scans:write and sources:write. A probe key gets only ingest: it pushes events for the one system it is bound to, and can do nothing else.
| Scope | Allows |
|---|---|
ingest | Push probe events to the Edge ingest functions. Nothing else. |
findings:read | List and read systems, sources, findings and scans. |
scans:write | Enqueue safe_all_surfaces scans. |
sources:write | Create systems and draft sources, accept a risk statement, test, authorize scans and disconnect. |
sources:privileged | Submit privileged secrets for database or service-role sources. Added only when an owner or admin asks for it at mint. It is a standing credential, not MFA on each request. |
Limits and errors
Each key can make 60 requests per minute, and each organization 300. Bodies can be up to 256 KiB. Every error is a JSON object with an error string, {"error": "…"}. Some add an error_code, or the fields that did not validate.
| Status | Body | When |
|---|---|---|
400 | {"error":"Invalid body"} | A body that is not a JSON object or a field that does not validate. Some routes name the rule instead, such as sending both system_id and source_id. |
401 | {"error":"Unauthorized"} | Every authentication failure: a missing bearer token or signature header, a bad signature, a timestamp outside ±300 seconds, a reused nonce, a revoked or expired key, and the rate limit. The body never says which. |
403 | {"error":"Forbidden"} | A valid signature from a key without the scope. Scopes are checked after the signature, so a bearer token alone learns nothing about its key. |
404 | {"error":"Not found"} | No such object in your organization. |
413 | {"error":"Payload too large"} | A body over 256 KiB. |
500 | {"error":"Request failed"} | Something failed on our side. Retry with a new timestamp, nonce and signature. |
A nonce is spent even when a request is refused for the rate limit. Every retry needs a new timestamp, nonce and signature.
Not in v1
What the API leaves out, on purpose or for now. Written down, so you can hold us to it.
- By designNo SQL, REST or row passthrough. There is no /query, /sql or /rest. The API never forwards a query to your database and never returns rows.
- By designNo secrets on the way out. No decrypted credentials and no full event payloads.
- Not in v1No controls or evidence routes. Controls and evidence exports stay in the dashboard.
- Not in v1No webhooks and no SDKs. Poll instead, as in the CI release gate below.
- By designA passing test authorizes nothing. A successful connection test never turns on Watch, Shield or a scan.
- By designNo Full Active scans from a key. API keys enqueue safe_all_surfaces only. full_active cannot be started with an API key.
CI release gate
There is no findings webhook, so a pipeline polls. To fail a release on unresolved high-severity posture:
- Keep the
sr_live_…bearer token, thesr_sign_…private key and the token id in your CI secret store. Never commit them. - Call
GET /api/v1/findings?status=openwith a fresh signature. The signed pathname is/api/v1/findings, without the query string. - Count the rows whose severity is critical or high. Fail the job if there are any.
- Log counts by severity only. Titles and remediation are confidential, so keep them out of public logs.
- The list holds at most 100 rows. If you get 100, fail and ask a person to look: never assume the rest are low.
#!/usr/bin/env bash
# Fail the build on open critical or high findings. Needs OpenSSL 3, curl and jq.
# RAVEN_BEARER, RAVEN_SIGNING_KEY and RAVEN_TOKEN_ID come from your CI secret store.
set -euo pipefail
umask 077
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
ts="$(date +%s)"
nonce="$(openssl rand -hex 16)"
# SHA-256 of the empty body that every GET signs
empty="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
printf '%s' "v2|GET|/api/v1/findings|$ts|$nonce|application/json|$empty|$RAVEN_TOKEN_ID|2" \
> "$work/msg"
printf '%s' "${RAVEN_SIGNING_KEY#sr_sign_}" | tr '_-' '/+' | base64 -d \
| openssl pkey -inform DER -out "$work/key.pem"
sig="$(openssl pkeyutl -sign -inkey "$work/key.pem" -rawin -in "$work/msg" \
| od -An -v -tx1 | tr -d ' \n')"
curl -fsS "https://secureraven.app/api/v1/findings?status=open" \
-H "Authorization: Bearer $RAVEN_BEARER" \
-H "x-raven-timestamp: $ts" -H "x-raven-nonce: $nonce" \
-H "x-raven-token-id: $RAVEN_TOKEN_ID" -H "x-raven-signature: $sig" \
-o "$work/findings.json"
# Counts by severity only. Titles and remediation stay out of public logs.
jq -r '.findings | group_by(.severity) | map("\(.[0].severity)=\(length)") | join(" ")' \
"$work/findings.json"
total="$(jq '.findings | length' "$work/findings.json")"
blocking="$(jq '[.findings[] | select(.severity == "critical" or .severity == "high")] | length' \
"$work/findings.json")"
if [ "$total" -ge 100 ]; then echo "100 rows is the cap: failing rather than guessing"; exit 1; fi
if [ "$blocking" -gt 0 ]; then echo "$blocking open critical or high findings"; exit 1; fi
echo "No open critical or high findings"Probe ingest
The push probe in your database signs its telemetry the same way. It sends the same headers and the same canonical message to POST /functions/v1/events-ingest, and Shield sends its events to POST /functions/v1/shield-ingest.
- The signed pathname is always the constant
/functions/v1/<function>, whatever URL the request travels through. - A probe key carries only the ingest scope, is bound to one system, and expires after a year.
- In this mode Secure Raven holds a hash of the probe’s token and its Ed25519 public key. No database password.
Questions about the API? Write to support@secureraven.app, or read how Secure Raven secures itself.