AI event logging with receipts signed with HMAC

spurvik@1.0.8 wraps your model client and records each call as a small receipt signed with HMAC SHA256. In the default keyed mode, the prompt and the output are recorded as keyed fingerprints, not as text. This page lists exactly what is recorded and where it goes, taken from the SDK's source and type definitions.

Article 50 does not require logging. A receipt is evidence for your own records: what model ran, when, and whether you had configured a notice.

Record a call

npm i spurvik openai
// logging.ts: record a call and check its receipt
import OpenAI from "openai";
import { withSpurvik, provenanceOf, verifyProvenanceSignature } from "spurvik";

const ai = withSpurvik(new OpenAI(), {
  apiKey: process.env.SPURVIK_API_KEY!,
  signingSecret: process.env.SPURVIK_SIGNING_SECRET!, // signs receipts; never sent
  contentHash: { mode: "keyed", secret: process.env.SPURVIK_CONTENT_HASH_SECRET! }, // never sent
  baseUrl: process.env.SPURVIK_BASE_URL!,
  riskTier: "limited_art50", // your own classification
});

const res = await ai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [{ role: "user", content: "Hello" }],
});

const receipt = await provenanceOf(res);
const matches = receipt
  ? await verifyProvenanceSignature(receipt, process.env.SPURVIK_SIGNING_SECRET!)
  : false;
console.log(receipt?.inputHash, receipt?.outputHash, matches);
await ai._spurvik.flush();

withSpurvik() wraps OpenAI, Anthropic or Gemini clients. For other stacks: spurvikMiddleware (spurvik/ai, Vercel AI SDK), withSpurvikMastra (spurvik/mastra), withSpurvikModel / withSpurvikModelProvider (spurvik/openai-agents), withSpurvikLangGraph (spurvik/langgraph).

Setup throws unless apiKey is a spurvik_live_ project key, and unless signingSecret and contentHash.secret are each at least 16 characters. baseUrl is required when NODE_ENV is production.

What the receipt contains

The receipt is returned to your code (provenanceOf(res)). Its signed fields:

FieldWhat it holds
kindAlways "provenance_record"
eventIdRandom UUID for this call
timestampISO time the receipt was made
modelIdModel id from the response, or "unknown"
modelProvideropenai, anthropic, google, mistral, open_weight or unknown
contentHashModekeyed (default), on or off
inputHashFingerprint of the prompt text, or null
outputHashFingerprint of the output text, or null
article50Disclosuretrue only if you set it in config; records your setting, not that a notice was shown
markingHookIdId of a marking hook you configured, or null
markingAppliedtrue only if a hook changed the content or the provider confirmed a mark
evidenceTrailerAttachedtrue if a signed text trailer was attached (not an Art. 50(2) mark)
upstreamMarkingWhat the call could see about provider marking: requested, provider_confirmed, not_requested, inferred or unknown
provenanceVersion2

Plus signature (hex HMAC SHA256 over the signed fields, keyed with your signingSecret) and signatureAlg ("HMAC-SHA-256"). If a marking hook ran, the receipt also carries markedOutput, the hook's output text, in your process only.

Example receipt from a test call (fake client, keyed mode):

{
  "kind": "provenance_record",
  "eventId": "9db41ec3-1f9a-4d02-9e26-aaceacab5cb4",
  "timestamp": "2026-11-11T13:19:46.844Z",
  "modelId": "gpt-4o-mini",
  "modelProvider": "openai",
  "contentHashMode": "keyed",
  "inputHash": "e17efcc344533455e5bdae06a004a3478cdf9f1690a619b20d36a7859ded603e",
  "outputHash": "ed688328146776bc622581e8dd24cd0ad27007e5f0746e27f6ff79f375b1b728",
  "article50Disclosure": false,
  "markingHookId": null,
  "provenanceVersion": 2,
  "markingApplied": false,
  "evidenceTrailerAttached": false,
  "upstreamMarking": "unknown",
  "signature": "97cf43f8d88634812fe2875ace9d563c4997f14fd6565a412323f36f278f28bd",
  "signatureAlg": "HMAC-SHA-256"
}

What is sent to the console

The SDK batches one event per call and posts it to {baseUrl}/api/v1/telemetry. Each event holds:

eventId, timestamp, modelProvider, modelVersion, inputHash, outputHash, contentHashMode, latencyMs, temperature, contentMarkingApplied, evidenceTrailerAttached, markingHookId, hitlStatus, upstreamMarking (a signed summary of the signals about marking by the provider: provider, model, modality, route kind, requested/returned marking parameters, status), clientInstanceId and clientSeq (so gaps in the sequence can be noticed).

In our test call, neither the prompt text nor the output text appeared in the posted body. The signing secret and the secret for content hashes are never sent.

How the fingerprints work

  • keyed (default): HMAC SHA256 of the text with your contentHash.secret. The secret stays in your process. This is designed to support pseudonymisation under GDPR Art. 4(5): without your key, the fingerprint cannot be recomputed from a guess.
  • on: unkeyed SHA256 of the text. Anyone who can guess the text can recompute it.
  • off: no fingerprint; inputHash and outputHash are null.

A fingerprint of text about an identifiable person can still be personal data. Neither mode is anonymisation; treat the fingerprints accordingly.

What a matching signature tells you

verifyProvenanceSignature(receipt, signingSecret) returns true when the signed fields still match the signature made with your signing secret. It does not show that a notice was shown, that output is marked, or that your system meets the Act. Anyone who holds the signing secret can sign edited fields again, so limit who holds it.

Retention and export

Where events are kept depends on the control plane at baseUrl. On the hosted console: Free 10,000 events/mo; Pro 250,000 events/mo; Team 2,000,000 events/mo.

The SDK and MCP server are MIT and run in your own stack.

Try it

  • Install: npm i spurvik and wrap your client with withSpurvik()
Copy the badge
<script src="https://spurvik.com/badge.js" data-system="Your AI" data-attribution="true" defer></script>
  • A small “Art. 50 · Spurvik” link shows after the notice by default. On Pro (€19/mo) you can hide it with data-attribution="false" for a white-label badge.

Free €0 (10k events/mo) · Pro €19/mo · Team €79/mo · excl. VAT · B2B only.

FAQ

Does Spurvik store my prompts?

In the default keyed mode, no. Receipts and console events hold keyed HMAC SHA256 fingerprints of the prompt and output, not the text.

Are the fingerprints personal data?

They can be. Keyed fingerprints are designed to support pseudonymisation while your key exists; they are not anonymisation.

What does a matching signature show?

That the signed fields still match the signature made with your signing secret. It does not show that a notice was shown, that output is marked, or that your system meets the Act.

Is logging required by Article 50?

No. Article 50 sets transparency duties; event receipts are optional evidence for your own records.

Which fields are sent to the console?

Event id, timestamp, model provider and version, input and output fingerprints, hash mode, latency, temperature, marking flags, a signed marking summary and a client sequence number. Never the secrets.

Sources

Reviewed 11 November 2026. Not legal advice. Supports parts of your Art. 50 work; responsibility stays with you. EyeTea Solutions ApS · Trekantområdet, Denmark

Your AI Act controls should live in a few lines of code, not in six months of legal memos.