REST API
Send events directly to SignalVault. No SDK required — use any HTTP client in any language.
Base URL
https://api.signalvault.io
Authentication
All API requests require a Bearer token:
Authorization: Bearer sk_live_your_api_key
POST /v1/events
Ingest an event into SignalVault.
Event types
There are two kinds of events: ones you submit, and ones SignalVault generates internally.
| Type | Submitted by | Description |
|---|---|---|
| ai.request | You | Send before calling your AI provider. SignalVault evaluates rules and returns a decision. |
| ai.response | You | Send after your AI provider responds. SignalVault runs post-flight rules and records cost. |
| ai.error | You | Send if your AI provider call fails (timeout, rate limit, etc.). Logged for observability. |
| agent.tool_call | You | Audit a tool call made by an AI agent. Rules are evaluated against the tool's input and output, so this can return a block decision too. |
| policy.violation | SignalVault | Automatically created when a guardrail rule fires. Cannot be submitted directly. |
| budget.exceeded | SignalVault | Automatically created when a cost or token budget is exceeded. Cannot be submitted directly. |
Submitting a policy.violation or budget.exceeded event directly will return a 422 error.
These are generated by SignalVault automatically — you never need to send them yourself.
Request body fields
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | Yes | See event types above |
| request_id | string | Usually | Correlates the events of one call. Generated for you on an ai.request if omitted; required on ai.response and ai.error, and optional on agent.tool_call. |
| event_id | string | No | Idempotency key — safe to retry with the same value |
| environment | string | Ignored | Taken from your API key. A value sent here is overwritten, so guardrails scoped to one environment cannot be skipped. |
| provider | string | No | Any provider name you use, for grouping in the dashboard. Defaults to openai. |
| model | string | No | Model name, e.g. gpt-4o-mini. Also used to price the call when you do not send cost_usd. |
| metadata | object | No | Arbitrary key/value pairs (user_id, session_id, etc.) |
| payload | object | No | Event-specific data (see below) |
| payload.monitor_mode | boolean | No | Marks the event as observe-only, so the dashboard shows it as mirror-mode traffic. It does not change rule evaluation. The SDKs set it in mirror mode. |
ai.request payload
{"messages": [{"role": "user", "content": "Hello"}]}
ai.response payload
{"output": "Hi there!", "usage": {"prompt_tokens": 10, "completion_tokens": 8}, "cost_usd": 0.0018}
An ai.response must carry the
request_id of an
ai.request you already sent, or it is rejected with
404. Its environment, provider and model are inherited from
that request, so you do not need to repeat them.
cost_usd is optional: when you leave it out, SignalVault
computes a cost from usage and the model name. Usage may use
either OpenAI's prompt_tokens and
completion_tokens or Anthropic's
input_tokens and
output_tokens; both are read for costing and for the daily
token budget. Anthropic's prompt cache counts,
cache_read_input_tokens and
cache_creation_input_tokens, are priced at Anthropic's cache
prices. Cache writes are priced at the 5-minute rate unless
cache_creation says how many went to the 1-hour cache.
A model SignalVault has no list price for records no cost, rather than a guess: the request shows that
the cost is not available, and the Costs page says how many responses its totals exclude. Send
cost_usd yourself for an exact figure — always worth doing
if you have committed-use pricing, or route through Azure, Bedrock or Vertex, where your rate differs
from list price anyway.
agent.tool_call payload
Audit a tool call your agent made. tool_name and
duration_ms are required; a payload missing either returns
422 with
{"error": {"type": "invalid_payload"}}.
Set request_id to tie the call to its parent
ai.request, or leave it out for a call with no single parent turn.
| Field | Type | Required | Notes |
|---|---|---|---|
| tool_name | string | Yes | 1–200 bytes |
| duration_ms | integer | Yes | 0 or greater |
| tool_input | any JSON | No | Encrypted at rest. A hash is stored beside it, so a later change to the stored value can be detected. |
| tool_output | any JSON | No | Encrypted at rest. A hash is stored beside it, so a later change to the stored value can be detected. |
| error | string | No | Up to 2 000 bytes, when the tool failed |
| started_at | string | No | ISO 8601. Falls back to when SignalVault received the event. |
{"type": "agent.tool_call", "request_id": "...", "payload": {"tool_name": "fetch_weather", "tool_input": {"city": "Stockholm"}, "tool_output": {"temp_c": 7}, "duration_ms": 142}}
metadata example
Use metadata to attach arbitrary key/value pairs — user IDs, session IDs, feature flags — that appear in the dashboard and in the JSON export:
{"type": "ai.request", "metadata": {"user_id": "u_abc123", "session_id": "sess_xyz", "feature": "chat-v2"}, "payload": {"messages": [{"role": "user", "content": "..."}]}}
Put prompts in the keys SignalVault reads
Guardrails read payload.messages on an
ai.request and
payload.output on an
ai.response.
Text under another key is never scanned. Send a prompt as
payload.prompt and it is stored unscanned, returning a clean
allow because no rule looked at it.
Within the messages of a request, guardrails evaluate message text, tool results, and tool inputs and
tool-call arguments. Tool inputs and tool-call arguments are read by their string values, for OpenAI and
Anthropic alike. Keys and numbers inside tool inputs and tool-call arguments are not evaluated. An arguments
string, or a tool input given as a string, that is not valid JSON is evaluated as raw text. A tool input or arguments string that repeats a key
is read by value like any other, with every copy of the key included.
Encryption covers a fixed set of keys too:
messages and tools on a request,
output on a response, the input and output of a tool call, and
error, message,
detail and description on an
ai.error. Anything else is stored as you sent it, in clear. Note also
that a tools value that is not a non-empty list is dropped rather
than stored.
Responses
The response shape depends on the event type:
| Event type | Response |
|---|---|
| ai.request | Decision + violations + redactions. Use decision to decide whether to proceed with the AI call. |
| ai.response | {"event_id": "...", "status": "recorded", "violations": []} — any post-flight violations are included. |
| ai.error | {"event_id": "..."} — acknowledgement only. |
| agent.tool_call | {"event_id": "...", "decision": "allow", "violations": []} — rules are evaluated against the tool call too. |
{"event_id": "...", "decision": "redact", "violations": [...], "redactions": [{"type": "contains_pii", "count": 2}]}
HTTP Status Codes
| Code | Meaning |
|---|---|
| 200 | Accepted. A blocked prompt is also a 200, carrying "decision": "block" — read the decision, not the status. |
| 401 | Invalid, revoked or missing API key |
| 402 | subscription_inactive (no plan in service), app_inactive (the app has no active API key), or app_paused (the app is paused, because the plan allows fewer active apps or because it was paused in Account › Billing; the body's upgrade_url is Account › Billing) |
| 403 | environment_not_allowed — the organization's plan does not permit this key's environment. Every live plan permits all three, so in practice this means the subscription has lapsed. |
| 404 | {"error": "Request not found"} — an ai.response whose request_id has no matching ai.request |
| 400 | Body is not valid JSON |
| 413 | Request body over 4 MB |
| 422 | Invalid body or payload, or a system-generated event type submitted directly |
| 429 | Trial limit reached, or rate limited. The bodies differ — see below. |
| 503 | encryption_unavailable — the event was not stored. Send it again with the same event_id. |
Telling the 429s apart
The trial limit returns an object; the rate limits return a bare string, and a request recorded over the
rule-check limit adds recorded: true. Check that
error is an object before reading
error.type:
// Trial limit — HTTP 429
{"error": {"type": "trial_limit_exceeded", "code": "trial_limit_exceeded",
"message": "Your SignalVault trial has ended. Choose a plan to continue: ...",
"upgrade_url": "https://signalvault.io/account/billing"}}
// Over the rule-check limit — HTTP 429, with a Retry-After header in seconds.
// The ai.request is recorded, unchecked; do not send it again.
{"error": "rate_limited", "reason": "pre_flight_rate_limited", "recorded": true,
"event_id": "...", "decision": "unchecked"}
// Over an event ceiling — HTTP 429, with a Retry-After header in seconds.
// Nothing was stored; send the same event again after the delay.
{"error": "rate_limited"}
A lapsed subscription returns 402, not
429, and its body is flat:
{"error": "subscription_inactive", "message": "…"}.
Rate limits
| Endpoint | Limit | Key |
|---|---|---|
| ai.request rule checks | 120 / 60 s | Per app. Over it the request is recorded without a check — see below. |
| POST /v1/events | 3,000 events / 60 s | Per app, every event type together. A ceiling, not the product limit. |
| POST /v1/events | 3,000 events / 60 s | Per IP, checked before authentication. A floor against unauthenticated floods. |
| POST /proxy/* | 60 req / 60 s | Per app |
Over the rule-check limit, an ai.request is recorded with the decision
unchecked and the reason pre_flight_rate_limited: no rule runs, and
only its metadata is stored, never the prompt, since nothing could be redacted from it. The answer is a
429 with recorded: true. Don't send that request again; if
you forward it to the provider anyway, send its ai.response as usual, and it is linked to
the recorded request. Add "fail_mode": "open" or "closed" to the
request's payload and the record also says whether the request was forwarded unchecked.
When a limit is exceeded, SignalVault returns 429 with a
Retry-After header in seconds, and retrying after that delay
is the right response, except for a request already recorded unchecked. The trial-limit 429 carries no
Retry-After, because waiting will not clear it — retrying that
one is pointless until the app is subscribed.