Pulling a one-time code out of an email is pattern matching over text someone else wrote. The sender picks the wording, the layout and the code format, and the extractor either recognizes the result or does not. This post describes the approach Lumbox's receiving pipeline uses on incoming mail, lists the phrasings it matches and the ones it misses, and ends with a fallback for the misses. It is all regular expressions. No language model reads your mail to find the code, and a BYOK AI key does not change the extraction.
Which text gets scanned
The extractor reads the subject line and the plain-text body, joined together. It does not scan the HTML part separately. For a message sent as HTML only, the MIME parser generates a text version from the HTML, so a code there is still seen. When a message has both parts, only the plain-text part is read, so a code that appears in the HTML but not in the text part is missed. Scanning text also avoids a common HTML problem: templates that put each digit in its own styled cell or span, which a pattern run over raw markup cannot see as one number.
The six patterns
A code is a run of 4 to 8 digits. The patterns, in the order they run:
- A keyword, then "code", "PIN", "number" or "token", then "is" or a colon, then the digits: "Your verification code is 847291", "Login code: 482913". The keywords are verification, confirm or confirmation, security, auth or authentication, login, sign in (also sign-in and signin), one-time, OTP, 2FA and MFA.
- "Your" or "the", then "code", "PIN", "OTP" or "token", then "is" or a colon: "Your code is 482913".
- Enter, use, type, input or submit, then the digits, then "to", "for", "as" or "in": "Use 482913 to complete your login".
- The digits, then "is your", then optionally one of a shorter list of words (verification, confirm, security, auth, login, one-time, OTP), then "code", "PIN", "number" or "token": "482913 is your verification code".
- The digits alone on their own line.
- "Code", "PIN", "OTP" or "token", then a colon or equals sign, then the digits: "Code: 482913".
Matching ignores case. A four-digit result that looks like a year from 1900 through 2099 is thrown away, duplicates are merged, and the survivors land in parsed.otp_codes. The array is ordered by which pattern matched first, not by where the number sits in the message, and the /otp endpoint's code field is simply the first element.
Measured against common phrasings
These results come from running the production patterns and category rules on each line below, with the subject "Your Acme code". In a real email the rest of the message, footer and headers included, also feeds the category, so a full message can come out differently from the line alone.
| Line in the email | otp_codes | category |
|---|---|---|
| Your verification code is 847291 | ["847291"] | verification |
| 847291 is your verification code | ["847291"] | verification |
| Your login code is 482913 | ["482913"] | general |
| Use 482913 to complete your login | ["482913"] | general |
| Code: 482913 (expires in 10 minutes) | ["482913"] | general |
| 482913 on its own line | ["482913"] | general |
| 847291 is your one-time password | [] | verification |
| 482913 is your Acme code | [] | general |
| Enter 482913 on the sign-in page | [] | general |
| Your code is 482 913 | [] | general |
| Your code is K7M2QX | [] | general |
| Ihr Bestaetigungscode lautet 482913 | [] | general |
| Order number: then 58213 on its own line | ["58213"] | general |
| Your code: 3024 | ["3024"] | general |
The misses group into a few kinds. Codes containing letters are never extracted, and neither are digits split by a space or hyphen, codes shorter than 4 or longer than 8 digits, or wording in languages other than English. The "is your" pattern needs "code" or one of its short-list words right after "your", so a brand name there ("your Acme code") or the word "password" ("your one-time password") breaks the match. "Enter 482913 on the sign-in page" fails because "on" is not one of the words pattern 3 accepts after the digits.
Why the category column matters
The category comes from weighted keyword rules: verification (10), security (9), support (8), calendar (8), transactional (7), notification (5), conversation (4) and newsletter (3), with general when nothing matches. The /otp endpoint only considers emails in the verification category. Six of the eight rows with an extracted code came out as general, and /otp will wait on messages like those until it returns 408. The one-time-password row fails the other way: it counts as a verification email but has no extracted code, so /otp keeps waiting on it too.
GET /v1/inboxes/:id/wait?has_otp=true has no category condition. It takes the newest message received since your since timestamp and returns it once that message has at least one extracted code, which makes it the more predictable choice when you do not control the sender's wording.
False positives
The own-line pattern is the broadest. It turns any 4 to 8 digit number on a line by itself into a code, which covers the code in most templates and also order numbers, ZIP codes and invoice totals formatted that way. The year filter only drops four-digit numbers from 1900 through 2099, so "3024" passes. When otp_codes has more than one entry, the first one is the earliest pattern's match rather than the most likely code, and a test that asserts the array has exactly one element catches that case before the wrong code gets typed.
What else the same pass extracts
The same regex pass fills the rest of the parsed block. verification_links are URLs containing verify, confirm, activate, validate or auth, or a token=, code= or key= parameter with at least 8 characters. magic_links are URLs mentioning a magic link, passwordless or auto-login, or a login URL with a token. backup_codes are read from a block after words like "backup codes" or "recovery codes". code_expiry is set when the text says something like "expires in 10 minutes" or "valid for 2 hours", converted to an absolute time when the email is parsed. confidence starts at 0.5 and adds 0.3 for a code, 0.1 for a verification link and 0.1 for any category other than general.
A fallback for the formats it misses
The /wait response includes text_body, so when otp_codes is empty you can run a pattern written for the sender you care about:
function codeFrom(email) {
if (email.parsed.otp_codes.length === 1) return email.parsed.otp_codes[0];
const text = email.text_body ?? "";
const split = text.match(/\b(\d{3})[ -](\d{3})\b/);
if (split) return split[1] + split[2];
const alnum = text.match(/\bcode(?: is)?[:\s]+([A-Z0-9]{6,8})\b/i);
return alnum ? alnum[1] : null;
}
Against the misses in the table, this returns 482913 for "482 913" and "482-913" and K7M2QX for "Your code is K7M2QX". The API wraps text_body in marker lines that label it as untrusted email content. Those lines contain no digits and not the word "code", so they do not disturb either pattern. Keep a fallback like this narrow: the split-digit pattern would also match a phone number fragment in a footer, which is why it belongs in code aimed at one known sender.
If you write the templates
When the emails come from your own app, and agents or tests will read them, you can make extraction reliable with the template alone. Put the code in the plain-text part, as 4 to 8 digits with no spaces, after the words "Your verification code is", and keep other numbers off lines of their own. That wording matches the first pattern and puts the message in the verification category, so both /otp and /wait?has_otp=true return it.
The email object reference documents every parsed field, the inbox reference covers /otp and /wait, and handling OTPs with headless browsers and agents shows these endpoints inside real flows.