Skip to content
C50 Clause50EU AI Act transparency — made auditable.EU AI Act evidence, made auditable
All documentation

API

Push evidence in from your own CI, logs, or scripts with a per-system key: what it can and cannot do to your score, idempotency, and copy-paste examples.

Create a key on your system's API keys page — it is shown exactly once — and send this.

curl
curl -X POST https://clause50.com/api/ingest/v1/evidence \
  -H "Authorization: Bearer $CLAUSE50_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "events": [
    { "eventId": "gha-run-1928374-1",
      "kind": "commit_ref",
      "payload": {"sha":"9f2c","ref":"refs/heads/main"} }
  ] }'

Response — 201 because something was appended:

json
{ "results": [
  { "eventId": "gha-run-1928374-1", "status": "appended", "seq": "42" }
] }

More detail

What ingested evidence can and cannot do

Ingested evidence can satisfy the checks that are decided by evidence. It can never satisfy the ones that are decided by a person. That is not a caveat — it is the design, stated as a commitment rather than an apology. No other product in this space draws the line at all.

Concretely: this API can write five kinds of evidence — trace_batch, usage_stat, eval_result, commit_ref, config_snapshot. Of those, exactly four requirements in the shipped pack are decided automatically from evidence you ingest, and this is the complete list:

usage_stat and eval_result are real evidence too, and appear in your record and your documents, but neither currently moves a requirement by itself — the same honest gap the OpenAI and Anthropic connectors already document for usage totals.

An attestation can never be written through this API, by design and permanently. An attestation is a person making a statement about their system; a script making one would be the product manufacturing evidence about itself. There is no plan, scope, or configuration that unlocks this — if you need programmatic attestation, the honest answer is that a person has to make the statement. doc_upload is excluded too, for an unrelated and much less interesting reason: it needs a presign-upload-confirm round trip this single-`POST` API does not do. See Evidence for what a human attestation actually records.

Idempotency: a retry is a no-op, not a duplicate

Every event you send carries an eventId you choose — required, not optional. Send the same (key, eventId) pair twice and the second call appends nothing: you get back the status of the original event, including its original sequence number, at 200 instead of 201.

This is the opposite of the bulk-import behaviour on the Connectors page, where re-importing the same file appends every row again — deliberately, and for a reason that does not apply here. A human choosing to re-upload a file has made a choice; duplicating on that choice is the safe direction to be wrong in. A webhook retry is not a choice — it is a timeout, and the sender usually cannot tell whether the first attempt landed. Making a retry safe is what makes this API usable by something that never chooses to send an event twice.

One key per system, shown once, revoke instead of rotate

A key is bound to exactly one AI system at the moment you create it, and that binding cannot change. The honest trade this creates: if you run five AI systems and want one pipeline pushing evidence into all five, you need five keys and five secrets in your CI. That is deliberate — a leaked key only ever costs you the one system it was bound to.

The full key is shown exactly once, on your system's API keys page, at the moment you create it. It is never shown again by any surface, including this one — Clause50 stores only a one-way hash of it, so there is nothing to show even if we wanted to. If a key is compromised or simply no longer needed, revoke it; there is no rotation flow, because create-a-new-one-then-revoke-the-old-one already is rotation. A revoked key stays visible in your key list, greyed out with the date it was revoked — it is never deleted, because its id still appears in the provenance of every evidence item it ever wrote, and that record can only be added to.

Four copy-paste examples

The curl example above is the first of these four. Each is under 15 lines, reads the key from an environment variable, and never prints it.

GitHub Actions — reporting a deploy from a workflow step, using a repository secret:

yaml
- name: Report this deploy to Clause50
  run: |
    curl -sf -X POST https://clause50.com/api/ingest/v1/evidence \
      -H "Authorization: Bearer ${{ secrets.CLAUSE50_INGEST_KEY }}" \
      -H "Content-Type: application/json" \
      -d '{ "events": [
        { "eventId": "${{ github.run_id }}-1",
          "kind": "commit_ref",
          "payload": {"sha":"9f2c","ref":"refs/heads/main"} }
      ] }'

Node — a log shipper reporting a batch of sampled traces:

javascript
const res = await fetch("https://clause50.com/api/ingest/v1/evidence", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CLAUSE50_INGEST_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    events: [{
      eventId: `traces-${Date.now()}`,
      kind: "trace_batch",
      payload: {"traces":[{"conversation_id":"c-0","first_message":"Hi — I'm an AI assistant. How can I help?"}]},
    }],
  }),
});

Python — the same batch, from a Python-based shipper:

python
import os, time, requests

requests.post(
    "https://clause50.com/api/ingest/v1/evidence",
    headers={"Authorization": f"Bearer {os.environ['CLAUSE50_INGEST_KEY']}"},
    json={"events": [{
        "eventId": f"traces-{int(time.time())}",
        "kind": "trace_batch",
        "payload": {"traces":[{"conversation_id":"c-0","first_message":"Hi — I'm an AI assistant. How can I help?"}]},
    }]},
)

Limits, rate limits, and status codes

Per request, without drama:

What each status code means:

recordedAt is our clock; occurredAt is yours. The row's recordedAt is always the moment Clause50 received the event — you cannot set it, and a request that tries to is a 422. If you know when the thing actually happened, send it as occurredAt; it is kept as metadata alongside your evidence, but it can never move you into a satisfied window retroactively. This is deliberate: a caller-settable clock would let a client backdate itself into compliance.

Not sure a key is wired up correctly? GET /api/ingest/v1/whoami with the same bearer token returns exactly what it is bound to — the system, the allowed kinds, and these same limits — and nothing about your evidence, coverage, or score.

No code access to your AI system? Tap the traffic instead

Everything above assumes you can add a line of code somewhere in your own pipeline. If you can't — the system sits behind an API gateway or edge proxy you don't own the application code for — you can still get evidence into Clause50 by shaping traffic you already control into this same endpoint. All three patterns below end in the identical POST /api/ingest/v1/evidence call above; Clause50 never runs, hosts, or holds credentials for any of the infrastructure described here.

Reverse-proxy / edge-worker: a copy-paste starting point

A worker that sits in front of your own LLM-provider endpoint or backend, passes the request/response through completely unchanged, and — in the background, never on the request path — extracts a trace_batch or config_snapshot and reports it. Zero added latency on the path your users actually feel.

docs/examples/reverse-proxy-worker.js — a full Cloudflare Worker, committed to this repo as a reference file. Clause50 never deploys or runs it; copy it into your own Cloudflare account (or the equivalent on any edge platform) and point it at your ingest key.

javascript
// Clause50 reverse-proxy reference worker (docs/examples/reverse-proxy-worker.js)
// Copy-paste starting point — Clause50 never deploys or runs this for you.
//
// Sits in front of your own LLM-provider endpoint or backend. The customer-facing
// request/response is passed through UNCHANGED, at unchanged latency. In the background
// (event.waitUntil, non-blocking) it extracts a trace_batch record and reports it to Clause50.
//
// HARD RULE (never relax this): only ever emit the five machine-safe kinds
// (trace_batch, usage_stat, eval_result, commit_ref, config_snapshot). Never forward a field
// that could read as a human attestation (e.g. "reviewed_by" / "approved").
export default {
  async fetch(request, env, ctx) {
    const response = await fetch(request); // unchanged pass-through, customer's own backend

    ctx.waitUntil(reportTrace(request, response, env).catch(() => {})); // never blocks or throws

    return response;
  },
};

async function reportTrace(request, response, env) {
  const body = await request.clone().json().catch(() => null);
  if (!body) return;

  await fetch("https://clause50.com/api/ingest/v1/evidence", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${env.CLAUSE50_INGEST_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      events: [{
        eventId: crypto.randomUUID(),
        kind: "trace_batch",
        payload: {"traces":[{"conversation_id":"c-0","first_message":"Hi — I'm an AI assistant. How can I help?"}]},
      }],
    }),
  });
}

Can't run Cloudflare Workers? The same shape works from an nginx/OpenResty location block — this is a sketch to adapt, not a runnable file:

lua
-- nginx.conf, inside a location block (openresty / lua-nginx-module) — sketch only, not runnable.
-- access_by_lua_block or log_by_lua_block:
--   1. Read the request/response body you already have in scope.
--   2. Build the same { events: [{ eventId, kind: "trace_batch", payload }] } shape as the
--      Cloudflare Worker above (see docs/examples/reverse-proxy-worker.js).
--   3. Fire-and-forget it with an async HTTP client (e.g. resty.http) so this never adds
--      latency to the request nginx is actually proxying.
--   4. Same hard rule as the worker: only the five machine-safe kinds, never an attestation
--      field.

This worker (and any adaptation of it) must only ever emit the five machine-safe kinds trace_batch, usage_stat, eval_result, commit_ref, config_snapshot. Never forward a field that could read as a human attestation (a "reviewed by" or "approved" flag) — that is a hard rule, not a suggestion, for the same reason this API can never write an attestation directly.

API Gateway tap

If your traffic already passes through an API gateway (Kong, Envoy, Apigee, AWS API Gateway, …), its own async-logging or webhook configuration can forward matched requests to the same endpoint — no code, no reference file to maintain, because every gateway's configuration language is different and yours already knows how to adapt a documented target shape.

Kong — an http-log plugin (or a small transform plugin ahead of it, since Kong's stock log format is not the ingest envelope) pointed at the endpoint:

yaml
# kong.yml — http-log plugin pointed at the ingest endpoint
plugins:
  - name: http-log
    config:
      http_endpoint: https://clause50.com/api/ingest/v1/evidence
      method: POST
      headers:
        Authorization: Bearer <CLAUSE50_INGEST_KEY>
      # Map the fields Clause50 accepts (eventId, kind, payload) in a pre-function
      # or serverless-function plugin ahead of http-log — Kong's stock log format
      # is not the ingest envelope, so a small transform plugin sits between them.

Envoy — an access-log service or tap filter forwarding matched requests through a small translator that reshapes Envoy's log format into the ingest envelope:

yaml
# envoy.yaml — access-log service (ALS) or tap filter forwarding matched requests
access_log:
  - name: envoy.access_loggers.http_grpc
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.access_loggers.grpc.v3.HttpGrpcAccessLogConfig
      common_config:
        log_name: clause50-evidence
        grpc_service:
          envoy_grpc: { cluster_name: clause50_als_translator }
# The ALS stream is Envoy's native format, not the ingest envelope — a small translator
# service between Envoy and Clause50 reshapes each log entry into
# { eventId, kind: "trace_batch"|"config_snapshot"|..., payload } before POSTing it on.

The same hard rule applies here as above: a gateway tap may only ever produce one of the five machine-safe kinds, never an attestation.

CMS publish hook (WordPress)

Publishing AI-generated content through a CMS? Report the publish event after it happens, rather than trying to gate it live — there is no "is this covered yet?" check to call before you publish, and out-of-band reporting is the same pattern as everything else on this page.

docs/examples/wordpress-publish-hook.php — a WordPress transition_post_status hook, committed to this repo as a reference file. Clause50 never installs or runs it; copy it into your own theme or a small custom plugin and point it at your ingest key.

php
<?php
/**
 * Clause50 publish-hook reference snippet (docs/examples/wordpress-publish-hook.php)
 * Copy-paste starting point — Clause50 never installs or runs this for you.
 *
 * On publish, reports a config_snapshot EVENT (metadata only) to Clause50 — never a live
 * pre-publish query. There is no "is this covered yet?" check to call: this plugin only
 * records what happened, after it happened, exactly like every other 27a integration.
 *
 * HARD RULE (never relax this): only ever send metadata — content type, the AI system this
 * post is bound to, whether a disclosure was shown, and the publish time. NEVER send the post
 * title, body, or any excerpt. That is not a suggestion; it is the same machine-safe-kinds rule
 * every other sidecar pattern on this page follows.
 */

add_action( 'transition_post_status', 'clause50_report_publish', 10, 3 );

function clause50_report_publish( $new_status, $old_status, $post ) {
	if ( $new_status !== 'publish' || $old_status === 'publish' ) {
		return; // only the moment a post first goes live, not every subsequent save
	}

	$ingest_key = getenv( 'CLAUSE50_INGEST_KEY' ); // set in wp-config.php, never in the DB
	if ( ! $ingest_key ) {
		return;
	}

	$body = wp_json_encode( array(
		'events' => array( array(
			'eventId' => 'wp-' . $post->ID . '-' . $post->post_modified_gmt,
			'kind'    => 'config_snapshot',
			'payload' => array(
				'contentType'     => $post->post_type,
				'aiSystemBinding' => get_post_meta( $post->ID, 'clause50_ai_system', true ),
				'disclosureShown' => (bool) get_post_meta( $post->ID, 'clause50_disclosure_shown', true ),
				'publishedAt'     => get_post_time( 'c', true, $post ),
			),
		) ),
	) );

	// Fire-and-forget: never blocks or delays the publish action itself.
	wp_remote_post( 'https://clause50.com/api/ingest/v1/evidence', array(
		'blocking' => false,
		'headers'  => array(
			'Authorization' => 'Bearer ' . $ingest_key,
			'Content-Type'  => 'application/json',
		),
		'body'     => $body,
	) );
}

Metadata only, never post content: content type, the AI system the post is bound to, whether a disclosure was shown, and the publish time. The hook never sends a title, body, or excerpt — the same machine-safe-kinds rule as the reverse-proxy worker above, applied here because post content is very often end-user-facing data that has no reason to leave your own site.

OpenTelemetry: planned, not yet supported

OTel support: planned, tracking upstream semantic-convention stability. The GenAI semantic conventions (gen_ai.* span/event attributes) are still stabilizing. Building a translator against them now risks a rewrite the first time an attribute name changes, so this stays a placeholder rather than a mapping likely to break silently. If you already run an OTel Collector, the reverse-proxy pattern above is the nearest fit today.