Unicode Normalization and Exact-Match Validation for Redemption Codes
A customer copies a short code from a message and pastes it into a redemption form. The code looks right, but the server rejects it. A support agent types what appears to be the same characters and gets a different result. The first instinct is to “clean up” the input before comparing it. That can be reasonable for a search box. For a value-bearing code, it can create an accidental alias.
The issue is that a code is an identifier with an issuer-defined alphabet, not free-form prose. Two strings that look alike may contain different Unicode characters. Conversely, a broad normalization step can map distinct strings to the same result. The correct behavior depends on the contract for the particular code format.
One example; three inputs
Take the fictional code AB-12. The second string below uses fullwidth Latin letters and digits. The third ends with a zero-width space.
import unicodedata expected = "AB-12" samples = [ "AB-12", "\uFF21\uFF22-\uFF11\uFF12", # fullwidth A, B, 1, 2 "AB-12\u200B", # zero-width space ] for value in samples: print( value == expected, unicodedata.normalize("NFKC", value) == expected, ) The three output pairs are (True, True), (False, True), and (False, False). NFKC folds the fullwidth characters into ordinary ASCII but leaves the zero-width space. That behavior is useful in some text-processing contexts. It is not automatically a safe redemption rule. If an issuer never defined fullwidth characters as equivalent, applying NFKC at the point of claim changes which inputs can identify the same value.
Make the alphabet part of the contract
Suppose this fictional issuer specifies exactly two uppercase ASCII letters, a hyphen, and two ASCII digits. In that case the server can reject every other shape. The validator does not guess what the customer intended.
import re CODE = re.compile(r"[A-Z]{2}-[0-9]{2}\Z") def parse_code(raw: str) -> str | None: if CODE.fullmatch(raw) is None: return None return raw assert parse_code("AB-12") == "AB-12" assert parse_code("\uFF21\uFF22-\uFF11\uFF12") is None assert parse_code("AB-12\u200B") is None assert parse_code("ab-12") is None These tests encode a product decision rather than a universal rule. If the issuer documents that ASCII letter case is ignored, accept lowercase ASCII and map it to uppercase before the lookup. If it documents optional hyphens or spaces, implement those exact exceptions. Do not infer equivalence from what happens to look similar on a screen.
There is a second reason to keep the rule narrow: support needs to explain failures. A generic “invalid code” message tells a customer little. A form can say that the code contains a character outside the published format, while avoiding an echo of the whole code. It can also show the expected pattern, such as AA-00, using clearly fictional characters. The server should record a structured error category and a request ID, not the raw code, in routine logs.
Keep presentation separate from authority
A client can help someone enter a code. It might display grouping, highlight unexpected characters, or offer a paste review before submission. Those hints are presentation. The server still validates against the same issuer contract after receiving the value. If two clients normalize differently, the server must not accept one and reject the other by accident.
This separation also matters when codes arrive through APIs. A mobile app, support tool, and web checkout may all send strings to the same endpoint. The documented alphabet and canonicalization rule should live with that endpoint and have tests that cover every client. Otherwise a “helpful” browser-only fix creates a bug that appears only in one channel.
For an existing issuer integration, I would first ask for the actual code specification and a small set of test vectors from the issuer. Then I would test exact matches, documented aliases, visually similar Unicode, invisible characters, and concurrent redemption. A matching input should claim at most once. A rejected input should reveal no balance or ownership details.
The practical lesson is simple: make input forgiving only where the issuer contract permits it. A code comparison protects value. Its normalization rule deserves the same care as the lookup that follows.
References
- Unicode Standard Annex #15: Unicode Normalization Forms, especially its discussion of compatibility normalization and the warning against applying NFKC indiscriminately.
- Python documentation:
unicodedata.normalize, for the behavior of the standard-library function used in the example.