# Unprofane documentation A profanity and toxicity API that is hard to fool. One call, `POST https://api.unprofane.com/v1/check`, answers whether a text can be shown: `action` (allow, review, block), the evidence (`matches` with spans, normalized forms and named evasion tags), `severity`, per-category scores and a `censored` version. Auth is `Authorization: Bearer up_live_…`; test keys `up_test_…` are free. No request text is stored. Byte-compatible Perspective API drop-in at `/v1alpha1/comments:analyze`. MCP server at `https://api.unprofane.com/mcp`. Site: https://unprofane.com. OpenAPI: https://api.unprofane.com/openapi.json. ## Quickstart 1. Sign in at https://unprofane.com/signin/ to get a test key (`up_test_…`, 100 checks a day per account, free, no card). Live and publishable keys need a plan, from $9 a month; with none they answer 402 `subscription-required`. 2. Send one request: ```bash curl -X POST https://api.unprofane.com/v1/check \ -H "Authorization: Bearer up_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"text":"xXsh1tXx","context":"username","policy":"teen"}' ``` 3. Read `action`, `matches` and `censored`. `engine.route` says whether the rules or the model answered; `engine.degraded` is true when the model was wanted and unavailable. # API reference Base URL `https://api.unprofane.com`. JSON in, JSON out, `application/problem+json` on failure. Response headers: `Request-Id`, `Unprofane-Engine`, `Unprofane-Route`, `Server-Timing`, `RateLimit-Policy`. ## POST /v1/check Check one text. ```bash curl -X POST https://api.unprofane.com/v1/check \ -H "Authorization: Bearer up_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"text":"xXsh1tXx","context":"username","policy":"teen"}' ``` Request (CheckRequest): | Field | Required | Default | Meaning | |---|---|---|---| | `text` | yes | | The text to check. At most 10,000 characters; 64 in the username and display_name contexts. | | `context` | no | `"general"` | What the text is: general, username, display_name, comment, chat, gaming, educational, creative, review or custom_text. Usernames are split into words and checked reversed; a comment is read as running text; in gaming a taunt about play is not acted on, under every policy but kids; in educational a word quoted or named is discussed, and in creative a quotation is dialogue. | | `policy` | no | `"standard"` | A preset (kids, teen, standard, strict), a saved policy id (pol_…) from the dashboard, or an object mapping each category to allow, review or block. | | `mode` | no | `"balanced"` | fast never asks the model; balanced asks it about every text the rules do not block outright; thorough asks it about every text. | | `language` | no | `"auto"` | auto detects. Set a code to skip detection when you already know the language of the text. | | `mask` | no | `"asterisk"` | How the censored string is written: asterisk (s***), first_letter (s***), grawlix (#@!%) or remove. | | `engine` | no | | A dated engine version to pin this request to, such as 2026-09-29. Defaults to the project pin, then the latest stable. | | `lists` | no | | Allow and block terms for this request only, added to the project's lists: up to 50 of each, 64 characters each, read through the same disguises as the text. An allowed phrase clears only the words inside it. Not accepted with a publishable key. | Response (CheckResponse): | Field | Required | Default | Meaning | |---|---|---|---| | `id` | yes | | The check id (chk_…), for a feedback call. Checks are not stored; this is a correlation handle. | | `allowed` | yes | | True when the action is allow. | | `action` | yes | | allow, review or block, from the policy applied to the categories. | | `score` | yes | | The highest category score, 0 to 1. | | `severity` | yes | | none, mild, moderate, strong or extreme: the strongest matched term, or the model’s answer. | | `categories` | yes | | A score from 0 to 1 per policy category: mild_profanity, profanity, sexual, insult, slur, threat, rude, sexual_minors and self_harm (the last three the model's alone) and reserved. | | `obfuscated` | yes | | True when any match was reached through a named evasion transform. | | `evasion` | yes | | Every evasion tag that fired anywhere in the text, deduplicated. | | `matches` | yes | | Each matched term with its UTF-16 span in the original, the text as typed, the normalized form, the term id, its category, severity and the evasion tags on that span. | | `censored` | yes | | The original text with every match masked as `mask` asks. | | `reason` | yes | | Why the action is what it is, in one sentence written from the matches and the policy, for a moderator or a log: the words read, their disguise, and what the model read where it was asked. Decide on `action`; the wording may change. | | `language` | yes | | The detected language code and the detector’s confidence. | | `engine` | yes | | Which version answered, which route (rules, model or cache), and whether the answer is degraded because the model was unavailable. | | `timing_ms` | yes | | Time inside the engine, in milliseconds. | ## POST /v1/check/batch Check up to 100 texts. ```bash curl -X POST https://api.unprofane.com/v1/check/batch \ -H "Authorization: Bearer up_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"items":[{"id":"a","text":"Have a lovely day"},{"id":"b","text":"f.u.c.k off"}],"context":"comment","policy":"standard"}' ``` Request (BatchRequest): | Field | Required | Default | Meaning | |---|---|---|---| | `items` | yes | | Up to 100 items, each with an id you choose (returned in order) and a text; an item may override context and policy. | | `context` | no | `"general"` | The context for every item that does not set its own. | | `policy` | no | `"standard"` | The policy for every item that does not set its own. | | `mode` | no | `"balanced"` | The mode for the whole batch. Four model calls run at a time. | | `mask` | no | `"asterisk"` | The mask for the whole batch. | | `engine` | no | | A dated engine version for the whole batch. | | `lists` | no | | Allow and block terms for every item, added to the project's lists. | Response (BatchResponse): | Field | Required | Default | Meaning | |---|---|---|---| | `results` | yes | | One entry per item, in order: { id, result } on success or { id, error } when that item alone failed. One bad item never fails the batch. | | `counted` | yes | | How many items counted against the allowance. | ## POST /v1/feedback Send a correction. ```bash curl -X POST https://api.unprofane.com/v1/feedback \ -H "Authorization: Bearer up_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"check_id":"chk_0123456789abcdefGHIJ","verdict":"false_positive","expected_action":"allow"}' ``` Request (FeedbackRequest): | Field | Required | Default | Meaning | |---|---|---|---| | `check_id` | yes | | The id from the response you are correcting. | | `verdict` | yes | | false_positive, false_negative or correct. | | `expected_action` | no | | What the action should have been, when you know. | | `note` | no | | Up to 500 characters for a person to read. | | `text` | no | | The text itself, only if you want us to have it. This is the one field that is stored; it leaves with your account. | Response (FeedbackResponse): | Field | Required | Default | Meaning | |---|---|---|---| | `id` | yes | | The feedback id (fb_…). | | `received` | yes | | Always true on a 202. | ## GET /v1/usage Read this period’s usage. ```bash curl -X GET https://api.unprofane.com/v1/usage \ -H "Authorization: Bearer up_live_YOUR_KEY" ``` Response (UsageResponse): | Field | Required | Default | Meaning | |---|---|---|---| | `period` | yes | | The current billing period’s start and end, ISO 8601. | | `plan` | yes | | The account’s plan. | | `checks` | yes | | Billable checks so far this period, from closed hours; the current hour is not yet in. | | `included` | yes | | The plan’s allowance, or null for a custom plan. | | `overage` | yes | | Checks past the allowance so far. | | `routes` | yes | | The share of checks answered by the rules, the model and the cache. | | `degraded` | yes | | The share of checks answered by the rules because the model was unavailable. | ## GET /v1/projects/prj_YOUR_PROJECT_ID/lists/allow Read a project list. ```bash curl -X GET https://api.unprofane.com/v1/projects/prj_YOUR_PROJECT_ID/lists/allow \ -H "Authorization: Bearer up_live_YOUR_KEY" ``` Response (List): | Field | Required | Default | Meaning | |---|---|---|---| | `kind` | yes | | allow, block or reserved. | | `terms` | yes | | The terms as they were sent, sorted. | | `version` | yes | | Bumped on every change; keys pick the new list up within a minute. | ## PUT /v1/projects/prj_YOUR_PROJECT_ID/lists/allow Replace a project list. ```bash curl -X PUT https://api.unprofane.com/v1/projects/prj_YOUR_PROJECT_ID/lists/allow \ -H "Authorization: Bearer up_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"terms":["Scunthorpe","Dickinson","cockatoo"]}' ``` Request (ListPut): | Field | Required | Default | Meaning | |---|---|---|---| | `terms` | yes | | Up to 5,000 terms of up to 64 characters. The list is replaced, not merged. | Response (List): | Field | Required | Default | Meaning | |---|---|---|---| | `kind` | yes | | allow, block or reserved. | | `terms` | yes | | The terms as they were sent, sorted. | | `version` | yes | | Bumped on every change; keys pick the new list up within a minute. | ## DELETE /v1/projects/{id}/lists/{kind} Empty a list. 204. ## POST /v1alpha1/comments:analyze Byte-compatible with Google's Perspective API: same request, same response, same errors; key as `?key=` or bearer. `comments:suggestscore` is accepted. ## GET /health No auth. `{ ok, engine, lexicon }`. # Errors | Type | Status | Title | What to do | |---|---|---|---| | `https://unprofane.com/docs/errors/invalid-request` | 400 | The request body is not valid. | Read `detail`: it names the field and what it accepts. The request schema is in the API reference and in openapi.json. | | `https://unprofane.com/docs/errors/unauthorized` | 401 | No valid API key. | Send `Authorization: Bearer up_live_…`. A key with a typo is refused before any lookup by its checksum; copy it again from the dashboard. | | `https://unprofane.com/docs/errors/key-revoked` | 401 | This API key was revoked. | The key was revoked in the dashboard. Create a new one and replace it. | | `https://unprofane.com/docs/errors/forbidden-origin` | 403 | This origin is not allowed for this publishable key. | A publishable key is limited to the origins on its project. Add the origin in the dashboard, or use a secret key from your server. Publishable keys may only call /v1/check. | | `https://unprofane.com/docs/errors/forbidden-secret-cors` | 403 | Secret keys cannot be used from a browser. | A secret key in a browser is visible to every visitor. Use a publishable key (up_pk_…) with an origin allowlist, or call from your server. | | `https://unprofane.com/docs/errors/not-found` | 404 | No such resource. | Check the path. Lists are allow, block and reserved, and a key reads only its own project’s lists. | | `https://unprofane.com/docs/errors/method-not-allowed` | 405 | Method not allowed. | Use POST for checks and feedback, GET for usage and health, PUT and DELETE for lists. | | `https://unprofane.com/docs/errors/unsupported-media-type` | 415 | Send application/json. | Send `Content-Type: application/json`. Nothing accepts a form or a file. | | `https://unprofane.com/docs/errors/payload-too-large` | 413 | The request body is too large. | Text is at most 10,000 characters, a batch at most 100 items, a list at most 5,000 terms. | | `https://unprofane.com/docs/errors/subscription-required` | 402 | A plan is required. | Live and publishable keys answer only on a plan. An account with no plan (a new one, or one whose plan was cancelled, went unpaid past the grace or whose research grant ended) has test keys only, 100 checks a day. Choose a plan on the pricing page or the dashboard’s billing page, from $9 a month; the same keys answer within a minute of the merchant’s confirmation. Until then, build with a test key. | | `https://unprofane.com/docs/errors/quota-exceeded` | 429 | This plan’s monthly checks are used up. | The Research plan stops at its monthly allowance, and a paid plan stops at the spend cap its owner set. Raise the cap or choose a plan in the dashboard, or wait for the period to reset. A paid plan with no spend cap is never stopped. | | `https://unprofane.com/docs/errors/rate-limited` | 429 | Too many requests. | Wait `Retry-After` seconds. Bursts are per key per minute by plan, and per address for publishable keys. | | `https://unprofane.com/docs/errors/test-key-daily-cap` | 429 | Test keys are capped per day. | An account’s test keys share one cap of 100 checks a day, on every plan and with none. Use a live key on a plan, or wait for tomorrow (UTC). | | `https://unprofane.com/docs/errors/engine-unknown` | 400 | No such engine version. | Pin a version listed on the changelog, such as 2026-09-29, or leave `engine` out for the latest stable. | | `https://unprofane.com/docs/errors/misconfigured` | 503 | The server is temporarily misconfigured. | A server setting is missing on our side. Retry shortly; we are alerted. | # Contexts What the text is changes how it is read. Usernames are split into words and checked backwards; comments are read as prose. `context` tells the engine what kind of text it is looking at. The lexicon, the transforms and the guards are the same everywhere; what changes is how the text is cut up and which tricks are worth trying. | Context | For | What is different | |---|---|---| | `general` | Anything, the default | Read as running text. | | `username` | A handle shown publicly | At most 64 characters. camelCase, digit runs and underscores are split, and the string is segmented into words with a frequency list, so `xXsh1tXx` and `ilovesh1t` are read. The reversed string is checked too. Reserved names (admin, support, the brand's own) match. | | `display_name` | A name shown beside a person's content | As `username`, without the reserved list. | | `comment` | A post or a comment | Running text; the model tier is consulted for insults and threats that a word list cannot see. | | `chat` | One line of a conversation | As `comment`, tuned for short lines. | | `gaming` | A line of a game's chat, between players | As `chat` to the rules. The model is told it reads a game's chat, where killing or destroying players describes play and harm outside the game does not. Under every policy but `kids`, a taunt about how someone plays (`noob`, `gg ez, uninstall`) and rudeness are not acted on; a slur, a threat, `kys`, sexual language, swearing, an insult about the person (`idiot`, `stupid`) and a personal attack are. A threat inside the game (`u will die`) goes to the model in `balanced`. | | `educational` | A lesson, a paper, a history or a dictionary entry | Read as `general`, with every quotation read as speech: a word quoted, named or reported (`the word 'fuck'`, `Nazi propaganda called Jews "untermenschen"`) is discussed, swearing and slurs included, and the model reads it. A word used on someone (`you are a fucking idiot`) or a slur outside quotes still counts. | | `creative` | Fiction, roleplay, lyrics | Read as `general`, with every quotation read as dialogue: a character's threat or oath (`"I'll kill you for this," she hissed`) goes to the model, and the model is told a threat to a real person is not fiction. A slur counts even in dialogue; narration is read as any text is. | | `review` | A product or place review | As `comment`. The anatomy and animal guards are widest here: a review of a chicken breast is not profane. | | `custom_text` | Text a customer typed for engraving, printing or a product | Read as `general`; the response is meant to be shown back to the buyer, so `censored` matters most. | ## Which to choose Pick the one that names the field. When none does, `general`. A wrong context is not a broken check; it is a slightly worse one: a username in `general` will not be segmented, so `ilovesh1t` may pass. ## Game chat The same line reads differently in a game. In `chat`, `nice try loser` is an insult aimed at someone and blocks under `standard`; in `gaming` it is a taunt and is allowed. `I know where you live. See you after the match.` blocks in both. | Text | `chat` | `gaming` | |---|---|---| | `gg ez, uninstall` | block | allow | | `nice try loser` | block | allow | | `kys noob` | block | block | | `you idiot` | block | block | | `I know where you live` | block | block | The table is the rules alone (`mode: "fast"`). An insult about the person (`you idiot`, `stupid mid`) is no taunt in a game either; game slang about play (`noob`, `bot`, `trash`) is. In `balanced`, the model reads the rest: an insult it reads as less than moderate is a taunt, and a personal attack (`your mom must be so ashamed of you`) is not. A threat inside the game (`u will die`, `te mato`) is no certain block in `gaming`: in `balanced` the model reads it, and with the rules alone it still blocks. A game for children uses `policy: "kids"`, which keeps every rule of `chat`. ## Lessons and fiction Quotation marks are read the same way in every language the engine covers (`"…"`, `«…»`, `„…“`, `「…」`). | Text | `general` | `educational` | `creative` | |---|---|---|---| | `The word 'fuck' has many uses.` | review | allow | allow | | `"You bastard," she hissed. "I'll kill you for this."` | block | allow | allow | | `La palabra «puta» tiene muchos usos.` | block | allow | allow | | `«Я тебя убью, сука», прошептал он.` | block | allow | allow | | `you are a fucking idiot` | block | block | block | The table is the rules alone (`mode: "fast"`). In `balanced`, the model reads what the rules handed it. ## Project defaults Set the context once on the project in the dashboard and leave it out of every request. A request that sends one overrides the default. ```json { "text": "xXsh1tXx", "context": "username" } ``` # Policies How strict to be. Four presets, or your own map of category to allow, review or block, saved on the project. A policy turns the categories the engine scored into one action. The engine never decides on its own: the model, when it is asked, returns numbers, and the policy code you can read on this page turns them into `allow`, `review` or `block`. ## The categories | Category | What it covers | |---|---| | `mild_profanity` | damn, hell, crap and their kin | | `profanity` | The strong words | | `sexual` | Sexual language and anatomy used sexually | | `insult` | Attacks on a person | | `slur` | Attacks on a group | | `threat` | Threats of harm | | `rude` | Rude, disrespectful or harsh text short of an insult: "shut up", "what nonsense", scornful anger at a party or a public figure. Only the model reads it, so an Edge-only project or `mode: "fast"` never scores it | | `sexual_minors` | Sexual content involving a child or a minor. Every preset blocks it. Only the model reads it | | `self_harm` | The writer's own self-harm or suicidal thoughts, plans or acts; urging someone else to hurt themselves is `threat`. Every preset holds it for review, so a person who needs help is seen by someone rather than silenced. Only the model reads it | | `reserved` | Names your project reserves (admin, staff, your brand), username contexts only | ## The presets Each preset is an action per category, with thresholds fitted per version on the benchmark's development split against a false-positive ceiling on innocent text. | Preset | For | Behaviour | |---|---|---| | `kids` | Under-13 products | Everything blocked, mild profanity and rudeness included; it reviews early and blocks early. | | `teen` | 13 to 17 | Mild profanity allowed; everything else blocked, rudeness included. | | `standard` | The default for adults | Mild profanity allowed; profanity, sexual talk and rudeness reviewed; insults, slurs, threats and sexual harassment aimed at a person ("suck my dick", "send nudes") blocked. | | `strict` | Brand-safe surfaces: engraving, printing, marketplaces | As kids, for adults. | In every preset, sexual content involving a minor and a name your project reserves are blocked, and the writer's own self-harm is held for review. ## Your own policy Send an object instead of a preset. Every category you leave out takes the `standard` action; leave out `sexual` and sexual harassment aimed at a person is blocked, as `standard` blocks it, while a `sexual` you set applies to it too. ```json { "text": "…", "policy": { "mild_profanity": "allow", "profanity": "allow", "insult": "review", "slur": "block", "threat": "block" } } ``` A slur is a slur on the page whoever wrote it: quoted or reported ("he called me a …"), it counts as the slur, as quoted profanity does. A slur someone claims for their own group, and a hateful sentence with no slur that the writer quotes to condemn it, go to the model to read. Save it on the project in the dashboard and it gets an id, `pol_…`, that a request can send instead of the object. Up to twenty saved policies per project. ## What `review` means Review is for text a person should see before it is shown, or for a product that shows it and flags it. If your product has no queue, treat `review` as `block` for usernames and as `allow` for comments, and send corrections through [feedback](/docs/feedback/) when it gets one wrong. # Evasion tags Every way a writer disguises a word is normalised and named in the response, so you can see how the filter was fooled and choose what to do about it. The engine reads a text in several views at once: as typed, with look-alike characters folded, with separators joined, with digits read as letters, reversed, and split into words. A match remembers which transforms it took to reach it, and the response names them. | Tag | Fires when | Example | |---|---|---| | `homoglyph` | A non-Latin look-alike was folded to a Latin letter inside a Latin word | `fυck` (Greek upsilon) | | `zero_width` | A zero-width or direction-control character was removed | `sh​it` | | `separator` | A spelled-out run was joined | `f.u.c.k`, `s h i t` | | `leetspeak` | A digit or symbol was read as a letter | `sh1t`, `a$$` | | `repeated_chars` | A run of three or more of one character was squeezed | `shiiiit` | | `diacritics` | A letter lost its accent | `shït` | | `mixed_script` | A word carried letters from more than one script | Latin with Cyrillic | | `fullwidth` | A fullwidth form was folded | `shit` | | `combining_marks` | A combining mark was removed | `s̶h̶i̶t̶` | | `phonetic` | `ph`, `kk` or a `z` for `s` was read | `phuck` | | `reversed` | The match came from the reversed string (usernames only) | `tihs_xX` | | `concatenation` | The match sits at a boundary inferred by splitting a username into words | `ilovesh1t` | | `case_mixing` | The match sits under unusual capitalisation | `ShIt` | | `self_censored` | The writer censored the word themselves | `sh*t`, `f***` | `obfuscated` is true when any match carried a tag. `evasion` at the top of the response is the union over all matches; each match lists its own. ## Using the tags A username with `leetspeak` or `homoglyph` in it is someone trying: many products block it outright whatever the policy says. A `self_censored` match is usually the opposite: someone being polite. The tags are also how a correction is understood: when you report a false positive on `Scunthorpe`, the fact that no tag fired tells us it is a dictionary problem, not a transform problem. ## What each tag costs Every transform is a chance to over-read innocent text, so each carries a weight in the engine, and a match reached through a weak transform on its own (a lone `diacritics` fold, say) scores lower than the same word typed plainly. The disguise discount is part of the calibration a version pins. # Engine versions Versions are dated and immutable. Pin one per project or per request, read the changelog, and replay a sample before you move. Every answer names the engine version that produced it, in `engine.version` and in the `Unprofane-Engine` header. A version is the lexicon, the questions the model is asked, the thresholds and the pinned model string, hashed together; once published it does not change. A behaviour change is a new dated version and a line on the [changelog](/changelog/). ## The default A request with no `engine` uses the project's pin, and a project with no pin uses the latest stable version. A `preview` version can be pinned before it becomes the default. ## Pinning Per request: ```json { "text": "…", "engine": "2026-09-29" } ``` Per project: the dashboard's project page. Pinning is included on Growth and above. ## Moving between versions 1. Read the changelog entry. 2. On Growth and above, replay a CSV of your own texts against both versions from the project page. The file is processed in memory and discarded, and the answer is a table of the texts whose action changed. 3. Change the pin. Every key on the project picks it up within a minute. A retired version is never the default but stays pinnable, so nothing you ship changes under you. # Lists Allow, block and reserved lists per project, from the dashboard or the API. Terms are folded the way the engine folds text, so a listed word matches its disguises too. Each project has three lists. A term on a list is folded the same way the engine folds text (case, look-alikes, separators, leetspeak), so `Scunthorpe` on the allow list also allows `SCUNTHORPE` and `Scunth0rpe`, and `pwnzor` on the block list blocks `p.w.n.z.o.r`. | List | Effect | |---|---| | `allow` | The term never matches, in any context. For place names, surnames, product names and the words your community uses harmlessly. | | `block` | The term matches as `profanity`, severity `strong`, whatever the lexicon says. For words that are a problem in your product and nowhere else. | | `reserved` | In the username contexts, the term matches as `reserved`. For admin, support, moderator and your own brand. | ## From the dashboard The lists page takes one term per line, or a CSV. The list is replaced on save. ## From the API Read, replace or empty a list with the project's own key. A key sees only its own project. ```bash curl -X PUT https://api.unprofane.com/v1/projects/prj_YOUR_PROJECT_ID/lists/allow \ -H "Authorization: Bearer up_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"terms":["Scunthorpe","Dickinson","cockatoo"]}' ``` Up to 5,000 terms of up to 64 characters per list. Every change bumps the list version, and every key on the project reads the new list within a minute. ## With a request A check or a batch can carry its own `allow` and `block` terms, added to the project's lists for that request only. Use it when the terms depend on where the text is going: a channel's own banned words, or the names in one game's lobby. ```json { "text": "we sell big ass fans, add me on t3l3gr4m", "lists": { "allow": ["big ass fans"], "block": ["telegram"] } } ``` Up to 50 terms of each kind, 64 characters each, folded as the project's are. An allowed phrase clears the words inside it and no others: `big ass fans` allows that phrase, not `ass` on its own. A publishable key cannot send lists, since anyone can write a browser's request; send them from your server. ## What a list cannot do An allow-listed term inside a longer profane string still matches on the longer string, and an allowed phrase clears only what it covers. A block-listed term does not become a slur or a threat; it is profanity. Lists are per project, not per account: make a project per product. # Publishable keys A key that may live in a browser or a game client, limited to /v1/check and to the origins you allow, with a per-address limit. A secret key (`up_live_…`) is refused from a browser, because a key in a browser is visible to every visitor. For a username field that checks as the person types, make a publishable key (`up_pk_…`) instead. ## What it can do - Call `/v1/check` only. No batch, no feedback, no usage, no lists. - From the origins listed on its project, checked against the browser's `Origin` header, with CORS headers in the answer. - At thirty requests a minute per address, on top of the plan's own limit. ## Setting it up 1. Add your site's origin to the project in the dashboard: `https://game.example`, no path. Up to fifty. 2. Create a key of kind *publishable*. 3. Call from the page: ```javascript const res = await fetch('https://api.unprofane.com/v1/check', { method: 'POST', headers: { Authorization: 'Bearer up_pk_YOUR_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ text: username, context: 'username' }), }); ``` ## Games Unity and Godot builds are not browsers, so the origin check cannot apply to them, and a key in a build is a key anyone can read. Check the final username from your own server with a secret key, and use the publishable key only for the live hint in the client. The [Unity](/recipes/unity/) and [Godot](/recipes/godot/) recipes show both halves. # Edge-only projects A project setting that guarantees no text is ever sent to a model. The model client is not constructed, so no code path can reach it. In balanced mode, the default, the rules answer at the edge only the texts they block outright; every other text is sent to the model tier, with the questions and nothing about who sent it, because a word list cannot read rudeness or abuse written in ordinary words. On mostly clean traffic that is nearly every check. Some products cannot allow even that: a medical forum, a children's app under a data agreement, a customer under a contract that names every processor. For them, switch the project to **Edge-only** in the dashboard. ## What it guarantees - Every check on the project is answered by the rules. `engine.route` is always `rules`. - The model client is never constructed for the project. This is not a flag checked before a call; the provider object does not exist, and a test in the source holds that an Edge-only check handed a provider throws before any text could travel. - `mode` is ignored: `thorough` behaves as `fast`. ## What it costs The [Scunthorpe Test](/bench/scunthorpe/) reports the measured rules-only F1 and false-positive rate for the named corpus and run. Edge-only gives up the model's reading of running text with no word-list hit, ambiguous insults or threats, and rudeness, which only the model scores. Check the results for your language and context, then replay representative examples from your own application before choosing a policy. ## Per request `mode: "fast"` gives the same guarantee for one request on any project. # Rate limits and quotas Bursts per key per minute by plan, the monthly allowance, what happens past it, how test keys are capped, and what a key does with no plan. ## Bursts Each key may make a number of requests a minute set by the plan: Hobby 120, Starter 600, Growth 3,000, Scale 12,000, and 60 for a test key on an account with no plan. Every response carries `RateLimit-Policy`; a refusal is a 429 with type `rate-limited` and a `Retry-After` header. Publishable keys also carry a limit of thirty a minute per address. Bursts are per key. A product that needs more than the plan's burst on one key is a product that needs a bigger plan, or a batch. ## The monthly allowance A check is one text: each batch item counts, a cached answer counts, a refused request does not. The allowance resets with the billing period. - **Research** stops at the allowance with a 429 of type `quota-exceeded` until the period resets. - **Paid plans** continue. Checks past the allowance are billed at the plan's rate on the same invoice, and you are emailed at 80% and at 100%. - **A spend cap**, if you set one on the billing page, stops a paid plan's live and publishable keys once the period's overage reaches it, with the same 429 `quota-exceeded`, until the period resets or you raise it. The current count is on the dashboard and at `/v1/usage`. ## No plan A new account has no plan, and so does one whose plan was cancelled, went unpaid past the seven-day grace, or whose research grant ended. It has test keys only. Its live and publishable keys are refused with a 402 of type `subscription-required` before anything is checked, until it chooses a plan; the same keys then answer within a minute. Plans start with Hobby at $9 a month. ## Test keys `up_test_…` keys share one cap of 100 checks a day per account, on every plan and with no plan, however many you make, answered with type `test-key-daily-cap` past that. They are never billed and never count toward an allowance, except on Research, where they count toward the grant's. They run the same engine as live keys, and they are for building and testing, not for serving your users. ## Quota checks are not on the hot path The allowance is checked from a flag that a cron refreshes every five minutes from the usage counters, so a check never waits on a count and a plan that has just crossed its allowance may get a few more answers than the number says. # Batch checks Up to 100 texts in one request, each with its own id, answered in order. One bad item never fails the batch. ```json POST /v1/check/batch { "items": [ { "id": "a", "text": "Have a lovely day" }, { "id": "b", "text": "f.u.c.k off", "policy": "kids" } ], "context": "comment", "policy": "standard" } ``` Each item carries an id you choose and may override `context` and `policy`; `mode`, `mask` and `engine` are set once for the batch. Results come back in the same order: ```json { "results": [ { "id": "a", "result": { "allowed": true, "action": "allow", "…": "…" } }, { "id": "b", "result": { "allowed": false, "action": "block", "…": "…" } } ], "counted": 2 } ``` An item that fails validation on its own comes back as `{ "id", "error": { "type", "title", "detail" } }` and the rest are answered. `counted` is what the batch cost against the allowance. Up to four model calls run at a time inside a batch, so a batch of a hundred comments takes a few seconds in balanced mode, where the model reads every item the rules do not block outright; in `fast` mode the rules answer a batch of a hundred in one. # Feedback Send a correction when a result is wrong. Corrections feed the lexicon, the innocent list and the calibration, never a silent change inside a pinned version. Every response carries an id. When the answer was wrong, say so: ```json POST /v1/feedback { "check_id": "chk_…", "verdict": "false_positive", "expected_action": "allow", "note": "It is a surname." } ``` `verdict` is `false_positive`, `false_negative` or `correct`. The answer is a 202 with a feedback id. ## The text Checks are not stored, so the id alone tells us which engine version, which route and which context, but not what the text was. Include `text` only if you want us to have it. It is the one field this product stores, it is kept with your account, and it leaves with the account. The dashboard's feedback page lists every correction and whether it has been applied. ## What happens to a correction A person reads it. A wrong grade or a missing innocent word becomes a change to the lexicon or the innocent list; a threshold that is wrong for a whole class of text becomes part of the next calibration fit. Every such change ships as a new dated engine version with a changelog line. A version you have pinned does not change. # Perspective migration Change the base URL and keep your client. The same path, the same JSON, the same errors, the key as ?key=. Google's Perspective API ends on 31 December 2026. `https://api.unprofane.com/v1alpha1/comments:analyze` answers the same request with the same response and the same error envelope, so a client moves by changing its base URL and its key. ```bash curl -X POST "https://api.unprofane.com/v1alpha1/comments:analyze?key=up_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"comment":{"text":"you are a clown"},"requestedAttributes":{"TOXICITY":{}},"languages":["en"],"doNotStore":true}' ``` ## What is the same - The path, the request body, the response shape (`attributeScores`, `summaryScore`, `spanScores`), the error envelope with `code`, `message` and `status`. - The errors themselves. Before launch we sent Google a request for each way a request can go wrong (the key, the body, each field's type, the attributes, the languages, the size, the quota), and every answer we give is Google's, word for word, with the same status. A test replays all of them against the API on every change. - The production attributes: `TOXICITY`, `SEVERE_TOXICITY`, `IDENTITY_ATTACK`, `INSULT`, `PROFANITY`, `THREAT`, `SEXUALLY_EXPLICIT`, and the experimental aliases. `FLIRTATION` is answered as `SEXUALLY_EXPLICIT`, and `OBSCENE`, whose definition is the same as `PROFANITY`'s, as `PROFANITY`. - The key as `?key=` (the first one, when a client sends two), as an `X-Goog-Api-Key` header, or as a bearer header with an Unprofane key. - The JSON Google read: any content type, a gzip body, single quotes, bare keys, trailing commas, snake_case field names, a list given as one string, a number given as a string, a byte that is not UTF-8 read as a space, and the same nesting limit. - Google's query parameters: `prettyPrint=false` for compact JSON, `fields` to keep some fields (and Google's refusal of a mask it cannot read), `alt=json` (other `alt` values refused as Google refused them), `quotaUser` and the `$` forms. A request field set in the query (`?comment.text=…`) is bound as Google bound it, and a form body is read as query parameters, as Google read it. - A browser's preflight, and the CORS headers on every answer to a request that names its origin. The path with a trailing slash, and `X-HTTP-Method-Override`. - `spanScores`: one span over the whole comment, or one per sentence with `spanAnnotations`. `scoreThreshold` leaves out an attribute under it, `scoreType` takes `PROBABILITY` and `RAW`, and `clientToken` comes back. - `doNotStore` is honoured because nothing is stored either way. - `comments:suggestscore` is accepted and answered with `{}`, as Google answered it. ## What is different - Scores come from a different engine. The rules give each attribute a floor from the matched words; the model's reading lifts the rest. The [replay tool](/perspective/replay/) takes a CSV of your texts with their old scores and maps your thresholds onto ours; it runs in memory and stores nothing. - With `spanAnnotations`, each sentence's span is scored by the rules on that sentence alone; the summary score is the whole engine's. - A byte that is not UTF-8, or half of an escaped emoji pair, is a space to Google and a masked letter to our engine: a word broken by one ("di\xffkhead") is read as the word, where Google's reading let it through. What the response shows (the accepted request, the offsets) is Google's. - A `RAW` score is the same number as the probability: the engine has no separate raw scale. - The other New York Times attributes (`ATTACK_ON_AUTHOR`, `ATTACK_ON_COMMENTER`, `INCOHERENT`, `INFLAMMATORY`, `LIKELY_TO_REJECT`, `SPAM`, `UNSUBSTANTIAL`) and the bridging attributes (`AFFINITY_EXPERIMENTAL`, `COMPASSION_EXPERIMENTAL`, `CURIOSITY_EXPERIMENTAL`, `NUANCE_EXPERIMENTAL`, `PERSONAL_STORY_EXPERIMENTAL`, `REASONING_EXPERIMENTAL`, `RESPECT_EXPERIMENTAL`) are not scored: a request for one answers the error Google gives an attribute it does not know (`Unknown requested attribute`), or leaves it out when `dropUnsupportedAttributes` is set. - Where Google refused and we can answer, we answer: a comment in a language Google detected but did not score (Hebrew, Turkish and the rest are scored), a comment Google found no language in at all (a run of one letter, an escaped half of an emoji pair), unless it is blank, `SEXUALLY_EXPLICIT` in every language the other attributes take (Google scored it in English only), and an `HTML` comment (Google refused it; we score the text without its tags). Languages you name in `languages` are held to Google's list, as Google held them. - A secret key sent from a browser (a page's `fetch`) is refused with `PERMISSION_DENIED`, where Google answered: in a page, the key is visible to every visitor. Call from your server. - A query string Google's front end found too long (a 411 and its HTML page) is read. - A body larger than the route's byte limit is refused before it is read, where Google read far larger ones to say the comment was too long. - A spent minute answers Google's quota error with your project in place of Google's project number and our plans as the place to ask for more. A spent month or a spent daily test cap answers `RESOURCE_EXHAUSTED` in our own words, since Google had neither. - A wrong path or method answers 404, as Google's did, with a JSON body where Google sent its HTML error page. - Our own failure answers `500 INTERNAL` with Google's standard words for its own. Google's other account errors (a key restricted to a referrer or an address, the API not enabled, billing) have no counterpart: a key here is valid or it is not. - Every response also carries `Unprofane-Engine` and `Unprofane-Route` headers, and the usage counts as one check per request. - Until the Civil Comments calibration ships, the scores are the engine's own scale rather than a fit to Perspective's. The replay tool says which is in force. ## Guides by client - [Coral Talk](/perspective/coral/) - [Discourse](/perspective/discourse/) - [Python, googleapiclient](/perspective/python/) - [Node, googleapis](/perspective/node/) - [Raw HTTP](/perspective/http/) # Privacy for developers What the API keeps and what it never keeps, in the terms a developer needs to answer their own users and their own lawyer. The short version: **no request text is stored.** The long version, with the test that keeps each line, is the file TRUST.md in the source; the [privacy page](/legal/privacy/) says it for a reader who is not a developer. ## Text - A check reads the text, answers and forgets it. No database, cache, log or trace holds it. The usage record holds ids, counts and buckets. - Text the rules answer never leaves Cloudflare. In balanced mode, the default, that is only the text they block outright: every other text goes to the model, which on mostly clean traffic is nearly every check. `mode: "fast"` keeps a request on the rules; an [Edge-only project](/docs/edge-only/) keeps every check on the rules by never constructing the model client. - Text sent to the model carries the questions and nothing about the sender. Who processes it, and what they retain, is on the [subprocessors page](/legal/subprocessors/). - The model's numeric answers are cached for seven days under a SHA-256 of the text with the engine version. The key is a hash, the value is numbers. - Feedback stores `text` only when you send it, and deletes it with your account. ## Keys and sessions - Only the SHA-256 of a key is stored, with its prefix and last four characters. A key is shown once. - Sign-in links work once, for fifteen minutes, and are stored as hashes. Sessions are signed cookies, thirty days, revocable. - Rate limits and abuse counters key on an hourly-rotating pseudonym of the address, never the address. ## Operations - Invocation logs and traces are switched off on both services; the deploy step refuses to run while they are on. The AI gateway the model calls pass through has logging and caching off, and the deploy step reads that setting too. - Deleting an account removes everything under it within 24 hours. Nightly backups are kept for 30 days and then deleted. ## Answering your users "Is my message stored?" No. "Was it sent to an AI?" Only if the rules could not settle it, and never on an Edge-only project. "Who can see it?" Nobody: it is not kept. # SDKs The JavaScript and Python clients: typed, no dependencies beyond the runtime, retries with jitter, and a batch helper for any number of texts. Both clients wrap the same endpoints with the same field names as the API reference, so nothing in them can drift from the contract without the contract changing. Both retry a 429, 502, 503 or 504 with jitter and honour `Retry-After`; any other failure raises an error carrying the problem details and the request id. ## JavaScript and TypeScript ```bash npm install @unprofane/sdk ``` ```ts import { Unprofane } from '@unprofane/sdk'; const unprofane = new Unprofane(process.env.UNPROFANE_API_KEY!); const r = await unprofane.check({ text: 'xXsh1tXx', context: 'username', policy: 'teen' }); if (!r.allowed) console.log(r.action, r.evasion, r.censored); // Any number of texts, chunked into batches of 100, results in order. const results = await unprofane.checkMany(comments, { context: 'comment' }); ``` `check`, `checkBatch`, `checkMany`, `feedback`, `usage`, `getList`, `putList`, `deleteList`. Works in Node 18 and later, Bun, Deno, Workers, and browsers with a publishable key. Options: `baseUrl`, `retries` (3), `timeoutMs` (10,000), `fetch`. ## Python ```bash pip install unprofane ``` ```python from unprofane import Unprofane client = Unprofane("up_live_YOUR_KEY") r = client.check("xXsh1tXx", context="username", policy="teen") if not r["allowed"]: print(r["action"], r["evasion"], r["censored"]) ``` ```python from unprofane import AsyncUnprofane async with AsyncUnprofane("up_live_YOUR_KEY") as client: results = await client.check_many(comments, context="comment", concurrency=4) ``` `check`, `check_batch`, `check_many`, `feedback`, `usage`, `get_list`, `put_list`, `delete_list`, sync and async. Python 3.9 and later; the only dependency is httpx. ## Everything else Eight languages of plain HTTP are in the [API reference](/docs/api/), and the [MCP server](/docs/mcp/) gives an agent the same calls as tools. # MCP server The same engine as a Model Context Protocol server, for coding agents and assistants. Three tools, the same keys, Streamable HTTP. `https://api.unprofane.com/mcp` speaks the Model Context Protocol over Streamable HTTP, JSON responses only. Authenticate with the same bearer key as `/v1`; publishable keys are refused. ## Tools | Tool | Does | |---|---| | `check_text` | One check. Arguments are `text`, `context`, `policy`, `mode`, `mask`, as in `/v1/check`. | | `check_batch` | Up to 100 items with ids. | | `get_integration_snippet` | A copy-paste recipe for a platform (`http`, `supabase`, `clerk`, `firebase`, `auth0`, `better-auth`, `unity`, `godot`) in a language (`curl`, `javascript`, `typescript`, `python`, `csharp`, `gdscript`, `go`, `php`, `ruby`). | ## Adding it to an agent Claude Code: ```bash claude mcp add --transport http unprofane https://api.unprofane.com/mcp --header "Authorization: Bearer up_live_YOUR_KEY" ``` Cursor, in `.cursor/mcp.json`: ```json { "mcpServers": { "unprofane": { "url": "https://api.unprofane.com/mcp", "headers": { "Authorization": "Bearer up_live_YOUR_KEY" } } } } ``` VS Code, in `.vscode/mcp.json`, asking for the key once instead of keeping it in the file: ```json { "inputs": [ { "type": "promptString", "id": "unprofane-key", "description": "Unprofane key", "password": true } ], "servers": { "unprofane": { "type": "http", "url": "https://api.unprofane.com/mcp", "headers": { "Authorization": "Bearer ${input:unprofane-key}" } } } } ``` Any client that takes a Streamable HTTP server URL and a header works the same way. The first message is `initialize`; the server answers with protocol version `2025-06-18` and the tool list. ## What it is for An agent asked to "add a profanity filter" can fetch the recipe for the stack it is in, and an agent moderating a queue can check text without leaving its loop. It is the same engine, the same key, the same usage counting. # Recipes Username hooks per platform. Replace the key. Also available from the MCP tool `get_integration_snippet`. ## http (curl) ```bash curl https://api.unprofane.com/v1/check \ -H "Authorization: Bearer up_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"text":"sh1tH3ad_69","context":"username","policy":"teen"}' ``` ## http (javascript) ```javascript const res = await fetch('https://api.unprofane.com/v1/check', { method: 'POST', headers: { Authorization: 'Bearer up_live_YOUR_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'sh1tH3ad_69', context: 'username', policy: 'teen' }), }); const result = await res.json(); if (!result.allowed) console.log(result.action, result.evasion, result.censored); ``` ## http (typescript) ```typescript import { Unprofane } from '@unprofane/sdk'; const unprofane = new Unprofane(process.env.UNPROFANE_API_KEY!); /** Returns the reason a username cannot be shown, or null when it can. */ export async function usernameProblem(username: string): Promise { const r = await unprofane.check({ text: username, context: 'username', policy: 'teen' }); if (r.allowed) return null; return r.matches[0]?.category === 'reserved' ? 'That name is reserved.' : 'Pick a different username.'; } ``` ## http (python) ```python import httpx r = httpx.post( "https://api.unprofane.com/v1/check", headers={"Authorization": "Bearer up_live_YOUR_KEY"}, json={"text": "sh1tH3ad_69", "context": "username", "policy": "teen"}, timeout=5.0, ) result = r.json() if not result["allowed"]: print(result["action"], result["evasion"], result["censored"]) ``` ## http (go) ```go req, _ := http.NewRequest("POST", "https://api.unprofane.com/v1/check", strings.NewReader(`{"text":"sh1tH3ad_69","context":"username","policy":"teen"}`)) req.Header.Set("Authorization", "Bearer up_live_YOUR_KEY") req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) ``` ## http (php) ```php $res = file_get_contents('https://api.unprofane.com/v1/check', false, stream_context_create(['http' => [ 'method' => 'POST', 'header' => "Authorization: Bearer up_live_YOUR_KEY\r\nContent-Type: application/json\r\n", 'content' => json_encode(['text' => 'sh1tH3ad_69', 'context' => 'username', 'policy' => 'teen']), ]])); $result = json_decode($res, true); ``` ## http (ruby) ```ruby require 'net/http'; require 'json' uri = URI('https://api.unprofane.com/v1/check') res = Net::HTTP.post(uri, { text: 'sh1tH3ad_69', context: 'username', policy: 'teen' }.to_json, 'Authorization' => 'Bearer up_live_YOUR_KEY', 'Content-Type' => 'application/json') result = JSON.parse(res.body) ``` ## http (csharp) ```csharp using var http = new HttpClient(); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "up_live_YOUR_KEY"); var res = await http.PostAsJsonAsync("https://api.unprofane.com/v1/check", new { text = "sh1tH3ad_69", context = "username", policy = "teen" }); var result = await res.Content.ReadFromJsonAsync(); bool allowed = result.GetProperty("allowed").GetBoolean(); ``` ## http (gdscript) ```gdscript var http := HTTPRequest.new() add_child(http) http.request_completed.connect(func(_r, _code, _h, body): var result = JSON.parse_string(body.get_string_from_utf8()) print(result["allowed"], result["action"])) http.request("https://api.unprofane.com/v1/check", PackedStringArray([ "Authorization: Bearer up_live_YOUR_KEY", "Content-Type: application/json"]), HTTPClient.METHOD_POST, JSON.stringify({"text": "sh1tH3ad_69", "context": "username", "policy": "teen"})) ``` ## supabase (typescript) ```typescript // supabase/functions/check-username/index.ts (Edge Function called before the profile update) import { Unprofane } from '@unprofane/sdk'; const unprofane = new Unprofane(process.env.UNPROFANE_API_KEY!); /** Returns the reason a username cannot be shown, or null when it can. */ export async function usernameProblem(username: string): Promise { const r = await unprofane.check({ text: username, context: 'username', policy: 'teen' }); if (r.allowed) return null; return r.matches[0]?.category === 'reserved' ? 'That name is reserved.' : 'Pick a different username.'; } Deno.serve(async (req) => { const { username } = await req.json(); const problem = await usernameProblem(username); return Response.json(problem ? { ok: false, problem } : { ok: true }, { status: problem ? 422 : 200 }); }); ``` ## clerk (typescript) ```typescript // app/api/webhooks/clerk/route.ts: refuse a username at user.created / user.updated import { Unprofane } from '@unprofane/sdk'; const unprofane = new Unprofane(process.env.UNPROFANE_API_KEY!); /** Returns the reason a username cannot be shown, or null when it can. */ export async function usernameProblem(username: string): Promise { const r = await unprofane.check({ text: username, context: 'username', policy: 'teen' }); if (r.allowed) return null; return r.matches[0]?.category === 'reserved' ? 'That name is reserved.' : 'Pick a different username.'; } export async function POST(req: Request) { const event = await req.json(); if (event.type === 'user.created' || event.type === 'user.updated') { const problem = await usernameProblem(event.data.username ?? ''); if (problem) await clerkClient.users.updateUser(event.data.id, { username: `user_${event.data.id.slice(-8)}` }); } return new Response('ok'); } ``` ## firebase (typescript) ```typescript // functions/src/index.ts: Cloud Function on profile writes import { onDocumentWritten } from 'firebase-functions/v2/firestore'; import { Unprofane } from '@unprofane/sdk'; const unprofane = new Unprofane(process.env.UNPROFANE_API_KEY!); /** Returns the reason a username cannot be shown, or null when it can. */ export async function usernameProblem(username: string): Promise { const r = await unprofane.check({ text: username, context: 'username', policy: 'teen' }); if (r.allowed) return null; return r.matches[0]?.category === 'reserved' ? 'That name is reserved.' : 'Pick a different username.'; } export const guardUsername = onDocumentWritten('profiles/{uid}', async (event) => { const after = event.data?.after.data(); if (!after?.username) return; const problem = await usernameProblem(after.username); if (problem) await event.data!.after.ref.update({ username: null, usernameProblem: problem }); }); ``` ## auth0 (javascript) ```javascript // Auth0 Pre User Registration Action exports.onExecutePreUserRegistration = async (event, api) => { const res = await fetch('https://api.unprofane.com/v1/check', { method: 'POST', headers: { Authorization: `Bearer ${event.secrets.UNPROFANE_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: event.user.username ?? event.user.nickname ?? '', context: 'username', policy: 'teen' }), }); const result = await res.json(); if (!result.allowed) api.access.deny('username', 'Pick a different username.'); }; ``` ## better-auth (typescript) ```typescript // auth.ts: Better Auth database hook import { betterAuth } from 'better-auth'; import { Unprofane } from '@unprofane/sdk'; const unprofane = new Unprofane(process.env.UNPROFANE_API_KEY!); /** Returns the reason a username cannot be shown, or null when it can. */ export async function usernameProblem(username: string): Promise { const r = await unprofane.check({ text: username, context: 'username', policy: 'teen' }); if (r.allowed) return null; return r.matches[0]?.category === 'reserved' ? 'That name is reserved.' : 'Pick a different username.'; } export const auth = betterAuth({ databaseHooks: { user: { create: { before: async (user) => { const problem = await usernameProblem(user.name); if (problem) throw new Error(problem); return { data: user }; }, }, }, }, }); ``` ## unity (csharp) ```csharp // Call from your own server in production; a secret key never ships in a build. // For a client-side username field use a publishable key (up_pk_…) with an origin allowlist. using UnityEngine.Networking; IEnumerator CheckUsername(string username, System.Action done) { var body = JsonUtility.ToJson(new { text = username, context = "username", policy = "teen" }); var req = new UnityWebRequest("https://api.unprofane.com/v1/check", "POST"); req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(body)); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Authorization", "Bearer up_pk_YOUR_PUBLISHABLE_KEY"); req.SetRequestHeader("Content-Type", "application/json"); yield return req.SendWebRequest(); done(req.result == UnityWebRequest.Result.Success && req.downloadHandler.text.Contains("\"allowed\":true")); } ``` ## godot (gdscript) ```gdscript # Username field: a publishable key (up_pk_…) with your game's origin allowed. func check_username(username: String) -> void: var http := HTTPRequest.new() add_child(http) http.request_completed.connect(_on_checked) http.request("https://api.unprofane.com/v1/check", PackedStringArray(["Authorization: Bearer up_pk_YOUR_PUBLISHABLE_KEY", "Content-Type: application/json"]), HTTPClient.METHOD_POST, JSON.stringify({"text": username, "context": "username", "policy": "teen"})) func _on_checked(_result, _code, _headers, body) -> void: var r = JSON.parse_string(body.get_string_from_utf8()) if not r["allowed"]: show_error("Pick a different username.") ```