The public API
A key from your server's own dashboard, one header, and every endpoint below: this is the whole surface a third party can build on, kept apart from the endpoints the dashboard itself calls. Those are not public, not authenticated the same way, and not documented here or anywhere else.
Building with a Python or npm package instead of raw HTTP: the developers page has the SDK, the CLI and the MCP server. Running your own copy: the same page has the self-hosted API image. This page is for a request against jevmod.dev itself, with a key issued for your server.
Jump to a section (5)
Get a key, then one header.
Sign in, open your server, then API keys, then Create key. That page shows the key exactly once; after that jevmod itself does not have the plaintext to show you again. It calls the same internal endpoint your server's Discord commands use to mint a key against your own tenant. POST /v1/keys below is not that: it needs an operator's own key-minting token, which a hosted customer is never issued, so it is not how you get a key for jevmod.dev.
Send the key as Authorization: Bearer jm_... on every request. Leave the header off, or send it without the Bearer prefix, and the API answers 401 with missing bearer token. Send a key nobody minted, or one you already revoked, and it answers 401 with unknown api key. There is no third state: a key either resolves to your tenant or it does not.
The key is stored as a SHA-256 hash, the same hash every request is checked against; jevmod does not keep the plaintext anywhere after the response that showed it once. A key you lose cannot be recovered. Revoke it and issue a new one from the same API keys page.
Request
curl -X POST https://jevmod.dev/v1/moderate \
-H "Authorization: Bearer jm_..." -H "Content-Type: application/json" \
-d '{"messages":[{"id":"a","text":"FREE NITRO for the first 100!! claim at discord-gifts.ru/nitro"}]}'
Every endpoint.
POST /v1/moderate- Takes 1 to 50 messages, each an id, text up to 8,000 characters, an optional channel topic and an
author_trustedflag; returns one decision per message plus this month's usage. AnX-Request-Idheader you send comes back unchanged asrequest_idin the body and as the same header. If judging itself fails, for example the model is unreachable and this tenant's policy does not fail open, it answers502withjudgment failed: <exception type>; a batch over 50 or a message over 8,000 characters answers422before anything is judged. GET /v1/policy- Returns this tenant's thresholds, actions and custom rules as one object. No body, no refusal beyond the shared auth check above.
PUT /v1/policy- Merges in whichever of
thresholds,actions,rules,rule_actions,rule_thresholdsandtimeout_minutesyou send, and returns the resulting policy in full. Any other field name in the body answers422, on purpose: a typo that silently did nothing used to be how this endpoint worked. An invalid category, action or rule name also answers422, with the reason in the body. GET /v1/decisions- The audit log, most recent first.
?limit=asks for up to 500; a higher number is quietly capped at 500 rather than refused, and the default with no query string is 50. DELETE /v1/tenant- Deletes every row this tenant has: its policy, usage counters, decision log, and every API key issued for it, including the one that just authenticated this call, so the same key cannot call it, or anything else, again. It also deletes jevmod's own record of a Stripe subscription for this tenant, but it never calls Stripe itself: a subscription still active there keeps charging the card until it is cancelled in Stripe's own billing portal.
POST /v1/keys- Mints a key for a tenant. Guarded by a key-minting token, not by a tenant's own key, so this is how a self-hosted operator issues the first key against their own copy; it is not reachable with a hosted customer's key and is not part of the hosted integration surface. Answers
403without the right token. GET /v1/health,GET /metrics- Liveness and Prometheus counters. No key required; nothing tenant-specific in the response.
The machine-readable schema for every path above is at /openapi.json, generated from the same code that serves the requests, so it cannot drift from what is documented here. A ready-to-import collection is at /jevmod.postman_collection.json.
The shape of a decision.
Every decision carries action (none, flag, delete or timeout), the category that triggered it or null, a probability from 0 to 1, and scores: every category you have enabled, each with its own probability, whether or not it crossed a line. judged is true only when the message actually reached the model; reason says why it did or did not.
A probability is Jev's confidence that the message belongs to that category, nothing more. Where the line sits is yours: PUT /v1/policy sets a threshold from 0 to 1 per category and per rule, and only a probability at or above it turns into an action. jevmod ships thresholds it considers reasonable defaults; nothing about the API enforces them.
One decision
{
"message_id": "a",
"action": "flag",
"category": "scam",
"probability": 0.99,
"scores": {
"spam": 0.98,
"scam": 0.99,
"harassment": 0.02
},
"judged": true,
"reason": "jev"
}
The limits that actually bite.
One request judges at most 50 messages. Send 51 and POST /v1/moderate refuses the whole request with 422 before spending anything, rather than judging the first 50 and dropping the rest. There is no way to raise this per key; a caller sending more traffic sends more requests, not bigger ones.
A server on the Free plan never reaches the model at all, from any surface, including this one: every message comes back with judged: false and reason: "free_plan", at zero cost and with no quota to run out of. Local rules, blocked words, links, patterns and the flood filter, still run and can still flag, delete or time out; only the model call is skipped.
Trial and Pro judge up to 50,000 messages a month by default. Past that, every message for the rest of the month comes back judged: false, reason: "quota", at 200, not an error status: it is up to the caller to notice the reason field. The Discord and Telegram bots post one notice in the log channel the first time a server hits it each month; a caller using only the HTTP API gets no notice beyond that field, so a tenant that batches its own requests should watch for it explicitly rather than assume a paused server is a bug on jevmod's side.
An operator can also set a shared spend ceiling across every tenant at once, separate from any one tenant's own quota. If jevmod.dev has one configured and traffic across the whole service reaches it, every affected message comes back reason: "global_budget" or reason: "free_budget" depending on which ceiling was hit, at 200, the same shape as a quota pause. This page does not state whether such a ceiling is active right now, because that is an operational setting that can change without a version bump; a caller that already handles reason: "quota" handles this the same way.
If the model itself cannot be reached, jevmod fails open by default: the batch comes back judged: false, reason: "error_open", and nothing is acted on. Nothing is deleted and nobody is timed out on a guess.
Every reason value a caller can see from this API: jev (judged normally), cache (answered from an in-memory repeat within 24 hours), local (a link, word, pattern or raid rule decided it before the model was asked; category then says which one, as local:link, local:word, local:pattern:<name> or local:raid), policy inactive (every category is off and no local rule is set, so there is nothing to check against), quota, free_plan, global_budget, free_budget and error_open. All but jev and cache mean judged: false.
A score moves with where it sits in its batch
Measured directly, not assumed: the same thirty messages scored alone, first, last and in different company inside batches of twenty-five moved by an average of 0.071 in spam score between the first slot and the twenty-fifth, five to fifteen times the noise floor of asking twice with nothing changed.
The worst case observed was a scam-labelled message reading 0.73 in the first slot of its batch and 0.93 in the last, crossing its own shipped threshold of 0.75 on position alone, with nothing about the message itself different between the two calls.
Nine of 210 category-message cells crossed their shipped threshold this way, all nine in spam, scam or doxxing, the three categories with the loosest default lines. Harassment, nsfw, selfharm and minors held steady across every position tested.
That measurement used batches of twenty-five; this API accepts batches up to 50, larger than what was measured, so a caller building its own large batches has less evidence about its own actual spread than these numbers give, not more. A caller whose own batching keeps a message's neighbours and position roughly stable between calls will not see this; one that reorders or resizes its own batches between an initial check and a re-check should expect the same message to score differently for no reason of its own.
What v1 promises.
Every path on this page keeps its method, its path and what a valid request means: the same call answers the same question. A field that validates today keeps validating; a field name is never removed or repurposed under the same version. New optional fields can appear in a request body, and new fields can appear in a response, at any time, without a version change. A well-behaved caller ignores a response field it does not recognize rather than rejecting the whole payload.
A category jevmod adds after today ships off by default, the same way the ai_generated category did when it was added: it does not appear as a new key in scores, and it cannot produce a new action, for a tenant that never turned it on. Turning one on is always an explicit PUT /v1/policy, never something a version change does for you.
Not promised: the exact wording after a status code, this page's own prose, latency, or that a limit named above keeps the same number. Those can change without a new version, because none of them change what a request means or whether it succeeds.
A change that would break the promises above, removing a field, changing what a status code means, or changing how a key authenticates, ships as a new path prefix, /v2/, with /v1/ left running rather than turned off under callers who have not moved. There is no /v2/ today; when one exists, it is announced on this page first.