Unprofane
DocsPerspective migration

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.

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 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