Docs
Quickstart for the Disclarion Python SDK, the n8n community node, and the plain REST API underneath both, for every other language. This is the whole setup. There's no separate docs platform yet.
1. Install the SDK
The SDK is a pip package published as disclarion.
pip install disclarion2. Get an API key
Create a project in the dashboard and generate an API key for it. Keys look like dcl_live_... and are only shown once, so store it somewhere safe.
3. Log your first event
Wrap the response from your LLM provider call with dc.track() right after you get it back.
from disclarion import Disclarion
import openai
client = openai.OpenAI()
dc = Disclarion(api_key="dcl_live_...")
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hello"}],
)
dc.track(response, session_id="session-123")OpenAI, Anthropic, and Gemini responses are all supported out of the box: the SDK detects which provider a response came from automatically.
track() also takes jurisdiction (default "EU") and interaction_type (default "chat"), which feed the backend's Obligation Engine: which obligations (disclosure_modal, audit_log, content_label) actually apply to this pair. Today EU + chat and EU + generated_content (see "Labeling AI-generated content" below) are the two currently active rules; any other combination is still logged in full, it just comes back with no obligations:
dc.track(response, session_id="session-123", jurisdiction="US-CA", interaction_type="chat")4. Show the disclosure to your users
By default (disclosure_mode="modal_once"), track() never rewrites your model's reply: it attaches a disclarion_meta dict instead, and your own frontend decides what to show:
tracked = dc.track(response, session_id="session-123")
tracked.disclarion_meta
# {"ai_generated": True, "is_first_message": True, "disclosed": False,
# "logged_at": "...", "ack_token": "..."}is_first_message is what a disclosure widget should key off of: show a one-time, non-dismissable notice on True, then a small persistent badge for the rest of the session. The SDK ships a ready React and vanilla-JS implementation of exactly that pattern in widget/. Copy it into your frontend and wire your own chat API to forward is_first_message alongside each reply.
ack_token is present whenever a disclosure is owed. Forward it to the widget the same way, as the ackToken prop (React) or option (vanilla JS), and it reports back to Disclarion, on its own, when the notice actually renders and when the user clicks through it. This is the difference between a disclosure that was logged as owed and one your audit trail can show was actually seen. If your frontend can't call Disclarion's API directly, report it from your backend instead:
dc.record_disclosure_event(tracked.disclarion_meta["ack_token"], "acknowledged")Two things worth checking if a disclosure shows up as Not confirmed in the dashboard even though the widget is wired up correctly: your site's Content-Security-Policy needs connect-src to allow api.disclarion.com, or the request silently fails and the event never arrives; and an ad or privacy blocker in the visitor's own browser can do the same thing, which is outside anyone's control from your side. Both failure modes are why dc.record_disclosure_event() above exists: confirming server-side, from your own backend, isn't subject to either one.
disclosure_shown in the REST API (and the n8n node) is unrelated to any of this: it's a field the caller sets directly, so it records what the caller claims, not what actually happened. Still accepted for backward compatibility, but deprecated. The dashboard and compliance report label it Self-reported, separate from the confirmed status above.
Two other modes are available, set once on the client (not per call): inline_every_message rewrites the session's first reply to append a disclosure line directly into its text (the SDK's original behavior, kept for backward compatibility), and off is pure logging with no disclosure signal at all, for products with their own unrelated AI-disclosure UI. The audit trail (interaction_logs) is written the same way regardless of mode.
Labeling AI-generated content
disclosure_modal is about chatbots: a live conversation where a user needs a heads-up they're talking to an AI. Not everything track() logs is a conversation: a product description, a social media post, or an article drafted with an LLM is AI-generated content published somewhere, with no "session" a disclosure modal would ever attach to. For that, pass interaction_type="generated_content":
dc.track(response, session_id="post-42", interaction_type="generated_content")Today ("EU", "generated_content") is the one configured rule for this, and it returns a different obligation, content_label, not disclosure_modal. Unlike disclosure, this isn't a live UI component and isn't gated by disclosure_mode at all: when it applies, disclarion_meta just gets a plain signal:
tracked = dc.track(response, session_id="post-42", interaction_type="generated_content")
tracked.disclarion_meta
# {"ai_generated": True, "logged_at": "...", "content_label_required": True}There's no widget for this (yet, see the roadmap): content_label_required is a flag, not a component. It's up to your own content template to decide what that looks like: an "AI-generated" caption under a product description, an aria-label, a byline note on a blog post. The interaction is logged exactly the same way regardless.
Non-text content (images, video, audio): track_content()
dc.track(response, ...) only works with a response object one of its adapters can introspect (OpenAI ChatCompletion, Anthropic Message, Gemini GenerateContentResponse). That's fine for text drafted via a chat completion, but an image/video/audio generation call returns something else entirely, with none of the fields those adapters read (checked against OpenAI's own ImagesResponse: no .id, no .model). For those, use track_content() instead — it takes provider/model_name directly rather than trying to detect them from a response, since you already know both from your own generation call:
image = openai_client.images.generate(model="dall-e-3", prompt="...")
meta = dc.track_content(
provider="openai",
model_name="dall-e-3",
session_id="product-photo-42",
content_type="image", # free text — "image", "video", "audio", "text", ...
)
meta
# {"ai_generated": True, "logged_at": "...", "content_label_required": True}Always logs as interaction_type="generated_content" (it isn't a parameter here — there's no chat-vs-content ambiguity for this method). Returns the disclarion_meta dict directly, since there's no response object to attach it to. content_type is optional and purely descriptive today — pass whatever describes the content, there's no fixed list.
The standard Disclarion label
track_content() also produces a fixed, non-customizable bottom-left visual label for the content you just logged — a small "AI-generated"/"AI-assisted" badge plus a "Labeled by Disclarion" credit underneath it, at roughly half the visual weight. There's no API to restyle it: a label a customer could reskin to look like something else would defeat the point of a standard, recognizable one.
meta = dc.track_content(
provider="openai",
model_name="dall-e-3",
session_id="product-photo-42",
content_type="image",
image=image_bytes, # optional: raw PNG/JPEG/WebP bytes to watermark
)
meta["badge_html"] # HTML/CSS snippet, for text/article content
meta["watermarked_image"] # bytes, only present when image= was passed
meta["content_labeled"] # True iff a label was actually appliedbadge_html is an HTML/CSS snippet for text or article content — place it as the last child inside the container wrapping your content. That container needs position: relative (or any non-static position) — the one thing this snippet can't set for you. Its "Labeled by Disclarion" credit is a real, clickable link to disclarion.com.
watermarked_image is only produced when you pass image=<raw bytes> (PNG, JPEG, or WebP) — the same badge baked into the image's own pixels, so it travels with the file through a re-upload or re-host. Because it's pixels, not markup, its credit line is plain text, not a link — a raster image has no clickable regions. A very small image (shorter side under ~320px) can't fit a legible badge: watermarking is skipped rather than shrunk into unreadability, and you instead get watermark_skipped: True. This is a visible label, not tamper-proofing — a crop that excludes the bottom-left corner removes it, the same way it would remove a printed photo credit.
Two optional parameters: ai_involvement ("generated" default, or "assisted"), and include_disclarion_link (default True; set False to drop the credit line and keep only the required AI-disclosure text — no leftover space is reserved for it, the badge simply shrinks to fit the one remaining line).
Watermarking an image
Pass the raw image bytes from your generation call as image=. What you do with watermarked_image afterward depends on where the image needs to end up — three common cases:
import requests
from disclarion import Disclarion
dc = Disclarion(api_key="dcl_live_...")
# 1. Generate the image with your provider of choice.
image_bytes = requests.get(openai_image_response.data[0].url).content
meta = dc.track_content(
provider="openai",
model_name="dall-e-3",
session_id="product-photo-42",
content_type="image",
image=image_bytes,
)
if meta.get("watermark_skipped"):
# Image was too small to fit a legible badge (shorter side < ~320px).
# Fall back to the original, unwatermarked bytes.
final_image = image_bytes
else:
final_image = meta["watermarked_image"]
# 2a. Save it to disk.
with open("product-photo-42.png", "wb") as f:
f.write(final_image)
# 2b. Upload it to S3 instead of (or as well as) saving locally.
import boto3
s3 = boto3.client("s3")
s3.put_object(
Bucket="your-bucket",
Key="products/product-photo-42.png",
Body=final_image,
ContentType="image/png",
)
# 2c. Or hand it straight to your frontend as a base64 data URL, with
# no file storage involved at all.
import base64
data_url = f"data:image/png;base64,{base64.b64encode(final_image).decode('ascii')}"
return {"image_data_url": data_url}Only the last one you actually need — they're shown together to make the three destinations concrete, not as steps to run in sequence.
Text content, end to end: backend → API → React
This is the full path for an AI-drafted article or product description: your backend logs it and gets badge_html, your own API returns it to the browser, and your React component renders it.
Backend
track_content()
API response
badge_html
React
dangerouslySetInnerHTML + position: relative
Step 1 — Backend: call track_content()
# articles.py
from disclarion import Disclarion
dc = Disclarion(api_key="dcl_live_...")
def publish_article(title: str, body: str, session_id: str) -> dict:
meta = dc.track_content(
provider="anthropic",
model_name="claude-sonnet-5",
session_id=session_id,
content_type="text",
ai_involvement="assisted",
)
return {"title": title, "body": body, "badge_html": meta["badge_html"]}Step 2 — Your own API endpoint returns it (FastAPI)
This is your backend endpoint, not a Disclarion one — Disclarion never serves content to your frontend directly.
# main.py
from fastapi import FastAPI
from articles import publish_article
app = FastAPI()
@app.get("/api/articles/{article_id}")
def get_article(article_id: str):
article = load_article_from_db(article_id) # your own storage
return publish_article(article.title, article.body, session_id=f"article-{article_id}")Step 3 — React: render it with dangerouslySetInnerHTML
The one step that's easy to miss
The wrapping element around your article content must have position: relative (or any non-static position). The badge is positioned absolute against the nearest positioned ancestor — without one, it escapes to the next ancestor that has a position set, which in practice usually means it ends up pinned to a corner of the whole page instead of the bottom-left of your article card. Verified directly: removing position: relative from the container below makes the badge jump to the bottom-left of the browser viewport.
// ArticleView.tsx
"use client";
type Article = { title: string; body: string; badge_html: string };
export function ArticleView({ article }: { article: Article }) {
return (
// position: relative is required here — see callout above.
<div style={{ position: "relative" }} className="article-card">
<h1>{article.title}</h1>
<p>{article.body}</p>
<div dangerouslySetInnerHTML={{ __html: article.badge_html }} />
</div>
);
}A note on dangerouslySetInnerHTML: it's named that way because injecting arbitrary HTML is a real XSS risk in general — never do this with user-submitted or otherwise untrusted content. badge_html is generated entirely by the SDK from a fixed template (the two variable parts are which of two fixed strings to show and whether to include the link, not free text), so there's nothing for it to inject. That doesn't change the general rule for anything else you might render this way.
Step 4 — Not React? Same idea, framework-agnostic
Vue (v-html), Svelte ({@html ...}), or plain HTML/JS (element.innerHTML = badge_html) all work the same way: a positioned container, the badge markup injected as its last child. The position: relative requirement doesn't change with the framework.
Using it in a Next.js Server Component
No difference worth worrying about. dangerouslySetInnerHTML is a plain React prop, not a client-only API — it works identically whether the component rendering it is a Server or Client Component, because it just tells React what HTML string to render for that element, and that happens the same way on the server:
// app/articles/[id]/page.tsx — a Server Component, no "use client"
async function getArticle(id: string) {
const res = await fetch(`${process.env.API_BASE}/api/articles/${id}`);
return res.json();
}
export default async function ArticlePage({ params }: { params: { id: string } }) {
const article = await getArticle(params.id);
return (
<div style={{ position: "relative" }} className="article-card">
<h1>{article.title}</h1>
<p>{article.body}</p>
<div dangerouslySetInnerHTML={{ __html: article.badge_html }} />
</div>
);
}The only thing that would need "use client" is if you wrap the badge in interactive state (a toggle to hide it, say) — rendering the markup itself doesn't.
Backend isn't Python? Call the API directly
Stated plainly, because it's a real gap, not downplayed: watermarking an image (the Pillow-based pixel overlay) and generating badge_html both happen inside the Python SDK, on your machine, before anything is sent to Disclarion. The raw POST /v1/logs endpoint underneath it only records the interaction; it doesn't take an image and doesn't return a badge. If your backend is Node.js, Ruby, Go, or anything else, you have two options today:
- For the text badge:
badge_htmlis a fixed template. Copy the literal markup below into your own codebase rather than calling the SDK for it. One thing you lose by copying it statically: a realtrack_content()call links the badge to that specific record's/verify/{record_id}page. The static markup below can't do that, so it links to the Disclarion homepage instead. - For image watermarking, there's no way around running the Python SDK somewhere: either run it as a small internal service your main backend calls, or do the labeling step from a small Python job/function even if the rest of your stack isn't Python.
<!-- The badge_html template dc.track_content() generates, with
ai_involvement="generated" and include_disclarion_link=True.
Swap "AI-generated" for "AI-assisted", or drop the second <span>
entirely, to match the other parameter combinations. This static
copy links to the Disclarion homepage; a real track_content() call
links to that record's own /verify/{record_id} page instead. -->
<div style="position:absolute;bottom:8px;left:8px;z-index:2147483647;background:rgba(15,23,42,0.72);color:#eef1f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:7px 11px;border-radius:8px;pointer-events:auto;">
<span style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:600;line-height:1.2;">
<span style="color:#06b6d4;" aria-hidden="true">✦</span>AI-generated
</span>
<span style="display:block;font-size:11px;line-height:1.3;margin-top:2px;">
<a href="https://www.disclarion.com" target="_blank" rel="noopener noreferrer" style="color:#8b94ab;text-decoration:underline;">Labeled by Disclarion</a>
</span>
</div>Logging the interaction itself works from any language — that part is a plain HTTP call:
curl -X POST https://api.disclarion.com/v1/logs \
-H "Authorization: Bearer dcl_live_..." \
-H "Content-Type: application/json" \
-d '{
"provider": "anthropic",
"model_name": "claude-sonnet-5",
"session_id": "article-99",
"disclosure_shown": false,
"content_labeled": true,
"raw_metadata": {},
"jurisdiction": "EU",
"interaction_type": "generated_content",
"content_type": "text"
}'
# {
# "data": { "id": "...", "created_at": "...", ... },
# "error": null,
# "is_first_message": null,
# "required_actions": ["content_label"]
# }Set content_labeled to whatever's actually true for this request (e.g. false if you hardcoded the badge markup above but didn't actually render it that time) — it's what the dashboard's Logs table shows for this row, so it should reflect reality, not always be true.
Troubleshooting
- The badge doesn't appear at all. Almost always a missing
position: relativeon the wrapping element — see the callout above. Check the rendered DOM: if the badge<div>is there but invisible, it may be rendering off-screen rather than not rendering at all — see the next point. - The badge appears in the wrong place (top of the page, wrong corner, or overlapping unrelated content). The badge is
position: absolute, positioned against the nearest ancestor that has any position other thanstaticset — if your intended container isn't that ancestor, walk up the DOM tree and check which one is. An ancestor withoverflow: hiddenbetween the badge and its positioned ancestor can also clip it out of view entirely, even when the positioning itself is otherwise correct.
Rendering the disclosure UI
track() only ever gives you data (disclarion_meta). It does not render anything on its own, and there is no separate npm package to install for the frontend. The modal and badge shown above are ordinary UI your own code renders from that data.
To avoid building that from a blank page, every install of the SDK bundles a copy-paste reference implementation (React and vanilla JS) of the exact modal-once pattern. Find it with:
python -c "import disclarion; print(disclarion.widget_dir())"Copy DisclarionDisclosure.jsx (or the vanilla-JS version, if you're not on React) from that folder into your own frontend, then render it with the same is_first_message your backend already forwards from disclarion_meta:
<DisclarionDisclosure
sessionId={sessionId}
isFirstMessage={latestMessage?.disclarion_meta?.is_first_message}
/>That's a starting point, not a dependency: it's a plain file you own once copied, styled with inline styles specifically so it has no CSS/build assumptions about your app. Most teams end up rewriting it to match their own design system rather than keeping it as-is (see the widget on this site's own chat assistant for an example of that). We designed it this way deliberately: every product's UI is different, and shipping one opinionated, pre-styled component would mean either it looks out of place everywhere or you fight it to override the styling. Data plus an optional reference gets out of your way either way.
5. What happens on failure
A Disclarion outage should never take down your application. If the backend is unreachable, or returns a 429 or 5xx after retrying, track() logs a warning and returns without raising: your OpenAI/ Anthropic/Gemini call itself is never blocked or delayed by a Disclarion problem. An invalid or revoked API key is treated differently, since it's a setup mistake rather than a runtime hiccup: it raises disclarion.AuthenticationError immediately. An inactive subscription is treated the same way, raising disclarion.SubscriptionInactiveError immediately (SDK >=0.3.0).
None of the above is about volume: there is no volume cap on Pro, and track() never stops logging because of volume. What does stop new logs is an inactive subscription (trial ended, payment failed): see pricing.
Use it from n8n
Prefer building with n8n instead of writing Python? Disclarion ships a community node, n8n-nodes-disclarion, that does the same thing dc.track() does, without touching Python. Source is on GitHub.
Install the node
The node is verified, so on n8n Cloud you can add it straight from the canvas: search for Disclarion in the node panel. On a self-hosted instance, go to Settings → Community Nodes → Install a community node and enter the package name:
n8n-nodes-disclarionAdd the credential
Drop a Disclarion node into your workflow right after the node that calls your LLM provider (OpenAI, Anthropic, etc.), then create a new Disclarion API credential:
- API Key: the same
dcl_live_...key from step 2 above. - Base URL: leave as
https://api.disclarion.comunless you're on a self-hosted or staging backend.
Click the credential's Test button before moving on: it calls a side-effect-free endpoint, so it confirms the key works without writing a log entry.
Configure and run the node
The node has one operation, Track Interaction, with these fields:
- Session ID: the same id you'd pass as
session_idin the SDK, used to group a conversation and detect its first message. - Provider and Model Name: which model produced the response you're logging.
- Additional Fields (optional): the provider's own response ID, any extra JSON metadata to store alongside the log, and the same Jurisdiction / Interaction Type pair the SDK takes (defaults to
"EU"/"chat"if left untouched) — set Interaction Type to Generated Content for the same non-chat labeling described above.
Running it returns the same shape dc.track() logs to the audit trail, with is_first_message surfaced at the top level:
{
"is_first_message": true,
"id": "...",
"account_id": "...",
"project_id": "...",
"session_id": "session-123",
"provider": "openai",
"model_name": "gpt-4o-mini",
"disclosure_shown": true,
"content_labeled": true,
"created_at": "..."
}Feed is_first_message into an IF node the same way you'd branch on disclarion_meta.is_first_message in the SDK, to decide whether this step in the workflow should surface a disclosure notice. The node can also be called directly as a tool by n8n's AI Agent node, so an agent can log its own reply right after generating it.
The node doesn't yet support disclosure render/acknowledge confirmation: there's no ack_token output or a way to report the event back. Its disclosure_shown is self-reported only, same as the raw REST API. Support is planned.
Use it from any other language
Not on Python, and not using n8n? Both of the above are just clients for a plain HTTPS API: call it directly from Node.js, Ruby, Go, PHP, or anything else that can make an HTTP request.
POST to https://api.disclarion.com/v1/logs with an Authorization: Bearer dcl_live_... header and a JSON body. Here it is from Node.js, using nothing but the built-in fetch:
const res = await fetch("https://api.disclarion.com/v1/logs", {
method: "POST",
headers: {
Authorization: "Bearer dcl_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: "session-123",
provider: "openai",
model_name: "gpt-4o-mini",
disclosure_shown: true,
content_labeled: true,
raw_metadata: {},
}),
});
const { data, is_first_message, required_actions, ack_token } = await res.json();All six body fields are required (send raw_metadata: {} if you have nothing to attach). The response and is_first_message behave exactly like the SDK and the n8n node, because they're calling this same endpoint. See steps 3 and 4 above for what to do with it.
One thing you don't get for free this way: the Python SDK never lets a Disclarion problem block or crash your app, because it retries in the background and fails open (see "What happens on failure" above). A raw fetch call has none of that built in: wrap it in your own try/catch, don't await it on the critical path of your response to the end user, and treat a 401 (bad API key) differently from a 402 or 5xx (both of which mean: log it, don't retry inline, move on).
The body also takes the same jurisdiction / interaction_type fields dc.track() takes (see step 3), and the response returns required_actions alongside is_first_message either way, which is what the destructure above already picks up. There's also an optional content_type field (free text — "image", "video", "audio", "text", ...) for the same non-chat content track_content() logs above — meaningful only alongside interaction_type="generated_content", omit it otherwise.
Check obligations without logging anything
GET /v1/obligations is a side-effect-free lookup for the same pair: same API-key auth, no interaction gets recorded. Useful for a pre-flight check, or just to see what a given jurisdiction/interaction type currently requires before you build against it:
const params = new URLSearchParams({ jurisdiction: "EU", interaction_type: "chat" });
const res = await fetch(`https://api.disclarion.com/v1/obligations?${params}`, {
headers: { Authorization: "Bearer dcl_live_..." },
});
const { data } = await res.json();
// { jurisdiction: "EU", interaction_type: "chat", required_actions: ["disclosure_modal", "audit_log"] }Compliance evidence reports
Every logged interaction already carries the jurisdiction, interaction type, and the obligations that were applied to it (see above). The dashboard turns that into a report: open a project, go to Compliance, pick a date range, and click Generate report for a summary, total interactions, disclosure shown/not shown, a provider/model breakdown, and a jurisdiction / interaction type / applied actions table, that you can hand to legal or an auditor as-is, or export with Download PDF.
Interactions logged before this feature existed show up as predating compliance tracking: their jurisdiction, interaction type, and applied actions were never recorded, not zero.
See the changelog for release history, the blog for writing on Article 50 itself, or get in touch with questions.