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.
Canonical message · v2Sample
  1. v2version
  2. GETmethod
  3. /api/v1/findingspathname
  4. 1792159520timestamp
  5. 0c2b6d4e…6c15nonce
  6. application/jsoncontent-type
  7. e3b0c442…b855sha256(body)
  8. 9b1f4e27…6f45token id
  9. 2schema

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
Sample: the canonical message for a GET of /api/v1/findings. Nine parts joined with a vertical bar: version, method, pathname, timestamp, nonce, content type, the SHA-256 of the body, the token id and the schema. Signing it with the Ed25519 private key gives the 64-byte signature sent as 128 hex characters in the x-raven-signature header.
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.

The three values shown when a key is minted
ValueFormatWhat Secure Raven keeps
Bearer tokensr_live_…, or sr_probe_… for a probe keyA SHA-256 hash only.
Token idA UUIDThe id of the key.
Private signing keysr_sign_ and the base64url of the PKCS#8 DER Ed25519 private key, 64 charactersNothing. 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

Request headers
HeaderValueRule
AuthorizationBearer sr_live_…The bearer token. Bearer-only requests are refused.
x-raven-timestampUnix seconds, digits onlyWithin ±300 seconds of server time.
x-raven-nonce8 to 128 characters: A–Z a–z 0–9 . _ ~ -Unique per key. A replayed nonce is refused.
x-raven-token-idThe token id shown at mintMust match the key the bearer token belongs to.
x-raven-signature128 hex charactersThe 64-byte Ed25519 signature over the canonical message.
x-raven-schema2Optional. 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.

Canonical message · UTF-8, joined with |
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

sign-request.mjs · node:crypto, no dependencies
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
sign.sh · the canonical message in $canonical
# 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.txt

Example: 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.

Request · findings.httpSample
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
200 OK · application/jsonSample
{
  "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.
Systems routes
RouteScopeWhat it does
GET/api/v1/systemsfindings:readList systems, newest first: id, name, external_id, environment, kind and approved.
POST/api/v1/systemssources:writeCreate a system.

Sources

Websites and databases attached to a system. Credentials never come back.
Sources routes
RouteScopeWhat it does
GET/api/v1/sourcesfindings:readList sources: id, name, kind, host, status, system_id, scan_authorized_at and connection_mode.
POST/api/v1/sourcessources:writeCreate a draft source. A draft never takes a secret.
DELETE/api/v1/sources/:idsources:writeDisconnect a source and erase its credentials.
POST/api/v1/sources/:id/risk-acceptsources:writeAccept the source’s current risk statement.
POST/api/v1/sources/:id/credentialsources:write, and sources:privileged for privileged kindsSubmit 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/testsources:writeTest the connection. A passing test never authorizes a scan, Watch or Shield.
POST/api/v1/sources/:id/authorize-scansources:writeAuthorize 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.
Findings routes
RouteScopeWhat it does
GET/api/v1/findingsfindings:readList findings, newest first. Filter with severity, status and category; category=hipaa matches every HIPAA Hawk finding.
GET/api/v1/findings/:idfindings:readRead one finding.

Scans

Scan sessions and their verdicts. Never worker logs.
Scans routes
RouteScopeWhat it does
GET/api/v1/scansfindings:readList scan sessions: profile, status, verdict, and how many runs were planned and succeeded.
GET/api/v1/scans/:idfindings:readRead one scan, with each run’s Raven and status.
POST/api/v1/scansscans:writeEnqueue 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.
POST /api/v1/scans · bodySample
{
  "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.

API scopes
ScopeAllows
ingestPush probe events to the Edge ingest functions. Nothing else.
findings:readList and read systems, sources, findings and scans.
scans:writeEnqueue safe_all_surfaces scans.
sources:writeCreate systems and draft sources, accept a risk statement, test, authorize scans and disconnect.
sources:privilegedSubmit 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.

Error responses
StatusBodyWhen
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 design
    No 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 design
    No secrets on the way out. No decrypted credentials and no full event payloads.
  • Not in v1
    No controls or evidence routes. Controls and evidence exports stay in the dashboard.
  • Not in v1
    No webhooks and no SDKs. Poll instead, as in the CI release gate below.
  • By design
    A passing test authorizes nothing. A successful connection test never turns on Watch, Shield or a scan.
  • By design
    No 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:

  1. Keep the sr_live_… bearer token, the sr_sign_… private key and the token id in your CI secret store. Never commit them.
  2. Call GET /api/v1/findings?status=open with a fresh signature. The signed pathname is /api/v1/findings, without the query string.
  3. Count the rows whose severity is critical or high. Fail the job if there are any.
  4. Log counts by severity only. Titles and remediation are confidential, so keep them out of public logs.
  5. The list holds at most 100 rows. If you get 100, fail and ask a person to look: never assume the rest are low.
findings-gate.sh · OpenSSL 3, curl, jq
#!/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.