Skip to content
winston ai
Winston AI API

Put AI detection inside your product

Send text over HTTPS and get back a score, a verdict and sentence-level highlights as JSON. Same model as the dashboard, paid for from the same credits.

  • JSON over HTTPS
  • Sentence-level results
  • 1 credit per word
Request
curl https://winstonai.com/api/v1/detect \
  -H "Authorization: Bearer $WINSTONAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Remote work has become a cornersto…"}'
Response, shortened200 OK
{
  "human_score": 47,
  "verdict": "mixed",
  "sentences": [
    {
      "text": "Remote work has become a c…",
      "ai_probability": 0.755,
      "verdict": "ai"
    },
    {
      "text": "Moreover, it enables organ…",
      "ai_probability": 0.524,
      "verdict": "mixed"
    },
    {
      "text": "I still miss the office co…",
      "ai_probability": 0.218,
      "verdict": "human"
    }
  ]
}

Overview

The Winston AI API is a small set of JSON endpoints over HTTPS. Every request creates a scan, the same kind you see in your dashboard, so results from your code and your team live in one place.
  1. Step 1: Create a key

    Open Developers in the dashboard and create an API key.

  2. Step 2: Send some text

    POST at least 40 words to /api/v1/detect.

  3. Step 3: Read the result

    Use human_score for the overall call and sentences for highlights.

Conventions

Base URL
https://winstonai.com/api/v1
Format
JSON request and response bodies in UTF-8, snake_case field names, timestamps in ISO 8601 UTC.
Versioning
The version is part of the path. Within v1 we only add fields, never remove or rename them, so ignore any field you don't recognize.
Safe retries
Send an Idempotency-Key header, up to 255 characters, with any POST. Repeating the request with the same key and body within 24 hours replays the first response, marked Idempotent-Replayed: true, instead of scanning and charging twice.
OpenAPI
GET /api/v1/openapi.json returns an OpenAPI 3.1 description of every endpoint, with no key needed, so you can generate a client from it. Open the spec.
Request ids
Every response carries an X-Request-Id header. Include it when you contact support.

Authentication

Authenticate every request with a secret API key, sent as a bearer token in the Authorization header.
Header
Authorization: Bearer wai_live_8f3b2c7e91d04a6fK2mQx7Rt5LwZ9pNc

Create and revoke keys on the Developers page of your dashboard. Keys start with wai_live_ followed by 32 letters and numbers. Each key belongs to a workspace and spends that workspace's credits. A key is shown only once when you create it, so store it somewhere safe, such as an environment variable called WINSTONAI_API_KEY.

Keep keys on your server

Never put a key in browser or mobile app code: anyone who finds it can spend your credits. If a key leaks, revoke it in the dashboard and create a new one. Requests with a revoked key fail with unauthorized.

Credits and rate limits

API scans are paid for from the same credit balance as the dashboard, at the same rates.
Credit cost per endpoint
EndpointCostExample
POST /api/v1/detect1 credit per wordA 1,200-word essay uses 1,200 credits.
POST /api/v1/plagiarism2 credits per wordThe same essay uses 2,400 credits.
POST /api/v1/image300 credits per imageComing soon.
GET /api/v1/scans/{id}FreeFetch results as often as rate limits allow.
GET /api/v1/usageFreeCheck your balance before a large batch.

Credits are reserved when a scan is accepted and refunded automatically if it fails. If your balance is too low, the request fails with insufficient_credits before anything is scanned.

Limits

  • Text per request40 to 25,000 words
  • Images, coming soonUp to 10 MB: JPEG, PNG, WebP or HEIC
  • Requests60 per minute for each API key
  • Plagiarism scans10 running at once per workspace

Starting another plagiarism check while 10 are running returns rate_limited with retry_after set to 30 seconds.

Response headers

Response headers
HeaderMeaning
X-RateLimit-LimitRequests allowed per minute for this key. Sent with every authenticated response.
X-RateLimit-RemainingRequests left in the current minute.
X-RateLimit-ResetSeconds until the count resets.
Retry-AfterSent with 429 responses: seconds to wait before trying again.
X-Credits-RemainingYour credit balance afterwards. Sent on requests that spend credits: POST /detect and POST /plagiarism.
X-Request-IdUnique id for this request, the same value as request_id in error bodies.
Idempotent-Replayedtrue when the response is a replay of an earlier request with the same Idempotency-Key.

Check your balance and limits at any time with Check usage. Need higher limits for a large rollout? Talk to sales.

Detect AI text

POST/api/v1/detect

Scores text for signs of machine writing and returns the result straight away, usually in about a second for an essay. Each call creates a scan you can also open in the dashboard.

Body parameters

textstringRequired
The text to check, from 40 to 25,000 words.
languagestring
ISO 639-1 code such as en or fr. Detected automatically when left out.
titlestring
A name for the scan, shown in your dashboard. Up to 200 characters.
authorstring
Who wrote the text. Up to 120 characters.
folder_idstring
Save the scan into one of your dashboard folders. An unknown id fails with invalid_body.
sentencesboolean
Include the per-sentence results. Defaults to true; set false to leave sentences out of the result.
metadataobject
Up to 20 string key-value pairs, such as your own submission id. Returned with the scan and in webhooks.
curl https://winstonai.com/api/v1/detect \
  -H "Authorization: Bearer $WINSTONAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Remote work has become a cornerstone of modern business, offering employees unparalleled flexibility and a better balance between work and home. Moreover, it enables organizations to tap into a diverse, global talent pool. I still miss the office coffee, though, and the chats that only happen by accident.",
    "title": "Remote work essay"
  }'

Response

A scan object with type set to ai_text and status set to completed.

Response200 OK
{
  "id": "scn_6f1c2a9e4b7d4c1e9a3f5b8d2e7c4a10",
  "object": "scan",
  "type": "ai_text",
  "status": "completed",
  "credits_used": 48,
  "metadata": {},
  "created_at": "2026-10-18T09:24:11Z",
  "completed_at": "2026-10-18T09:24:12Z",
  "result": {
    "human_score": 47,
    "verdict": "mixed",
    "word_count": 48,
    "sentence_count": 3,
    "readability": 66,
    "language": "en",
    "sentences": [
      {
        "text": "Remote work has become a cornerstone of modern business, offering employees unparalleled flexibility and a better balance between work and home.",
        "ai_probability": 0.755,
        "verdict": "ai"
      },
      {
        "text": "Moreover, it enables organizations to tap into a diverse, global talent pool.",
        "ai_probability": 0.524,
        "verdict": "mixed"
      },
      {
        "text": "I still miss the office coffee, though, and the chats that only happen by accident.",
        "ai_probability": 0.218,
        "verdict": "human"
      }
    ]
  },
  "error": null
}

The result object

human_scoreinteger
0 to 100. Higher means more likely written by a person.
verdictstring
human when human_score is 60 or more, mixed from 35 to 59, ai below 35.
sentences[].ai_probabilitynumber
0 to 1, to three decimal places. How likely this sentence is to be machine-written.
sentences[].verdictstring
ai from 0.65, mixed from 0.40, otherwise human. Use it to color highlights.
readabilityinteger
Reading ease, from 0 (hard) to 100 (easy).
languagestring | null
ISO 639-1 code of the language the text was scored as, or null if it couldn't be detected.
word_count, sentence_countinteger
Counts for the text that was scanned.

Check plagiarism

POST/api/v1/plagiarism

Compares text against web pages and your library. A check takes 10 to 60 seconds, so this endpoint queues the scan and replies at once with status set to queued. Get the result from a webhook or by polling Retrieve a scan.

Included on Advanced and Team plans

Keys on other plans get plan_required. Credits are only charged once the check is accepted.

Body parameters

textstringRequired
The text to compare against web pages and your library, from 40 to 25,000 words.
titlestring
A name for the scan, shown in your dashboard. Up to 200 characters.
authorstring
Who wrote the text. Up to 120 characters.
folder_idstring
Save the scan into one of your dashboard folders. An unknown id fails with invalid_body.
exclude_urlsstring[]
Sources to ignore, such as the writer's own site. Up to 50 URLs.
exclude_quotesboolean
Skip text inside quotation marks. Defaults to true.
metadataobject
Up to 20 string key-value pairs, such as your own submission id. Returned with the scan and in webhooks.
curl https://winstonai.com/api/v1/plagiarism \
  -H "Authorization: Bearer $WINSTONAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Remote work has become a cornerstone of modern business, offering employees unparalleled flexibility and a better balance between work and home. Moreover, it enables organizations to tap into a diverse, global talent pool. I still miss the office coffee, though, and the chats that only happen by accident.",
    "metadata": {
      "submission_id": "essay-1042"
    }
  }'

Response

Response202 Accepted
{
  "id": "scn_b3e07d5a1c9f4e2d8a6b0c4f7e1d9a35",
  "object": "scan",
  "type": "plagiarism",
  "status": "queued",
  "credits_used": 96,
  "metadata": {
    "submission_id": "essay-1042"
  },
  "created_at": "2026-10-18T09:26:40Z",
  "completed_at": null,
  "result": null,
  "error": null
}

Retrieve a scan

GET/api/v1/scans/{id}

Returns any scan in your workspace, whether it was created through the API or the dashboard. Use it to poll a plagiarism scan until status is completed or failed. Every 5 seconds is plenty, or use webhooks and skip polling.

Path parameters

idstringRequired
The scan id returned when you created the scan: scn_ followed by 32 hex characters.
curl https://winstonai.com/api/v1/scans/scn_b3e07d5a1c9f4e2d8a6b0c4f7e1d9a35 \
  -H "Authorization: Bearer $WINSTONAI_API_KEY"

Response

Response200 OK
{
  "id": "scn_b3e07d5a1c9f4e2d8a6b0c4f7e1d9a35",
  "object": "scan",
  "type": "plagiarism",
  "status": "completed",
  "credits_used": 96,
  "metadata": {
    "submission_id": "essay-1042"
  },
  "created_at": "2026-10-18T09:26:40Z",
  "completed_at": "2026-10-18T09:27:05Z",
  "result": {
    "similarity": 22.9,
    "word_count": 48,
    "matched_words": 11,
    "sources": [
      {
        "url": "https://example.com/blog/remote-hiring-guide",
        "title": "A practical guide to hiring remotely",
        "type": "web",
        "matched_words": 11,
        "percent": 22.9,
        "excerpt": "it enables organizations to tap into a diverse, global talent pool"
      }
    ]
  },
  "error": null
}

The scan object

Every endpoint and webhook returns the same scan object. Only the shape of result changes with type.

idstring
Unique id: scn_ followed by 32 hex characters.
typestring
ai_text, plagiarism or ai_image.
statusstring
queued, processing, completed or failed.
credits_usedinteger
Credits charged for this scan. Refunded if it fails.
resultobject | null
The findings, once status is completed. Its shape depends on type.
errorobject | null
A code and message, when status is failed.
metadataobject
Whatever you sent when creating the scan.
created_at, completed_atstring
ISO 8601 timestamps in UTC.

The plagiarism result

similaritynumber
0 to 100, to one decimal place. The share of words that match at least one source.
matched_wordsinteger
How many words match a source.
sources[].typestring
web for a public web page; document for a match in your own workspace library.
sources[].urlstring | null
Link to the source. null for documents in your workspace.
sources[].percentnumber
Share of your text that matches this source, to one decimal place.
sources[].matched_wordsinteger
Words in your text that match this source.
sources[].excerptstring | null
The matching passage, when one is available.

Check usage

GET/api/v1/usage

Returns your workspace's plan, credit balance and current rate limit. It's free, so call it before a large batch to make sure you have enough credits.

curl https://winstonai.com/api/v1/usage \
  -H "Authorization: Bearer $WINSTONAI_API_KEY"

Response

Response200 OK
{
  "object": "usage",
  "workspace_id": "6f1c2a9e-4b7d-4c1e-9a3f-5b8d2e7c4a10",
  "plan": "advanced",
  "credits": {
    "balance": 154230,
    "monthly_allowance": 200000
  },
  "rate_limit": {
    "limit": 60,
    "remaining": 59,
    "reset": 42
  }
}
planstring
free, essential, advanced or team.
credits.balanceinteger
Credits left to spend right now.
credits.monthly_allowanceinteger
Credits your plan adds each month.
rate_limitobject
limit, remaining and reset (seconds), the same as the rate limit headers.

Detect AI images

Coming soon

POST/api/v1/image

Checks whether an image was made, or heavily edited, by an image model. Send a public URL as JSON, or upload the file as multipart/form-data. Results come back straight away.

Not live yet

Until image detection launches, this endpoint returns not_available (501). The request and response below show the planned shape. When it launches it will be included on Advanced and Team plans; keys on other plans will get plan_required.

Body parameters

urlstring
A public HTTPS link to a JPEG, PNG, WebP or HEIC image up to 10 MB. Send this or file.
filefile
The image itself, sent as multipart/form-data. Send this or url.
metadataobject
Up to 20 string key-value pairs, such as your own submission id. Returned with the scan and in webhooks.
curl https://winstonai.com/api/v1/image \
  -H "Authorization: Bearer $WINSTONAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/uploads/product-shot.png"
  }'

Upload a file instead

curl https://winstonai.com/api/v1/image \
  -H "Authorization: Bearer $WINSTONAI_API_KEY" \
  -F "file=@photo.png"

Response

For images, human_score is the chance the picture came from a camera or a person rather than a model, and ai_probability is the same signal from 0 to 1.

Response200 OK
{
  "id": "scn_0d8e4f2a7c1b4e9d93a5f6c8b2e0d471",
  "object": "scan",
  "type": "ai_image",
  "status": "completed",
  "credits_used": 300,
  "metadata": {},
  "created_at": "2026-10-18T09:31:02Z",
  "completed_at": "2026-10-18T09:31:04Z",
  "result": {
    "human_score": 4,
    "verdict": "ai",
    "ai_probability": 0.96,
    "format": "png",
    "width": 1024,
    "height": 1024
  },
  "error": null
}

Errors

Errors use standard HTTP status codes. The body is an error object with a broad type, a stable code to branch on and a message you can log or show.
Error response402 Payment Required
{
  "error": {
    "type": "billing_error",
    "code": "insufficient_credits",
    "message": "This scan needs 2,360 credits and your balance is 420.",
    "cost": 2360,
    "balance": 420,
    "request_id": "req_5bT1kz0Qe2"
  }
}

Extra fields sit inside error when they help: issues for invalid_body, each with a path and message; cost and balance for insufficient_credits; and retry_after for rate_limited. The request_id matches the X-Request-Id header.

Error types

Error types
TypeStatusWhat it means
invalid_request_error400, 404, 409Something about the request needs to change.
authentication_error401The API key is missing or no longer valid.
billing_error402The workspace needs more credits.
permission_error403The plan doesn't include this check.
rate_limit_error429Too many requests in the current minute.
api_error5xxA problem on our side, or a feature that isn't live yet.

Error codes

Error codes
CodeStatusWhat it means
invalid_body400The body isn't valid JSON or a field is wrong. issues lists each problem with its path.
too_short400The text has fewer than 40 words.
too_long400The text has more than 25,000 words. Split it into parts.
invalid_image400The image couldn't be read, or is too large or an unsupported type.
unauthorized401The API key is missing, revoked or mistyped.
insufficient_credits402Not enough credits. cost and balance are included.
plan_required403Your plan doesn't include this check. Plagiarism and images need Advanced or Team.
not_found404No scan with that id exists in your workspace.
idempotency_key_reused409This Idempotency-Key was already used with a different request body or endpoint.
request_in_progress409A request with this Idempotency-Key is still running. Retry shortly.
rate_limited429Too many requests, or too many plagiarism checks running at once. Wait retry_after seconds, also sent as the Retry-After header.
server_error500Something failed on our side. Safe to retry with the same Idempotency-Key.
not_available501The endpoint isn't live yet. Today that only applies to POST /image.
unavailable503Detection is briefly unavailable. Retry with exponential backoff.

Retry 429, 500 and 503 responses with exponential backoff, and request_in_progress after a short pause. Other 4xx errors won't succeed on retry until the request changes. Include the request_id when you contact support.

Webhooks

Webhooks tell your server when a scan finishes or fails. They're most useful for plagiarism checks, which run in the background, so you don't have to poll.
  1. Step 1: In the Webhooks section of the Developers page, add your endpoint URL and pick the events you want. Send a test event to check it.
  2. Step 2: Copy that endpoint's signing secret, which starts with whsec_, into your server's environment.
  3. Step 3: On every delivery, verify the signature, then reply with any 2xx status within 10 seconds.
Webhook events
EventWhen it's sent
scan.completedAny scan finished, including ones from POST /detect. data is the scan object with its result.
scan.failedA scan couldn't finish. data.error explains why, and the credits are refunded.
webhook.testSent once, without retries, when you press Send test event on the Developers page. It goes to that endpoint whatever its subscriptions, even if paused. data is a sample completed ai_text scan with metadata.test set to "true".
scan.completed payload
{
  "id": "evt_9sK1mP4rTbX2cQ7vN3wL8yZd",
  "object": "event",
  "type": "scan.completed",
  "created_at": "2026-10-18T09:27:05Z",
  "data": {
    "id": "scn_b3e07d5a1c9f4e2d8a6b0c4f7e1d9a35",
    "object": "scan",
    "type": "plagiarism",
    "status": "completed",
    "credits_used": 96,
    "metadata": {
      "submission_id": "essay-1042"
    },
    "created_at": "2026-10-18T09:26:40Z",
    "completed_at": "2026-10-18T09:27:05Z",
    "result": {
      "similarity": 22.9,
      "word_count": 48,
      "matched_words": 11,
      "sources": [
        {
          "url": "https://example.com/blog/remote-hiring-guide",
          "title": "A practical guide to hiring remotely",
          "type": "web",
          "matched_words": 11,
          "percent": 22.9,
          "excerpt": "it enables organizations to tap into a diverse, global talent pool"
        }
      ]
    },
    "error": null
  }
}

Delivery headers

Webhook delivery headers
HeaderMeaning
WinstonAI-SignatureTimestamp and signature, such as t=1760779625,v1=5f2b9c….
WinstonAI-EventThe event type, such as scan.completed.
WinstonAI-DeliveryUnique id for this delivery attempt. Useful when you contact support.

Verify the signature

Compute an HMAC-SHA256 of the timestamp from WinstonAI-Signature, a period and the raw request body, using the endpoint's signing secret. Compare it with v1 in constant time, and reject deliveries more than five minutes old.

import crypto from "node:crypto";

// rawBody: the exact request body as a string, before JSON parsing.
export function verifyWebhook(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}

Delivery and retries

  • Deliveries that time out or get a non-2xx reply are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 15 hours: seven attempts over about a day.
  • An event can arrive more than once or out of order. Store the event id and skip ones you have already handled.
  • Each endpoint has its own signing secret, so check deliveries with the secret of the endpoint they reached.

SDKs

Official SDKs are coming

Until then, use any HTTP client; the examples on this page are complete.

Every endpoint above has a cURL, JavaScript and Python example you can copy as it is. They use only fetch and requests, with your key read from WINSTONAI_API_KEY. Tell us which language you'd like an SDK for first.

Send your first scan in minutes

Create a key on the Developers page and run the cURL example above. You'll have a result in about a second. Sending more than a million words a month? We'll price it with you.