Skip to content
Assay Layer
Menu

API

AI detection API and MCP server

The checker on this site is a thin client over a public API. Everything it shows you — the layers, the spans, the caveats, the hash — is in the JSON, and the same pipeline is exposed to agents over MCP.

Updated

Quick start

One request, one report

Authenticate with a bearer token. Keys are issued per workspace and carry the workspace’s tier, its credit balance and its rate limit.

curl -X POST https://api.assaylayer.com/v1/assays \
  -H "Authorization: Bearer al_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "standard",
    "input": { "type": "text", "text": "The paragraph you want to check." },
    "reference": "cms-article-8412"
  }'

The TypeScript version, typed against the shared contracts package so the shapes cannot drift:

import type { ApiError, AssayReport, CreateAssayRequest } from "@assaylayer/contracts";

const body: CreateAssayRequest = {
  kind: "deep",
  input: { type: "text", text: draft },
  reference: "cms-article-8412",
};

const response = await fetch("https://api.assaylayer.com/v1/assays", {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.ASSAYLAYER_API_KEY}`,
    "content-type": "application/json",
  },
  body: JSON.stringify(body),
});

if (!response.ok) {
  const { error } = (await response.json()) as ApiError;
  throw new Error(`${error.code}: ${error.message}`);
}

const report = (await response.json()) as AssayReport;

// Origin established beats origin inferred: check this before the score.
if (report.summary?.signal === "provenance") {
  await markAsDisclosed(report.id);
}

Response

What comes back

The report is one shape everywhere: on screen, in the PDF, over the API and through MCP. This is a real standard-assay response with the long strings trimmed.

{
  "id": "asy_8xk2m4q1v7",
  "kind": "standard",
  "status": "complete",
  "inputHash": "b5bef6159c21e2e071d000891d3c05feb7aaba5070919bae2a9a27285f26cda3",
  "wordCount": 108,
  "language": "en",
  "summary": {
    "signal": "ai",
    "confidence": "medium",
    "aiScore": 0.87,
    "headline": "Strong AI signals across most sentences."
  },
  "layers": [
    {
      "layer": "watermark",
      "provider": "anthropic-watermark",
      "outcome": "unavailable",
      "note": "Anthropic watermark detection is in private preview."
    },
    {
      "layer": "metadata",
      "provider": "c2pa",
      "outcome": "unavailable",
      "note": "Pasted text carries no manifest."
    },
    {
      "layer": "classifier",
      "provider": "base-classifier",
      "outcome": "found",
      "score": 0.87,
      "spans": [{ "start": 0, "end": 95, "score": 0.94 }],
      "durationMs": 812
    }
  ],
  "caveats": [
    "Metadata is absent for pasted text.",
    "Detectors flag writing by non-native English speakers more often."
  ],
  "creditsCharged": 1,
  "reference": "cms-article-8412",
  "createdAt": "2026-09-01T09:12:44.000Z",
  "completedAt": "2026-09-01T09:12:45.000Z",
  "pipelineVersion": "2026.09.1"
}
Report fields. Optional fields are absent rather than null when a layer could not produce them.
Field Type Notes
id string Stable report id, usable with the get and export endpoints.
kind standard | deep Deep adds the deep classifier and an agreement score.
status queued | running | complete | failed Synchronous text assays return complete.
inputHash string(64) SHA-256 of the normalised input. The text itself is not stored.
wordCount integer From the normalised text. Under 50, classifiers refuse to score.
language string Detected language, when one could be guessed.
summary.signal provenance | none | mixed | ai | insufficient Read this before the score.
summary.confidence low | medium | high Never present a score without it.
summary.aiScore 0–1 Document-level classifier score. Present only when a classifier ran.
summary.agreement 0–1 Deep assays only. 1.00 means both classifiers landed in the same place.
summary.headline string A sentence for humans. Never asserts authorship.
layers[].outcome found | not_found | unavailable | too_short | error Unavailable is an answer, not a failure.
layers[].spans array Character offsets into the normalised text, with a per-span score.
caveats string[] Travels with the report. Do not drop these when you render it.
creditsCharged integer What this request cost the workspace.
reference string Your own id, echoed back and searchable in the workspace.
pipelineVersion string Bumped whenever providers or thresholds change.

Endpoints

The surface

All paths are relative to the API host.
Method Path What it does
POST /v1/assays Run an assay on text or a URL. The whole report comes back in the response.
POST /v1/assays/file Multipart upload. Adds the metadata layer and one credit.
GET /v1/assays/:id Fetch a report by id.
GET /v1/assays List a workspace's reports, filtered by kind, signal or your own reference.
GET /v1/assays/:id/export.json The same report as a JSON file, for your audit trail.
GET /v1/assays/:id/export.pdf A one-page PDF carrying the input hash, pipeline version and timestamp.
GET /v1/workspaces/:id/balance Current credit balance for a workspace.
GET /v1/workspaces/:id/ledger Every credit movement, with its reason.
GET /v1/me The signed-in user, their workspaces and the current one.
GET /openapi.json The OpenAPI document this table is generated against.

The full reference, generated from the same OpenAPI document, lives at api.assaylayer.com/docs.

MCP

The same pipeline, for agents

The Model Context Protocol is a standard way for an AI assistant to call tools that live outside itself. Assay Layer runs an MCP server over streamable HTTP at mcp.assaylayer.com/mcp, authenticated with the same bearer key as the REST API, exposing two tools: assay_text, which runs an assay and returns the signal, confidence, score, caveats and a link to the full report, and get_assay, which fetches a report by id. The practical use is an agent that checks a draft it has just been handed — or one it has just written — before it moves on.

Claude Code and Claude Desktop:

claude mcp add assaylayer \
  --transport http https://mcp.assaylayer.com/mcp \
  --header "Authorization: Bearer al_live_xxxxxxxxxxxxxxxx"

Cursor, in .cursor/mcp.json:

{
  "mcpServers": {
    "assaylayer": {
      "url": "https://mcp.assaylayer.com/mcp",
      "headers": {
        "Authorization": "Bearer al_live_xxxxxxxxxxxxxxxx"
      }
    }
  }
}

Limits and cost

Rate limits and credits

Requests per minute per API key, from RATE_LIMITS in @assaylayer/contracts.
Tier Rate limit Notes
Free 10 req/min Anonymous and signed-in checker traffic, counted per IP address
Developer 60 req/min First paid API tier
Growth 300 req/min Sustained pipeline use
Scale 1,000 req/min High-volume ingestion
Compliance 1,000 req/min Same ceiling as Scale, with retention controls and a signed DPA
One unit, the credit, across the checker, the API and MCP.
Operation Cost Notes
Standard assay 1 credit Per started 1,000 words. Base classifier, watermark and metadata layers.
Deep assay 10 credits Per started 1,000 words. Adds the deep classifier and an agreement score.
File upload +1 credit Added once per file, for text extraction and the C2PA or XMP read.

Limits are per key, per minute. Going over returns rate_limited with a 429; running out of credits returns insufficient_credits with a 402, and no assay is charged for. Full prices are on the pricing page, and the thresholds behind the numbers in the response are on the accuracy page.

Questions

API questions

How do I get an API key?

Sign in, open the workspace settings and create one. Keys start with al_live_ and the secret is shown once, at creation. Revoking a key takes effect immediately.

Is the API synchronous?

Yes for text. A standard assay returns the finished report in the same response, because that is what a checker in a form needs. File uploads above two megabytes are queued, and you can supply a webhook URL to be called with the finished report.

What happens when a provider is down?

That layer returns an outcome of error after one retry, and the rest of the report is still produced. A missing layer never silently becomes a clean result — the outcome is in the JSON and the summary confidence drops to low.