Skip to main content

Unicode Confusables and Homograph Detection API

REAL DATA

UTS #39 skeletons, mixed-script and identifier-status checks: whether two usernames or domains are visual lookalikes, and which characters differ.

Authoritative reference data or standards computation

Base URL
/api/confusables
Capabilities
5 routes
Last updated
July 30, 2026
Data sources · 3

GET /api/confusables/skeleton

Live requestRuns against the public API
No key required
GET/api/confusables/skeleton?text=%D1%80%D0%B0%D1%83%D1%80%D0%B0l

Request parameters

Text to reduce to its UTS #39 skeleton. Limited to 256 UTF-16 code units; whitespace is significant and is not trimmed.

Advanced response options4 options

Return only these fields (comma-separated). Mutually exclusive with 'exclude'.

Return all fields except these (comma-separated).

Pretty-print the JSON response.

Drop the envelope: return the raw array/object without data/meta wrapper.

Response

Example parameters are ready. Send the request to inspect the live response.

Route reference

Code samples Ready-to-copy requests in 4 languages
Choose a code sample language
curl "https://randomapi.dev/api/confusables/skeleton?text=%D1%80%D0%B0%D1%83%D1%80%D0%B0l"
const res = await fetch("https://randomapi.dev/api/confusables/skeleton?text=%D1%80%D0%B0%D1%83%D1%80%D0%B0l");
const { data, meta } = await res.json();
import requests

data = requests.get("https://randomapi.dev/api/confusables/skeleton?text=%D1%80%D0%B0%D1%83%D1%80%D0%B0l").json()["data"]
$json = json_decode(file_get_contents(
  "https://randomapi.dev/api/confusables/skeleton?text=%D1%80%D0%B0%D1%83%D1%80%D0%B0l"
), true);
$data = $json["data"];
Parameters Route-specific request inputs 1
text string required

Text to reduce to its UTS #39 skeleton. Limited to 256 UTF-16 code units; whitespace is significant and is not trimmed.

allowed: 1 – 256
example: text=раураl
Universal parameters Shared response and formatting options 4
fields list

Return only these fields (comma-separated). Mutually exclusive with 'exclude'.

example: fields=text,nfd
exclude list

Return all fields except these (comma-separated).

example: exclude=characters
pretty boolean

Pretty-print the JSON response.

default: false
example: pretty=true
unwrap boolean

Drop the envelope: return the raw array/object without data/meta wrapper.

default: false
example: unwrap=true
Response schema Fields returned in each record 11
text string

The resolved input, unchanged.

example: раураl

nfd string

The input after step 1 of the algorithm (Normalization Form D). The `characters` rows below are positions in this string, not in `text`.

example: раураl

skeleton string

The skeleton. Not intended for display and not stable across Unicode versions — treat it as a hash, and recompute rather than store it.

example: paypal

skeletonCodePoints string[]

The skeleton as U+XXXX code points, so an invisible result is still readable.

example: ["U+0070","U+0061","U+0079","U+0070","U+0061","U+006C"]

changed boolean

Whether the skeleton differs from the input. The skeleton is NFD, so a precomposed input can be `changed` with no prototype substitution at all — check `charactersMapped` for that.

example: true

definition string

"skeleton" when the value returned is exactly UTS #39 skeleton(X) = bidiSkeleton(LTR, X) via the section 4 fast path; "internalSkeleton" when the bidi steps were skipped.

example: skeleton

bidiExact boolean

Whether the UTS #39 4 left-to-right fast path applied, making the value returned exactly skeleton(X). False when the input contains a right-to-left or explicit directional-formatting character: we do not implement the UAX #9 bidirectional algorithm.

example: true

bidiNote string nullable

Why the fast path did not apply, naming the first offending character and its Bidi_Class. Null when the result is bidi-exact.

example: null

ignorablesRemoved integer

How many Default_Ignorable_Code_Point characters step 2 dropped.

example: 0

charactersMapped integer

How many characters survived step 2 and had a prototype in the confusables data, so were substituted in step 3.

example: 5

characters object[]

One row per character of `nfd`, in order: character, codePoint (U+XXXX), name (nullable), script and scriptName (ISO 15924 code plus its Unicode long name), identifierStatus and identifierTypes, plus `index` (UTF-16 code-unit offset within `nfd`), `ignorable` (removed by step 2), `prototype` (null when the character has none) and `prototypeCodePoints`.

example: [{"index":0,"character":"р","codePoint":"U+0440","name":"CYRILLIC SMALL LETTER ER","script":"Cyrl","scriptName":"Cyrillic","identifierStatus":"Allowed","identifierTypes":["Recommended"],"ignorable":false,"prototype":"p","prototypeCodePoints":["U+0070"]}]

Documented examples

Build-generated requests and complete responses
3
Skeleton of a Cyrillic paypal lookalike
GET /api/confusables/skeleton?text=%D1%80%D0%B0%D1%83%D1%80%D0%B0l
{
  "data": {
    "text": "раураl",
    "nfd": "раураl",
    "skeleton": "paypal",
    "skeletonCodePoints": [
      "U+0070",
      "U+0061",
      "U+0079",
      "U+0070",
      "U+0061",
      "U+006C"
    ],
    "changed": true,
    "definition": "skeleton",
    "bidiExact": true,
    "bidiNote": null,
    "ignorablesRemoved": 0,
    "charactersMapped": 5,
    "characters": [
      {
        "index": 0,
        "character": "р",
        "codePoint": "U+0440",
        "name": "CYRILLIC SMALL LETTER ER",
        "script": "Cyrl",
        "scriptName": "Cyrillic",
        "identifierStatus": "Allowed",
        "identifierTypes": [
          "Recommended"
        ],
        "ignorable": false,
        "prototype": "p",
        "prototypeCodePoints": [
          "U+0070"
        ]
      },
      {
        "index": 1,
        "character": "а",
    …
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}
Digit one reduces to the letter l
GET /api/confusables/skeleton?text=paypa1
{
  "data": {
    "text": "paypa1",
    "nfd": "paypa1",
    "skeleton": "paypal",
    "skeletonCodePoints": [
      "U+0070",
      "U+0061",
      "U+0079",
      "U+0070",
      "U+0061",
      "U+006C"
    ],
    "changed": true,
    "definition": "skeleton",
    "bidiExact": true,
    "bidiNote": null,
    "ignorablesRemoved": 0,
    "charactersMapped": 1,
    "characters": [
      {
        "index": 0,
        "character": "p",
        "codePoint": "U+0070",
        "name": "LATIN SMALL LETTER P",
        "script": "Latn",
        "scriptName": "Latin",
        "identifierStatus": "Allowed",
        "identifierTypes": [
          "Recommended"
        ],
        "ignorable": false,
        "prototype": null,
        "prototypeCodePoints": []
      },
      {
        "index": 1,
        "character": "a",
        "codePoint": "U+0061",
        "name": "LATIN SMALL LETTER A",
    …
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}
Right-to-left input is not bidi-exact
GET /api/confusables/skeleton?text=%D9%85%D8%AB%D8%A7%D9%84
{
  "data": {
    "text": "مثال",
    "nfd": "مثال",
    "skeleton": "مىۛlل",
    "skeletonCodePoints": [
      "U+0645",
      "U+0649",
      "U+06DB",
      "U+006C",
      "U+0644"
    ],
    "changed": true,
    "definition": "internalSkeleton",
    "bidiExact": false,
    "bidiNote": "U+0645 ARABIC LETTER MEEM (Bidi_Class AL) takes the UTS #39 section 4 left-to-right fast path away, because UAX #9 reordering could change the result. The value returned is internalSkeleton(X), not skeleton(X).",
    "ignorablesRemoved": 0,
    "charactersMapped": 2,
    "characters": [
      {
        "index": 0,
        "character": "م",
        "codePoint": "U+0645",
        "name": "ARABIC LETTER MEEM",
        "script": "Arab",
        "scriptName": "Arabic",
        "identifierStatus": "Allowed",
        "identifierTypes": [
          "Recommended"
        ],
        "ignorable": false,
        "prototype": null,
        "prototypeCodePoints": []
      },
      {
        "index": 1,
        "character": "ث",
        "codePoint": "U+062B",
        "name": "ARABIC LETTER THEH",
        "script": "Arab",
    …
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}

GET /api/confusables/compare

Live requestRuns against the public API
No key required
GET/api/confusables/compare?a=paypal&b=%D1%80%D0%B0%D1%83%D1%80%D0%B0l

Request parameters

The first string — for example the username or domain that already exists. Limited to 256 UTF-16 code units; whitespace is significant and is not trimmed.

The second string — for example the one being registered. Limited to 256 UTF-16 code units; whitespace is significant and is not trimmed.

Advanced response options4 options

Return only these fields (comma-separated). Mutually exclusive with 'exclude'.

Return all fields except these (comma-separated).

Pretty-print the JSON response.

Drop the envelope: return the raw array/object without data/meta wrapper.

Response

Example parameters are ready. Send the request to inspect the live response.

Route reference

Code samples Ready-to-copy requests in 4 languages
Choose a code sample language
curl "https://randomapi.dev/api/confusables/compare?a=paypal&b=%D1%80%D0%B0%D1%83%D1%80%D0%B0l"
const res = await fetch("https://randomapi.dev/api/confusables/compare?a=paypal&b=%D1%80%D0%B0%D1%83%D1%80%D0%B0l");
const { data, meta } = await res.json();
import requests

data = requests.get("https://randomapi.dev/api/confusables/compare?a=paypal&b=%D1%80%D0%B0%D1%83%D1%80%D0%B0l").json()["data"]
$json = json_decode(file_get_contents(
  "https://randomapi.dev/api/confusables/compare?a=paypal&b=%D1%80%D0%B0%D1%83%D1%80%D0%B0l"
), true);
$data = $json["data"];
Parameters Route-specific request inputs 2
a string required

The first string — for example the username or domain that already exists. Limited to 256 UTF-16 code units; whitespace is significant and is not trimmed.

allowed: 1 – 256
example: a=paypal
b string required

The second string — for example the one being registered. Limited to 256 UTF-16 code units; whitespace is significant and is not trimmed.

allowed: 1 – 256
example: b=раураl
Universal parameters Shared response and formatting options 4
fields list

Return only these fields (comma-separated). Mutually exclusive with 'exclude'.

example: fields=a,b
exclude list

Return all fields except these (comma-separated).

example: exclude=differences
pretty boolean

Pretty-print the JSON response.

default: false
example: pretty=true
unwrap boolean

Drop the envelope: return the raw array/object without data/meta wrapper.

default: false
example: unwrap=true
Response schema Fields returned in each record 12
a string

The first resolved input, unchanged.

example: paypal

b string

The second resolved input, unchanged.

example: раураl

equal boolean

Whether the two inputs are identical UTF-16 sequences.

example: false

skeletonA string

The UTS #39 skeleton of `a`.

example: paypal

skeletonB string

The UTS #39 skeleton of `b`.

example: paypal

confusable boolean nullable

Whether the two skeletons are identical. Null when either side is not bidi-exact: UAX #9 reordering could change either skeleton, so no verdict is issued rather than a possibly-wrong false.

example: true

confusableClass string nullable

Per UTS #39 4 Definitions: "single-script" when the resolved script sets share an element, "mixed-script" when they are disjoint, "whole-script" when they are disjoint and both strings are single-script. The three are not mutually exclusive in the spec — "all whole-script confusables are also mixed-script confusables" — and this field reports the narrowest class that applies, so read "whole-script" as implying mixed-script and do not filter on the string "mixed-script" alone. Null when `confusable` is false or null.

example: mixed-script

bidiExact boolean

Whether the UTS #39 4 left-to-right fast path applied, making the value returned exactly skeleton(X). False when the input contains a right-to-left or explicit directional-formatting character: we do not implement the UAX #9 bidirectional algorithm.

example: true

undeterminedReason string nullable

Why `confusable` is null, naming the side, the character and its Bidi_Class. Null otherwise.

example: null

resolvedScriptsA string[]

The resolved script set of `a` as ISO 15924 codes. Empty for a mixed-script string; ["ALL"] when every character is Common or Inherited, which section 5.1 treats as every script.

example: ["Latn"]

resolvedScriptsB string[]

The resolved script set of `b`, same conventions as `resolvedScriptsA`.

example: []

differences object[]

Rows where the two inputs differ, compared code point by code point at the same position up to the longer length — a positional comparison, not a diff algorithm. Keys: `index` (zero-based code-point position, not a code-unit offset), `aCharacter`, `aCodePoint`, `aName`, `aScript`, `bCharacter`, `bCodePoint`, `bName`, `bScript`. The shorter side's four keys are null past its end.

example: [{"index":1,"aCharacter":"a","aCodePoint":"U+0061","aName":"LATIN SMALL LETTER A","aScript":"Latn","bCharacter":"а","bCodePoint":"U+0430","bName":"CYRILLIC SMALL LETTER A","bScript":"Cyrl"}]

Documented examples

Build-generated requests and complete responses
3
paypal versus a Cyrillic lookalike
GET /api/confusables/compare?a=paypal&b=%D1%80%D0%B0%D1%83%D1%80%D0%B0l
{
  "data": {
    "a": "paypal",
    "b": "раураl",
    "equal": false,
    "skeletonA": "paypal",
    "skeletonB": "paypal",
    "confusable": true,
    "confusableClass": "mixed-script",
    "bidiExact": true,
    "undeterminedReason": null,
    "resolvedScriptsA": [
      "Latn"
    ],
    "resolvedScriptsB": [],
    "differences": [
      {
        "index": 0,
        "aCharacter": "p",
        "aCodePoint": "U+0070",
        "aName": "LATIN SMALL LETTER P",
        "aScript": "Latn",
        "bCharacter": "р",
        "bCodePoint": "U+0440",
        "bName": "CYRILLIC SMALL LETTER ER",
        "bScript": "Cyrl"
      },
      {
        "index": 1,
        "aCharacter": "a",
        "aCodePoint": "U+0061",
        "aName": "LATIN SMALL LETTER A",
        "aScript": "Latn",
        "bCharacter": "а",
        "bCodePoint": "U+0430",
        "bName": "CYRILLIC SMALL LETTER A",
        "bScript": "Cyrl"
      },
      {
        "index": 2,
    …
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}
scope versus its whole-script Cyrillic confusable
GET /api/confusables/compare?a=scope&b=%D1%95%D1%81%D0%BE%D1%80%D0%B5
{
  "data": {
    "a": "scope",
    "b": "ѕсоре",
    "equal": false,
    "skeletonA": "scope",
    "skeletonB": "scope",
    "confusable": true,
    "confusableClass": "whole-script",
    "bidiExact": true,
    "undeterminedReason": null,
    "resolvedScriptsA": [
      "Latn"
    ],
    "resolvedScriptsB": [
      "Cyrl"
    ],
    "differences": [
      {
        "index": 0,
        "aCharacter": "s",
        "aCodePoint": "U+0073",
        "aName": "LATIN SMALL LETTER S",
        "aScript": "Latn",
        "bCharacter": "ѕ",
        "bCodePoint": "U+0455",
        "bName": "CYRILLIC SMALL LETTER DZE",
        "bScript": "Cyrl"
      },
      {
        "index": 1,
        "aCharacter": "c",
        "aCodePoint": "U+0063",
        "aName": "LATIN SMALL LETTER C",
        "aScript": "Latn",
        "bCharacter": "с",
        "bCodePoint": "U+0441",
        "bName": "CYRILLIC SMALL LETTER ES",
        "bScript": "Cyrl"
      },
    …
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}
m carries the two-character prototype rn
GET /api/confusables/compare?a=microsoft&b=rnicrosoft
{
  "data": {
    "a": "microsoft",
    "b": "rnicrosoft",
    "equal": false,
    "skeletonA": "rnicrosoft",
    "skeletonB": "rnicrosoft",
    "confusable": true,
    "confusableClass": "single-script",
    "bidiExact": true,
    "undeterminedReason": null,
    "resolvedScriptsA": [
      "Latn"
    ],
    "resolvedScriptsB": [
      "Latn"
    ],
    "differences": [
      {
        "index": 0,
        "aCharacter": "m",
        "aCodePoint": "U+006D",
        "aName": "LATIN SMALL LETTER M",
        "aScript": "Latn",
        "bCharacter": "r",
        "bCodePoint": "U+0072",
        "bName": "LATIN SMALL LETTER R",
        "bScript": "Latn"
      },
      {
        "index": 1,
        "aCharacter": "i",
        "aCodePoint": "U+0069",
        "aName": "LATIN SMALL LETTER I",
        "aScript": "Latn",
        "bCharacter": "n",
        "bCodePoint": "U+006E",
        "bName": "LATIN SMALL LETTER N",
        "bScript": "Latn"
      },
    …
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}

GET /api/confusables/inspect

Live requestRuns against the public API
No key required
GET/api/confusables/inspect?text=%D1%95%D1%81%D0%BE%D1%80%D0%B5

Request parameters

Text to evaluate as a candidate identifier. Limited to 256 UTF-16 code units; whitespace is significant and is not trimmed.

Advanced response options4 options

Return only these fields (comma-separated). Mutually exclusive with 'exclude'.

Return all fields except these (comma-separated).

Pretty-print the JSON response.

Drop the envelope: return the raw array/object without data/meta wrapper.

Response

Example parameters are ready. Send the request to inspect the live response.

Route reference

Code samples Ready-to-copy requests in 4 languages
Choose a code sample language
curl "https://randomapi.dev/api/confusables/inspect?text=%D1%95%D1%81%D0%BE%D1%80%D0%B5"
const res = await fetch("https://randomapi.dev/api/confusables/inspect?text=%D1%95%D1%81%D0%BE%D1%80%D0%B5");
const { data, meta } = await res.json();
import requests

data = requests.get("https://randomapi.dev/api/confusables/inspect?text=%D1%95%D1%81%D0%BE%D1%80%D0%B5").json()["data"]
$json = json_decode(file_get_contents(
  "https://randomapi.dev/api/confusables/inspect?text=%D1%95%D1%81%D0%BE%D1%80%D0%B5"
), true);
$data = $json["data"];
Parameters Route-specific request inputs 1
text string required

Text to evaluate as a candidate identifier. Limited to 256 UTF-16 code units; whitespace is significant and is not trimmed.

allowed: 1 – 256
example: text=ѕсоре
Universal parameters Shared response and formatting options 4
fields list

Return only these fields (comma-separated). Mutually exclusive with 'exclude'.

example: fields=text,codePointCount
exclude list

Return all fields except these (comma-separated).

example: exclude=characters
pretty boolean

Pretty-print the JSON response.

default: false
example: pretty=true
unwrap boolean

Drop the envelope: return the raw array/object without data/meta wrapper.

default: false
example: unwrap=true
Response schema Fields returned in each record 16
text string

The resolved input, unchanged.

example: ѕсоре

codePointCount integer

Unicode scalar values in the input (not UTF-16 code units).

example: 5

nonAsciiCount integer

How many of those scalar values are above U+007F.

example: 5

scripts string[]

One minimal cover set (UTS #39 5.1): a smallest set of scripts such that every character shares at least one script with it, ordered by first appearance. Section 5.1 notes minimal cover sets are not unique, so this is one of them. Empty when every character is Common or Inherited, because then every script covers the string.

example: ["Cyrl"]

scriptsMinimal boolean

Whether `scripts` is proven to be a smallest cover set. Proving it is an exact set-cover search run under a node budget; it completes for anything resembling an identifier, and only a deliberately pathological mix of dozens of multi-script characters falls back to a greedy cover (still a valid cover, possibly one script too large).

example: true

singleScript boolean

Whether the resolved script set is non-empty (UTS #39 5.1). Mind the spec's own warning: "single-script" means at least one script in the set, not exactly one — 日本語 is single-script with four.

example: true

mixedScript boolean

The negation of `singleScript`: the resolved script set is empty.

example: false

restrictionLevel string

One of ASCII-Only, Single Script, Highly Restrictive, Moderately Restrictive, Minimally Restrictive, Unrestricted (UTS #39 5.2, all six computed exactly). The identifier profile applied is the General Security Profile, so a character outside it — a space, an exclamation mark — makes the string Unrestricted no matter how clean its scripts are.

example: Single Script

identifierAllowed boolean

Whether every character has Identifier_Status=Allowed under the General Security Profile (UTS #39 3.1).

example: true

restrictedCharacters object[]

Every distinct Identifier_Status=Restricted character, first appearance first: character, codePoint (U+XXXX), name (nullable), script and scriptName (ISO 15924 code plus its Unicode long name), identifierStatus and identifierTypes. `identifierTypes` is the reason for the status — Not_XID, Limited_Use, Obsolete, Not_NFKC, Default_Ignorable, Not_Character and so on. Empty when every character is Allowed.

example: []

confusableCharacters object[]

Every distinct character that has a UTS #39 prototype: character, codePoint (U+XXXX), name (nullable), script and scriptName (ISO 15924 code plus its Unicode long name), identifierStatus and identifierTypes, plus `prototype` and `confusableWithCount`.

example: [{"character":"ѕ","codePoint":"U+0455","name":"CYRILLIC SMALL LETTER DZE","script":"Cyrl","scriptName":"Cyrillic","identifierStatus":"Allowed","identifierTypes":["Recommended"],"prototype":"s","confusableWithCount":21}]

mixedNumbers boolean

Whether more than one decimal number system appears (UTS #39 5.3).

example: false

numberSystems object[]

One row per distinct decimal system found — `zeroCodePoint`, `zeroName` and `digitCount` (how many of the input's digits came from that system) — first appearance first. Only General_Category=Nd characters participate, so Roman numerals and vulgar fractions are absent (they surface in `restrictedCharacters` instead). Empty when the input has no decimal digits.

example: []

skeleton string

The same value /skeleton returns, so /inspect answers on its own.

example: scope

bidiExact boolean

Whether the UTS #39 4 left-to-right fast path applied, making the value returned exactly skeleton(X). False when the input contains a right-to-left or explicit directional-formatting character: we do not implement the UAX #9 bidirectional algorithm.

example: true

characters object[]

The full walk over the input's scalar values: character, codePoint (U+XXXX), name (nullable), script and scriptName (ISO 15924 code plus its Unicode long name), identifierStatus and identifierTypes, plus `index` (UTF-16 code-unit offset) and `augmentedScripts` (the section 5.1 augmented script set, ["ALL"] for Common and Inherited characters).

example: [{"index":0,"character":"ѕ","codePoint":"U+0455","name":"CYRILLIC SMALL LETTER DZE","script":"Cyrl","scriptName":"Cyrillic","identifierStatus":"Allowed","identifierTypes":["Recommended"],"augmentedScripts":["Cyrl"]}]

Documented examples

Build-generated requests and complete responses
3
A single-script Cyrillic string that still spoofs Latin
GET /api/confusables/inspect?text=%D1%95%D1%81%D0%BE%D1%80%D0%B5
{
  "data": {
    "text": "ѕсоре",
    "codePointCount": 5,
    "nonAsciiCount": 5,
    "scripts": [
      "Cyrl"
    ],
    "scriptsMinimal": true,
    "singleScript": true,
    "mixedScript": false,
    "restrictionLevel": "Single Script",
    "identifierAllowed": true,
    "restrictedCharacters": [],
    "confusableCharacters": [
      {
        "character": "ѕ",
        "codePoint": "U+0455",
        "name": "CYRILLIC SMALL LETTER DZE",
        "script": "Cyrl",
        "scriptName": "Cyrillic",
        "identifierStatus": "Allowed",
        "identifierTypes": [
          "Recommended"
        ],
        "prototype": "s",
        "confusableWithCount": 21
      },
      {
        "character": "с",
        "codePoint": "U+0441",
        "name": "CYRILLIC SMALL LETTER ES",
        "script": "Cyrl",
        "scriptName": "Cyrillic",
        "identifierStatus": "Allowed",
        "identifierTypes": [
          "Recommended"
        ],
        "prototype": "c",
        "confusableWithCount": 23"generatedAt": "2026-07-30T15:14:08.000Z"
  }
}
Latin plus Han is Highly Restrictive
GET /api/confusables/inspect?text=%E6%97%A5%E6%9C%AC%E8%AA%9Eabc
{
  "data": {
    "text": "日本語abc",
    "codePointCount": 6,
    "nonAsciiCount": 3,
    "scripts": [
      "Hani",
      "Latn"
    ],
    "scriptsMinimal": true,
    "singleScript": false,
    "mixedScript": true,
    "restrictionLevel": "Highly Restrictive",
    "identifierAllowed": true,
    "restrictedCharacters": [],
    "confusableCharacters": [],
    "mixedNumbers": false,
    "numberSystems": [],
    "skeleton": "日本語abc",
    "bidiExact": true,
    "characters": [
      {
        "index": 0,
        "character": "日",
        "codePoint": "U+65E5",
        "name": "CJK UNIFIED IDEOGRAPH-65E5",
        "script": "Hani",
        "scriptName": "Han",
        "identifierStatus": "Allowed",
        "identifierTypes": [
          "Recommended"
        ],
        "augmentedScripts": [
          "Hani",
          "Hanb",
          "Jpan",
          "Kore"
        ]
      },
      {
    …
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}
Two decimal number systems in one string
GET /api/confusables/inspect?text=a1%D9%A2
{
  "data": {
    "text": "a1٢",
    "codePointCount": 3,
    "nonAsciiCount": 1,
    "scripts": [
      "Latn",
      "Arab"
    ],
    "scriptsMinimal": true,
    "singleScript": false,
    "mixedScript": true,
    "restrictionLevel": "Moderately Restrictive",
    "identifierAllowed": true,
    "restrictedCharacters": [],
    "confusableCharacters": [
      {
        "character": "1",
        "codePoint": "U+0031",
        "name": "DIGIT ONE",
        "script": "Zyyy",
        "scriptName": "Common",
        "identifierStatus": "Allowed",
        "identifierTypes": [
          "Recommended"
        ],
        "prototype": "l",
        "confusableWithCount": 79
      }
    ],
    "mixedNumbers": true,
    "numberSystems": [
      {
        "zeroCodePoint": "U+0030",
        "zeroName": "DIGIT ZERO",
        "digitCount": 1
      },
      {
        "zeroCodePoint": "U+0660",
        "zeroName": "ARABIC-INDIC DIGIT ZERO",
    …
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}

GET /api/confusables/character

A compute route rather than a lookup, on purpose: every Unicode scalar value is a valid key, so there is no honest "unknown key" 404. A character simply absent from confusables.txt answers prototype: null with an empty confusableWith, which is the correct answer rather than a substitute record.

Live requestRuns against the public API
No key required
GET/api/confusables/character?char=%D0%B0

Request parameters

The single character to describe. Give the character itself or its code point in U+XXXX notation, so %20 and U+0020 both address the space. Not trimmed: a stray space makes the value two characters and a 400.

Advanced response options4 options

Return only these fields (comma-separated). Mutually exclusive with 'exclude'.

Return all fields except these (comma-separated).

Pretty-print the JSON response.

Drop the envelope: return the raw array/object without data/meta wrapper.

Response

Example parameters are ready. Send the request to inspect the live response.

Route reference

Code samples Ready-to-copy requests in 4 languages
Choose a code sample language
curl "https://randomapi.dev/api/confusables/character?char=%D0%B0"
const res = await fetch("https://randomapi.dev/api/confusables/character?char=%D0%B0");
const { data, meta } = await res.json();
import requests

data = requests.get("https://randomapi.dev/api/confusables/character?char=%D0%B0").json()["data"]
$json = json_decode(file_get_contents(
  "https://randomapi.dev/api/confusables/character?char=%D0%B0"
), true);
$data = $json["data"];
Parameters Route-specific request inputs 1
char string required

The single character to describe. Give the character itself or its code point in U+XXXX notation, so %20 and U+0020 both address the space. Not trimmed: a stray space makes the value two characters and a 400.

allowed: 1 – 8
example: char=а
Universal parameters Shared response and formatting options 4
fields list

Return only these fields (comma-separated). Mutually exclusive with 'exclude'.

example: fields=character,codePoint
exclude list

Return all fields except these (comma-separated).

example: exclude=inConfusablesData
pretty boolean

Pretty-print the JSON response.

default: false
example: pretty=true
unwrap boolean

Drop the envelope: return the raw array/object without data/meta wrapper.

default: false
example: unwrap=true
Response schema Fields returned in each record 17
character string

The character itself.

example: а

codePoint string

Its code point in U+XXXX notation.

example: U+0430

decimal integer

The same scalar value as a number.

example: 1072

name string nullable

The Unicode Name, from the pinned UnicodeData.txt. Non-null two ways: the 7559 bundled name rows, which cover everything but the CJK ideographs among the 8853 code points this endpoint can be asked about meaningfully (the 8809 the confusables data mentions plus the 44 decimal-number-system zeros it does not already mention), and every CJK Unified Ideograph, whose name the UAX #44 4.8 rule generates from the code point itself — that rule covers the blocks whole, so all 101984 of them get a name, not only the 1294 the confusables data mentions. Null for everything else: U+2603 SNOWMAN, unassigned code points, Hangul syllables outside the bundled rows. 109543 code points in all — a stated boundary, not a general character-name lookup.

example: CYRILLIC SMALL LETTER A

script string

The ISO 15924 script code (UAX #24); Zzzz for unassigned code points.

example: Cyrl

scriptName string

The script's Unicode long name with underscores rendered as spaces ("Canadian_Aboriginal" becomes "Canadian Aboriginal").

example: Cyrillic

augmentedScripts string[]

The UTS #39 5.1 augmented script set: Script_Extensions plus the multi-script writing systems (Han adds Hanb, Jpan and Kore; Hiragana and Katakana add Jpan; Hangul adds Kore; Bopomofo adds Hanb). ["ALL"] for Common and Inherited characters.

example: ["Cyrl"]

identifierStatus string

Allowed or Restricted under the UTS #39 General Security Profile.

example: Allowed

identifierTypes string[]

The Identifier_Type set — the reason behind the status. Code points the property does not list default to ["Not_Character"].

example: ["Recommended"]

prototype string nullable

The UTS #39 prototype this character maps to, which may be several characters (m maps to rn). Null when confusables.txt does not map it.

example: a

prototypeCodePoints string[]

The prototype as U+XXXX code points; empty when there is none.

example: ["U+0061"]

skeleton string

The skeleton of this single character. Empty for a Default_Ignorable character, which step 2 removes entirely.

example: a

confusableWith object[]

Every other code point in the confusables data whose one-character skeleton is identical: character, codePoint (U+XXXX), name (nullable), script and scriptName (ISO 15924 code plus its Unicode long name), identifierStatus and identifierTypes, plus `prototype`. Bounded — the largest group in this snapshot is 80 code points.

example: [{"character":"a","codePoint":"U+0061","name":"LATIN SMALL LETTER A","script":"Latn","scriptName":"Latin","identifierStatus":"Allowed","identifierTypes":["Recommended"],"prototype":null}]

confusableWithCount integer

The length of `confusableWith`.

example: 23

intentionalPartners object[]

Characters paired with this one in Unicode's hand-picked intentional-confusables list (glyphs designed to be identical in shape): character, codePoint (U+XXXX), name (nullable), script and scriptName (ISO 15924 code plus its Unicode long name), identifierStatus and identifierTypes. Empty for most characters — the list has only 77 pairs.

example: []

nfkc string

The character under NFKC, for contrast: NFKC is not a confusable check. Computed by the runtime's normalizer rather than from this pinned snapshot.

example: а

inConfusablesData boolean

Whether confusables.txt mentions this code point at all, as a mapped source or inside a prototype.

example: true

Documented examples

Build-generated requests and complete responses
2
Cyrillic small letter a
GET /api/confusables/character?char=%D0%B0
{
  "data": {
    "character": "а",
    "codePoint": "U+0430",
    "decimal": 1072,
    "name": "CYRILLIC SMALL LETTER A",
    "script": "Cyrl",
    "scriptName": "Cyrillic",
    "augmentedScripts": [
      "Cyrl"
    ],
    "identifierStatus": "Allowed",
    "identifierTypes": [
      "Recommended"
    ],
    "prototype": "a",
    "prototypeCodePoints": [
      "U+0061"
    ],
    "skeleton": "a",
    "confusableWith": [
      {
        "character": "a",
        "codePoint": "U+0061",
        "name": "LATIN SMALL LETTER A",
        "script": "Latn",
        "scriptName": "Latin",
        "identifierStatus": "Allowed",
        "identifierTypes": [
          "Recommended"
        ],
        "prototype": null
      },
      {
        "character": "ɑ",
        "codePoint": "U+0251",
        "name": "LATIN SMALL LETTER ALPHA",
        "script": "Latn",
        "scriptName": "Latin",
        "identifierStatus": "Restricted",
    …
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}
By code point: Roman numeral one
GET /api/confusables/character?char=U%2B2160
{
  "data": {
    "character": "Ⅰ",
    "codePoint": "U+2160",
    "decimal": 8544,
    "name": "ROMAN NUMERAL ONE",
    "script": "Latn",
    "scriptName": "Latin",
    "augmentedScripts": [
      "Latn"
    ],
    "identifierStatus": "Restricted",
    "identifierTypes": [
      "Not_NFKC"
    ],
    "prototype": "l",
    "prototypeCodePoints": [
      "U+006C"
    ],
    "skeleton": "l",
    "confusableWith": [
      {
        "character": "1",
        "codePoint": "U+0031",
        "name": "DIGIT ONE",
        "script": "Zyyy",
        "scriptName": "Common",
        "identifierStatus": "Allowed",
        "identifierTypes": [
          "Recommended"
        ],
        "prototype": "l"
      },
      {
        "character": "I",
        "codePoint": "U+0049",
        "name": "LATIN CAPITAL LETTER I",
        "script": "Latn",
        "scriptName": "Latin",
        "identifierStatus": "Allowed",
    …
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}

GET /api/confusables/intentional

Live requestRuns against the public API
No key required
GET/api/confusables/intentional

Request parameters

Keep only pairs containing this character on either side. Give the character itself or its code point in U+XXXX notation, so %20 and U+0020 both address the space. Not trimmed: a stray space makes the value two characters and a 400.

Advanced response options6 options

Limit the number of returned records (1–100). Defaults to all matches.

Return only these fields (comma-separated). Mutually exclusive with 'exclude'.

Return all fields except these (comma-separated).

Response format: json envelope, ndjson (one record per line) or csv.

Pretty-print the JSON response.

Drop the envelope: return the raw array/object without data/meta wrapper.

Response

Example parameters are ready. Send the request to inspect the live response.

Route reference

Code samples Ready-to-copy requests in 4 languages
Choose a code sample language
curl "https://randomapi.dev/api/confusables/intentional"
const res = await fetch("https://randomapi.dev/api/confusables/intentional");
const { data, meta } = await res.json();
import requests

data = requests.get("https://randomapi.dev/api/confusables/intentional").json()["data"]
$json = json_decode(file_get_contents(
  "https://randomapi.dev/api/confusables/intentional"
), true);
$data = $json["data"];
Parameters Route-specific request inputs 1
char string

Keep only pairs containing this character on either side. Give the character itself or its code point in U+XXXX notation, so %20 and U+0020 both address the space. Not trimmed: a stray space makes the value two characters and a 400.

allowed: 1 – 8
example: char=а
Universal parameters Shared response and formatting options 6
count int

Limit the number of returned records (1–100). Defaults to all matches.

default: all matches
allowed: 1 – 100
example: count=3
fields list

Return only these fields (comma-separated). Mutually exclusive with 'exclude'.

example: fields=a,b
exclude list

Return all fields except these (comma-separated).

example: exclude=sameSkeleton
format enum

Response format: json envelope, ndjson (one record per line) or csv.

default: json
allowed: json | ndjson | csv
example: format=csv
pretty boolean

Pretty-print the JSON response.

default: false
example: pretty=true
unwrap boolean

Drop the envelope: return the raw array/object without data/meta wrapper.

default: false
example: unwrap=true
Response schema Fields returned in each record 11
a string

The pair's first character, in the file's own order.

example: A

b string

The pair's second character.

example: Α

aCodePoint string

`a` in U+XXXX notation.

example: U+0041

bCodePoint string

`b` in U+XXXX notation.

example: U+0391

aName string nullable

The Unicode Name of `a`; null if this snapshot bundles none.

example: LATIN CAPITAL LETTER A

bName string nullable

The Unicode Name of `b`; null if this snapshot bundles none.

example: GREEK CAPITAL LETTER ALPHA

aScript string

The ISO 15924 script code of `a`.

example: Latn

bScript string

The ISO 15924 script code of `b`.

example: Grek

aIdentifierStatus string

Allowed or Restricted for `a` under the General Security Profile.

example: Allowed

bIdentifierStatus string

Allowed or Restricted for `b`.

example: Allowed

sameSkeleton boolean

Whether the pair also shares a UTS #39 skeleton. True for all 77 rows in this snapshot — the field exists so a future release that breaks the agreement is visible rather than silent.

example: true

Documented examples

Build-generated requests and complete responses
2
Every intentional confusable pair
GET /api/confusables/intentional
{
  "data": [
    {
      "a": "!",
      "b": "ǃ",
      "aCodePoint": "U+0021",
      "bCodePoint": "U+01C3",
      "aName": "EXCLAMATION MARK",
      "bName": "LATIN LETTER RETROFLEX CLICK",
      "aScript": "Zyyy",
      "bScript": "Latn",
      "aIdentifierStatus": "Restricted",
      "bIdentifierStatus": "Restricted",
      "sameSkeleton": true
    },
    {
      "a": "A",
      "b": "Α",
      "aCodePoint": "U+0041",
      "bCodePoint": "U+0391",
      "aName": "LATIN CAPITAL LETTER A",
      "bName": "GREEK CAPITAL LETTER ALPHA",
      "aScript": "Latn",
      "bScript": "Grek",
      "aIdentifierStatus": "Allowed",
      "bIdentifierStatus": "Allowed",
      "sameSkeleton": true
    },
    {
      "a": "B",
      "b": "Β",
      "aCodePoint": "U+0042",
      "bCodePoint": "U+0392",
      "aName": "LATIN CAPITAL LETTER B",
      "bName": "GREEK CAPITAL LETTER BETA",
      "aScript": "Latn",
      "bScript": "Grek",
      "aIdentifierStatus": "Allowed",
      "bIdentifierStatus": "Allowed",
      "sameSkeleton": true"generatedAt": "2026-07-30T15:14:08.000Z"
  }
}
Pairs involving Latin capital A
GET /api/confusables/intentional?char=A
{
  "data": [
    {
      "a": "A",
      "b": "Α",
      "aCodePoint": "U+0041",
      "bCodePoint": "U+0391",
      "aName": "LATIN CAPITAL LETTER A",
      "bName": "GREEK CAPITAL LETTER ALPHA",
      "aScript": "Latn",
      "bScript": "Grek",
      "aIdentifierStatus": "Allowed",
      "bIdentifierStatus": "Allowed",
      "sameSkeleton": true
    }
  ],
  "meta": {
    "endpoint": "confusables",
    "route": "/intentional",
    "count": 1,
    "params": {
      "char": "A"
    },
    "generatedAt": "2026-07-30T15:14:08.000Z"
  }
}

About this API

Coverage & behavior

Unicode's own confusables data, computed exactly: Unicode Technical Standard #39 17.0.0, 6565 characters mapped to 3416 prototypes, the 77 intentional-confusable pairs, and the Script, Script_Extensions, Identifier_Status, Identifier_Type, Bidi_Class and character-name tables the checks need. Every file is pinned by SHA-256 to a dated 17.0.0 snapshot and regenerated by a committed parity script; nothing is fetched while your request is served.

The skeleton

/skeleton runs the four steps of UTS #39 section 4 in order: convert to NFD, drop Default_Ignorable_Code_Point characters, replace each remaining character with its prototype, then NFD again. Two strings are confusable when their skeletons are identical, which is why раураl (Cyrillic р, а, у) and paypal both reduce to paypal.

A shared skeleton is evidence, not a verdict

It means Unicode records those characters as visually confusable in some typefaces. Section 4 chose a simple, transitive mechanism over an expensive "closeness" metric and says so plainly: in some cases "the test may be overly inclusive". Fonts differ, contextual shaping changes shapes for whole clusters at a time, and section 6 opens by admitting confusability cannot be an exact science. This endpoint hands you the evidence and the reasons; the policy is yours. It is not an anti-phishing product and it issues no verdict about intent.

What each route answers

  • /skeleton — the skeleton of one string, plus a row per character showing exactly which substitution happened.
  • /compare — whether two strings are confusable, and if so which of section 4's three classes the pair falls into: single-script (their resolved script sets share a script), mixed-script (disjoint), whole-script (disjoint and both strings single-script, as scope against Cyrillic ѕсоре). Those classes nest rather than partition — section 4 states that every whole-script confusable is also a mixed-script confusable — so confusableClass reports the narrowest label that applies and whole-script implies mixed-script. Plus a positional, code-point-by-code-point list of where they differ.
  • /inspect — one string as a candidate identifier: its cover set of scripts, its restriction level, which characters the General Security Profile restricts and why, which have prototypes, and whether it mixes decimal number systems.
  • /character — everything about one character, including every other code point in the data that reduces to the same skeleton.
  • /intentional — Unicode's hand-picked list of pairs whose glyphs are designed to be identical in shape.

The bidi limit

skeleton(X) is defined as bidiSkeleton(LTR, X), which reorders X by UAX #9 before reducing it. We implement only the fast path section 4 spells out — no right-to-left or explicit directional-formatting characters — and we are deliberately stricter than the one sentence describing it: an override such as U+202E RIGHT-TO-LEFT OVERRIDE is given class R by UAX #9 rule X6 and can reorder a string of otherwise left-to-right characters, so it also disqualifies the fast path. When it does not apply you get internalSkeleton instead, clearly labelled (definition, bidiExact, bidiNote), and /compare returns confusable: null with a reason rather than a guess. We do not implement UAX #9.

Restriction levels, and why hello world is Unrestricted

/inspect computes all six levels of section 5.2 exactly. The identifier profile applied is the General Security Profile of section 3.1 — Identifier_Status=Allowed — and not the UAX #31 Default Identifier Syntax. Step 1 of the algorithm returns Unrestricted for any character outside that profile, and U+0020 SPACE has Identifier_Type=Not_XID. So hello world and paypal! are Unrestricted: the levels answer "would this be an acceptable identifier", not "is this text suspicious". ㄅㄆabc is Unrestricted too, because as of Unicode 17 Bopomofo is not a UAX #31 Recommended script and its characters are Limited_Use.

Mixed numbers

Section 5.3 covers only General_Category=Nd characters: each digit maps to its number system's zero, and more than one zero means mixed numbers. Roman numerals and vulgar fractions are not decimal digits, so they are reported through restrictedCharacters instead — every one of them is Restricted anyway (U+2160 ROMAN NUMERAL ONE is Not_NFKC).

NFKC is not a confusable check

Of the 6565 characters UTS #39 maps to a prototype, 2784 are left completely unchanged by NFKC, and 637 of those are Identifier_Status=Allowed — including the Cyrillic letters that make раураl look like paypal. /character returns nfkc next to prototype so the two can be compared directly: U+2011 NON-BREAKING HYPHEN has NFKC U+2010 but prototype U+002D.

Deliberate limits

  1. No verdicts. See above. A shared skeleton is a reason to review, chosen by you.
  2. No UAX #9 bidirectional algorithm. Only the LTR fast path is exact; bidiSkeleton(RTL, ·) and bidiSkeleton(FS, ·) are not offered at all.
  3. No whole-script or mixed-script search (section 4.1 and section 4.2). Answering "does this string have some whole-script confusable?" needs the set of all strings confusable with it, and the spec warns the straightforward algorithm flags large numbers of ordinary Latin and Cyrillic strings. Pairwise classification only.
  4. No sequence confusables beyond the data. The files map single code points to prototypes, which may be multi-character (m maps to rn, so microsoft and rnicrosoft share a skeleton). Section 5.4 notes they do not cover sequence-to-sequence cases, so w against vv is not confusable here. We add no rules of our own.
  5. No section 5.4 optional heuristics: no CLDR exemplar check, no combining-mark-sequence limits.
  6. No section 3.1.1 join-control context rules. They matter only for a profile that permits ZWJ/ZWNJ; under the General Security Profile both are Restricted.
  7. No registry or DNS behaviour: no Label Generation Rulesets, no per-TLD variant tables. punycode covers IDNA syntax; this endpoint covers UTS #39.
  8. No lone surrogates. Query strings decode to well-formed UTF-16, and unicode's /inspect route already has a utf16 parameter for surrogate analysis.
  9. The skeleton is not for display and is not version-stable. Section 4 says so explicitly. Never store one as a durable key without recomputing it on a Unicode upgrade.
  10. Unassigned and noncharacter code points are handled, not special-cased: Script Zzzz (which makes a string mixed-script), Identifier_Type ["Not_Character"], status Restricted.
  11. The cover set of scripts is minimal, and says so. /inspect returns one minimal cover set per section 5.1 and reports scriptsMinimal. Proving minimality is an exact set-cover search, which is bounded here by a node budget; a greedy cover that already matches the disjointness lower bound needs no search at all, which covers everything identifier-shaped. Only a deliberately pathological mix of dozens of overlapping multi-script characters could exhaust the budget, and then scriptsMinimal is false rather than a claim we cannot back.
  12. name is bounded on purpose, and the bound is "bundled rows plus the CJK rule". The bundled rows cover the 8853 code points that can appear in an answer — the 8809 the confusables data mentions plus the 44 decimal-number-system zeros it does not already mention — as 7559 stored rows plus the 1294 CJK ideographs among them, which the UAX #44 4.8 rule names from the code point instead of a row. That rule is a range test over whole CJK Unified Ideograph blocks, so name is also non-null for the other 100690 ideographs the data never mentions: answering null for a name the UCD rule already gives correctly would be worse. Everything else is null (U+2603 SNOWMAN, unassigned code points, Hangul syllables outside the bundled rows), so /api/confusables/character names 109543 of Unicode's 1,114,112 code points and is not a general character-name lookup.

Two notes on versions

Everything except normalization is pinned to Unicode 17.0.0 and read from the bundle — never from \p{…} property escapes, whose Unicode version belongs to the runtime rather than to us. NFD (and the nfkc field) come from the runtime's String.prototype.normalize, which cannot be bundled; Unicode's Normalization Stability Policy guarantees that an already-encoded character's decomposition never changes, so runtime skew can only affect characters assigned after the runtime's Unicode version.

Use it for

  • Reject a new username that reduces to the same UTS #39 skeleton as an existing one
  • Show a reviewer exactly which character makes a submitted domain a lookalike
  • Gate an identifier on a UTS #39 restriction level instead of a hand-written character allowlist
  • Build homograph-detection test fixtures from Unicode's own intentional-confusables list

Frequently asked questions

What is a Unicode skeleton?

The UTS #39 section 4 prototype string: convert to NFD, drop default-ignorable characters, replace each character with its confusables prototype, then NFD again. Two strings are confusable when their skeletons are identical — раураl and paypal both reduce to paypal.

Does a shared skeleton mean someone is attacking me?

No. It means Unicode records those characters as visually confusable in some typefaces. It is evidence for a review rule of your own, not a verdict, and this is not an anti-phishing product.

Is NFKC normalization enough to catch lookalike characters?

No. Of the 6565 characters UTS #39 maps to a prototype, 2784 are left completely unchanged by NFKC, and 637 of those are Identifier_Status=Allowed — including the Cyrillic letters that make раураl look like paypal.

Which UTS #39 restriction level does my username reach?

/api/confusables/inspect returns all six levels exactly. A space, or any other character outside the General Security Profile, makes a string Unrestricted — the levels answer whether something is an acceptable identifier, not whether it looks suspicious.

Can it tell that rn looks like m?

Yes, in the one direction Unicode records it: confusables.txt maps U+006D LATIN SMALL LETTER M to the two-character prototype rn, so microsoft and rnicrosoft share a skeleton. We invent no sequence rules of our own, so w and vv are not confusable in this data.

Does it detect whole-script confusables?

For a pair, yes: /api/confusables/compare classifies a confusable pair as single-script, mixed-script or whole-script exactly as section 4 defines them, so scope against Cyrillic ѕсоре comes back whole-script. Section 4's classes nest — every whole-script confusable is also a mixed-script one — and confusableClass reports the narrowest label, so treat whole-script as implying mixed-script rather than filtering on the string mixed-script alone. Searching for whether one string has some whole-script confusable is deliberately not implemented.

Standards & references