OTP-Token: the email header for one-time codes
The header that hands over the code of a verification email in a machine-readable way, bound to an origin, so the mail client can autofill it on the right page. Format, sending, reading and security.
#What it is
Many emails exist only to deliver a code: the sign-in code, the sign-up verification code, the code that confirms a purchase. Today the person opens the email, copies the code, goes back to the page and pastes it, usually switching apps or devices. Nothing ties the code to the site that asked for it, so it can also be typed into a fake page (phishing).
The OTP-Token header tackles both problems. It carries the code in a format programs can read, together with the origin (the site) that asked for it. A mail client or a browser that understands the header can offer the code only to the page of that origin, fill in the field by itself and not hand the code to any other page. It is the same idea as the origin-bound one-time codes that already exist for SMS.
OTP-Token: "123456"; origin="https://example.com"
Not a standard yet
OTP-Token is an individual Internet-Draft (draft-goto-otp-token, version 01, September 2026), not an IETF standard. Names, parameters and syntax may change. Whether a mail client reads the header is up to the client, and Postal does not claim that any specific client reads it today.
Adopting it now is safe because whoever does not know a header ignores it and the code stays in the body of the email. Just do not count on autofill.
What Postal already does for you:
- delivers the header untouched, by API and by SMTP, and refuses right away a value receivers would discard;
- reads the header on the emails you receive and hands you the code in the
otp_tokenfield, handy to test sign-in end to end; - already uses it in its own sign-in emails;
- has helpers in the SDKs to build the value without getting the escaping wrong.
#Format
The value is a Structured Field item from RFC 9651: a String (the code, in double quotes) with the required origin parameter, which is also a String.
OTP-Token: "<code>"; origin="<origin>"
The code
- Printable ASCII, from space to
~. Only the backslash and the double quote need escaping, with a backslash in front (\\and\"). - In Postal the code cannot be empty: with no code there is nothing to fill in. The draft does not say so, it is an extra rule of ours.
The origin
The origin is the site (or web app) where the person will type the code, written as in RFC 6454 (ASCII serialization): scheme, host and, only if it is not the default, the port.
- Lowercase and ASCII. A domain with accents goes in punycode form (
xn--…). - No default port (443 for
https, 80 forhttp), no path, no trailing slash and no?or#. - Never
"null"(an opaque origin). - Potentially trustworthy:
https, orhttponly forlocalhostand127.0.0.1, for development. Postal accepts only those two hosts overhttp. https://app.example.comandhttps://example.comare different origins: use the exact one of the page that asks for the code.
In the message
- Only once. The draft allows one
OTP-Tokenper message. With two, Postal treats the message as having no code. - Top level only. The header of the message itself counts. The ones inside MIME parts (an attachment or a forwarded message, for example) are ignored.
- Unknown parameters are ignored, so the format can grow. Do not put secrets in them.
- Long lines may be folded as RFC 5322 says: a line break followed by a space. Whoever reads it undoes the fold by removing only the line break.
- The header name is case-insensitive (
otp-tokenworks).
Valid and invalid examples
| Status | Value | Why |
|---|---|---|
| Valid | "123456"; origin="https://example.com" | The basic format. |
| Valid | "123456"; origin="https://app.example.com:8443" | A port that is not the default stays in the origin. |
| Valid | "123456"; origin="http://localhost:5173" | http is only valid for localhost and 127.0.0.1, for development. |
| Valid | "123456"; origin="https://example.com"; token=:YWJjZA==: | Unknown parameters are ignored. |
| Valid | "a\"b"; origin="https://example.com" | Quotes and backslashes in the code get a backslash in front. |
| Invalid | 123456; origin="https://example.com" | The code is not a String (without quotes it is an integer). |
| Invalid | "123456" | The origin parameter is missing. |
| Invalid | "123456"; origin=https://example.com | origin is not a String (the quotes are missing). |
| Invalid | "123456"; origin="https://example.com/login" | An origin has no path. |
| Invalid | "123456"; origin="https://example.com/" | Not even the trailing slash. |
| Invalid | "123456"; origin="https://Example.com" | The origin is written in lowercase. |
| Invalid | "123456"; origin="https://example.com:443" | The default port (443 for https, 80 for http) is left out. |
| Invalid | "123456"; origin="http://example.com" | http outside localhost and 127.0.0.1 is not trustworthy. |
| Invalid | "123456"; origin="null" | An opaque origin (null) will not do. |
| Invalid | "123456"; origin="https://exämple.com" | The origin is ASCII: use the punycode form (xn--…). |
| Invalid | "12é456"; origin="https://example.com" | The code has printable ASCII only (from space to ~). |
| Invalid | "12"34"; origin="https://example.com" | Quotes inside the code need an escape (\"). |
| Invalid | ""; origin="https://example.com" | Empty code: there is nothing to fill in (an extra Postal rule). |
| Invalid | "123456" origin="https://example.com" | The semicolon between the code and the parameter is missing. |
#Send by API
Pass the header in headers, in POST /emails. The value is a plain string: in JSON the double quotes get a backslash, and Postal delivers it exactly as you sent it.
cURL
curl -X POST https://api.postal.so/emails \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme Store <[email protected]>",
"to": ["[email protected]"],
"subject": "Your access code",
"html": "<p>Your access code is <b>482913</b>. It is valid for 10 minutes.</p>",
"headers": {
"OTP-Token": "\"482913\"; origin=\"https://app.acmestore.com\""
}
}'
Node.js
const response = await fetch('https://api.postal.so/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme Store <[email protected]>',
to: ['[email protected]'],
subject: 'Your access code',
html: '<p>Your access code is <b>482913</b>. It is valid for 10 minutes.</p>',
headers: {
'OTP-Token': '"482913"; origin="https://app.acmestore.com"',
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/emails",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"from": "Acme Store <[email protected]>",
"to": ["[email protected]"],
"subject": "Your access code",
"html": "<p>Your access code is <b>482913</b>. It is valid for 10 minutes.</p>",
"headers": {
"OTP-Token": "\"482913\"; origin=\"https://app.acmestore.com\"",
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('emails', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'from' => 'Acme Store <[email protected]>',
'to' => ['[email protected]'],
'subject' => 'Your access code',
'html' => '<p>Your access code is <b>482913</b>. It is valid for 10 minutes.</p>',
'headers' => [
'OTP-Token' => '"482913"; origin="https://app.acmestore.com"',
],
],
]);
$data = json_decode($response->getBody(), true);
{
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}
To check the result, read the original message at GET /emails/{id}/raw: the header is there, with the name in the letter case you used.
Batch, templates and scheduling
- Batch (
POST /emails/batch): each email carries its ownheaders, with its own code. If one value is invalid, the whole batch is refused with422, nothing is sent and the message starts with the index of the email, like[1] The `OTP-Token` header must look like…. - Templates:
headersworks together withtemplate; the header is not part of the template, it is set per send. - Scheduled (
scheduled_at): the header is stored and goes out when the time comes. One-time codes expire quickly, so schedule only if the time makes sense for the expiry. - Idempotency:
Idempotency-Keyworks as usual. Use one key per code request, not per user.
#SDKs and Laravel
The SDKs send headers as they are and ship a helper that builds the value with the right escaping and checks the origin before the API refuses it. In Node.js it is otpTokenHeader(code, origin); in PHP, Postal\OtpToken::header($code, $origin). If the code or the origin will not do, they throw PostalError (Node.js) or PostalException (PHP) with validation_error and statusCode 0, without calling the API. The message suggests the canonical origin when it can, for example Use "https://app.acmestore.com" for an address with a path.
Node.js
import { Postal, otpTokenHeader } from 'postal-so';
const postal = new Postal(); // reads POSTAL_API_KEY
await postal.emails.send({
from: 'Acme Store <[email protected]>',
to: '[email protected]',
subject: 'Your access code',
html: '<p>Your access code is <b>482913</b>. It is valid for 10 minutes.</p>',
headers: { 'OTP-Token': otpTokenHeader('482913', 'https://app.acmestore.com') },
});
// otpTokenHeader returns: "482913"; origin="https://app.acmestore.com"
PHP
use Postal\Client;
use Postal\OtpToken;
$postal = new Client(); // reads POSTAL_API_KEY
$postal->emails->send([
'from' => 'Acme Store <[email protected]>',
'to' => '[email protected]',
'subject' => 'Your access code',
'html' => '<p>Your access code is <b>482913</b>. It is valid for 10 minutes.</p>',
'headers' => ['OTP-Token' => OtpToken::header('482913', 'https://app.acmestore.com')],
]);
// OtpToken::header returns: "482913"; origin="https://app.acmestore.com"
Laravel
// app/Mail/LoginCode.php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Postal\OtpToken;
class LoginCode extends Mailable
{
public function __construct(public string $code)
{
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Your access code');
}
public function content(): Content
{
return new Content(view: 'emails.login-code'); // the code is in the body too
}
public function headers(): Headers
{
return new Headers(text: [
'OTP-Token' => OtpToken::header($this->code, 'https://app.acmestore.com'),
]);
}
}
// Anywhere in the application
Mail::to($user)->send(new LoginCode($code));
In Laravel, the Mailable above works with the postal transport of the PHP package (which sends the extra headers to the API) and with the SMTP mailer: Symfony Mailer, which Laravel uses, does not encode the value. See the SDKs in SDKs.
#Send by SMTP
Over SMTP, Postal delivers the message as your system sent it and does not check the format of the header: format validation belongs to the API. Check the value yourself beforehand.
The care is with the SMTP library. When a library applies the encoded-word encoding of RFC 2047 to the value, the quotes become =22 and the header turns into, for example:
OTP-Token: =?UTF-8?Q?=22123456=22=3B_origin=3D=22https=3A//ex?=
=?UTF-8?Q?ample=2Ecom=22?=
That is no longer a Structured Field: whoever reads it finds neither the code nor the origin. The encoding exists for non-ASCII text (a subject with accents), not for a value that is already ASCII and has its own syntax. That is why the Postal SMTP gateway undoes the encoding: if the message has one OTP-Token header made of encoded words (=?UTF-8?B?...?= or ?Q?), on one or several lines, Postal decodes it, unfolds it and delivers the original value ("123456"; origin="https://example.com"), unencoded, with long lines folded only after the ;. If the decoded value is not a valid OTP-Token, or if there are two headers, the message goes out as it arrived, and without the API error message. The rest of the message does not change. Even so, prefer not to encode. Here is how to avoid the encoding in the most common libraries:
Node.js
import nodemailer from 'nodemailer';
import { otpTokenHeader } from 'postal-so';
const transporter = nodemailer.createTransport({
host: 'smtp.postal.so',
port: 465,
secure: true,
auth: { user: 'postal', pass: process.env.POSTAL_API_KEY },
});
await transporter.sendMail({
from: 'Acme Store <[email protected]>',
to: '[email protected]',
subject: 'Your access code',
html: '<p>Your access code is <b>482913</b>. It is valid for 10 minutes.</p>',
headers: {
// "prepared: true" makes Nodemailer send the value as it is, without encoding it (RFC 2047)
'OTP-Token': { prepared: true, value: otpTokenHeader('482913', 'https://app.acmestore.com') },
},
});
PHP
use PHPMailer\PHPMailer\PHPMailer;
use Postal\OtpToken;
// PHPMailer encodes (RFC 2047) header values longer than 47 characters.
// This subclass leaves the OTP-Token as it is.
class OtpMailer extends PHPMailer
{
public function encodeHeader($str, $position = 'text')
{
if (str_starts_with($str, '"') && str_contains($str, '; origin="')) {
return $str;
}
return parent::encodeHeader($str, $position);
}
}
$mail = new OtpMailer(true);
$mail->isSMTP();
$mail->Host = 'smtp.postal.so';
$mail->Port = 465;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mail->SMTPAuth = true;
$mail->Username = 'postal';
$mail->Password = getenv('POSTAL_API_KEY');
$mail->CharSet = 'UTF-8';
$mail->setFrom('[email protected]', 'Acme Store');
$mail->addAddress('[email protected]');
$mail->Subject = 'Your access code';
$mail->isHTML(true);
$mail->Body = '<p>Your access code is <b>482913</b>. It is valid for 10 minutes.</p>';
$mail->addCustomHeader('OTP-Token', OtpToken::header('482913', 'https://app.acmestore.com'));
$mail->send();
Python
import os
import smtplib
from email.message import EmailMessage
message = EmailMessage()
message["From"] = "Acme Store <[email protected]>"
message["To"] = "[email protected]"
message["Subject"] = "Your access code"
message["OTP-Token"] = '"482913"; origin="https://app.acmestore.com"'
message.set_content("Your access code is 482913. It is valid for 10 minutes.")
with smtplib.SMTP_SSL("smtp.postal.so", 465) as server:
server.login("postal", os.environ["POSTAL_API_KEY"])
server.send_message(message)
What was tested
With Nodemailer (versions 6 and 10), the value goes out encoded unless you use prepared: true. In PHPMailer 7.1, values up to 47 characters go out as they are and longer ones are encoded, hence the subclass. Python's email module and Symfony Mailer (Laravel) do not encode an ASCII value of normal size. Other versions may behave differently: check the message that goes out with GET /emails/{id}/raw or by sending a test to an inbox.
If you need a guarantee, send by API or with the SDKs, where Postal builds the message and does not encode the value.
#What Postal validates
If the request has an OTP-Token header (in any letter case), the API checks what the draft requires and answers 422 validation_error instead of sending a header that receivers would discard. Nothing is sent or counted against the quota. Without the header, nothing is checked or added.
- The value is a String in double quotes, with printable ASCII only and valid escapes, and it is not empty.
originexists, is a String and is in the canonical form of an origin.- Unknown parameters pass.
- The header appears only once, in any letter case:
OTP-Tokenandotp-tokentogether also give 422.
Messages come in English, like the others; with Accept-Language: pt-BR they come in Portuguese. Handle the error by its name (validation_error), not by the text.
{
"statusCode": 422,
"name": "validation_error",
"message": "The `OTP-Token` header must look like `\"123456\"; origin=\"https://example.com\"`: the code in double quotes and an `origin` parameter with the https origin (scheme and host, lowercase, no path) that asks for the code."
}
{
"statusCode": 422,
"name": "validation_error",
"message": "O cabeçalho `OTP-Token` precisa ter o formato `\"123456\"; origin=\"https://example.com\"`: o código entre aspas duplas e um parâmetro `origin` com a origem https (esquema e domínio, em minúsculas, sem caminho) que pede o código."
}
{
"statusCode": 422,
"name": "validation_error",
"message": "The `OTP-Token` header can only be set once, whatever the letter case of its name."
}
{
"statusCode": 422,
"name": "validation_error",
"message": "O cabeçalho `OTP-Token` só pode ser definido uma vez, qualquer que seja a caixa das letras do nome."
}
The value goes out as you sent it
Postal does not encode the value. The quotes stay as they are and do not become RFC 2047 encoded words, which would leave the header unreadable as a Structured Field. Only long lines are folded (a line break followed by a space, after the ;), which the receiver undoes without changing the value. A subject with accents is encoded normally and does not affect the OTP-Token.
Limits of the headers field: up to 998 characters per value and no line breaks. See POST /emails.
#Receive and test
On the emails that reach your inboxes, Postal reads the header and hands it to you ready in the otp_token field: an {"code", "origin"} object or null. It shows up in GET /emails/receiving (list) and GET /emails/receiving/{id}, in the received messages of GET /inboxes/{id}/threads/{thread_id} and in the MCP tool get_inbox_thread. A full-access key is required. The value of the header, unfolded and, if it came as RFC 2047 encoded words, decoded, is in headers["otp-token"].
cURL
curl https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "received_email",
"id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"created_at": "2026-10-24T12:41:09.000000Z",
"from": "Acme Store <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"subject": "Your access code",
"message_id": "<[email protected]>",
"read": false,
"size": 18342,
"has_attachments": false,
"otp_token": {
"code": "482913",
"origin": "https://app.acmestore.com"
},
"reply_to": [],
"html": "<p>Your access code is <b>482913</b>. It is valid for 10 minutes.</p>",
"text": null,
"headers": {
"otp-token": ["\"482913\"; origin=\"https://app.acmestore.com\""],
"date": "Tue, 27 Oct 2026 10:12:07 -0300"
},
"attachments": []
}
Strict reading
Reading is all or nothing: otp_token is only filled in when the message has exactly one OTP-Token header in the top-level header section, unfolded as RFC 5322 says, with a non-empty String and an origin String in canonical form. Any deviation gives null, never a partial result. Headers inside MIME parts or attached messages are ignored, and so are unknown parameters. Emails received before the feature existed do not have the value stored and also give null.
Why the code is not in the webhook
The code is a short-lived secret. The payload of email.received stays as it always was (sender, subject, attachments), without otp_token: a webhook goes through queues, retries, logs and monitoring tools, places where a code should not end up. Use the webhook to learn that the email arrived and fetch the code with GET /emails/receiving/{id}, which requires your key.
In the dashboard
When you open the message in Inboxes, a line shows Code 123456 · origin example.com, with a button to copy the code. It only appears when the header is valid.
Test sign-in end to end
The most direct use is in your product's automated tests: instead of opening a real mailbox, the test asks for the code, fetches the email from the inbox and types the code. Create a test inbox (with catch_all, each run uses a new address) and let the sign-in email of the test environment reach it.
// tests/helpers/wait-for-otp.js
const API = 'https://api.postal.so';
const headers = { Authorization: `Bearer ${process.env.POSTAL_API_KEY}` };
/**
* Waits for the sign-in email to reach the test inbox and returns { code, origin }.
* Marks the email as read so the next run does not reuse the code.
*/
export async function waitForOtp({ inboxId, recipient, timeoutMs = 30_000 }) {
const query = new URLSearchParams({ inbox_id: inboxId, unread: 'true', search: recipient, limit: '5' });
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await fetch(`${API}/emails/receiving?${query}`, { headers });
const { data } = await response.json();
const email = data.find((item) => item.otp_token !== null);
if (email) {
await fetch(`${API}/emails/receiving/${email.id}`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
});
return email.otp_token;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`No email with an OTP-Token reached ${recipient} within ${timeoutMs} ms.`);
}
// tests/login.spec.js (Playwright)
import { test, expect } from '@playwright/test';
import { waitForOtp } from './helpers/wait-for-otp.js';
test('signs in with the code received by email', async ({ page }) => {
const recipient = `test-${Date.now()}@support.acmestore.com`; // test inbox with catch-all
await page.goto('https://app.acmestore.com/sign-in');
await page.getByLabel('Email').fill(recipient);
await page.getByRole('button', { name: 'Send code' }).click();
const { code, origin } = await waitForOtp({ inboxId: process.env.TEST_INBOX_ID, recipient });
expect(origin).toBe('https://app.acmestore.com'); // the header is bound to the right page
await page.getByLabel('Code').fill(code);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
The test also checks the origin, which catches the day someone changes the app domain and forgets the header. With cURL, it is the same query:
curl 'https://api.postal.so/emails/receiving?inbox_id=0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f&unread=true&limit=5' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Look after the test inbox
Anyone with a full-access key reads the codes that arrive. Use an inbox only for tests, with test emails, and never receive real people's codes in it.
#Postal's own emails
The code emails Postal sends to you (sign in, create the account and confirm it is you) already carry the OTP-Token, with the origin of the dashboard, because that is where the code is typed:
OTP-Token: "123456"; origin="https://app.postal.so"
The code stays visible in the body of the email. If one day the dashboard address does not qualify as an origin (a dashboard on http outside localhost, for example), the header is left out and the email goes out normally.
#Security best practices
- Bind the origin to the page that asks for the code. Use the exact origin of the site or web app where the person types the code, never the one of your mail server or the API.
- Short expiry and single use. A few minutes are enough. Invalidate the code once it is used and when a new one is requested.
- Limit attempts. Block or slow down after a few wrong tries and limit how many codes one address or IP can request. A 6-digit code with no attempt limit can be guessed.
- Keep the code visible in the body. The header is not shown to the person. Clients that do not read it, and people who open the email on another device, need the text. Also tell them not to share the code.
- Only the code. Do not put session tokens, recovery links or other secrets in the header, nor in extra parameters.
- Authenticate your domain. The header is only worth something if whoever reads it trusts whoever sent it. Keep DKIM, SPF and DMARC aligned with the sender domain (see Domains and DNS); without that, anyone can forge an email with your domain and your OTP-Token.
- Do not log the code. Keep an eye on logs, error tools and webhooks so it does not end up there.
- Email is plan B, not a replacement. A code by email is a good recovery path, but passkeys and other phishing-resistant factors should come first when you can offer them. OTP-Token reduces phishing of the code, it does not eliminate it.
#Frequently asked questions
Do Gmail or Apple Mail already read the header?
We do not know, and we do not claim support from any specific client. OTP-Token is a recent draft, and each client decides whether and when to read it. Check the documentation of the client that matters to you.
Sending the header does not get in the way of those who do not read it, because headers a client does not know are ignored, and the code stays in the body.
Do I need to change the body of the email?
No. Keep the code visible in the text and the HTML. The header is an addition for programs, and the person still sees the code as before.
What if the header is invalid?
By API, Postal answers 422 with the reason and nothing is sent (see What Postal validates). Over SMTP, the message goes out as it came and receivers simply ignore an invalid header. When receiving, an invalid header becomes otp_token: null.
Does it work with SMTP?
Yes. Postal does not touch the message. Just make sure your SMTP library does not encode the value (see Send by SMTP).
Does it work with templates and scheduled emails?
Yes. The headers field works in POST /emails, in a batch, with template and with scheduled_at.
Can I use it on localhost?
Yes: http://localhost:5173 and http://127.0.0.1:3000 are accepted for development. Any other host over http is refused.
What is an origin?
It is the identity of a site on the web: scheme, host and port, as in https://app.example.com (the port only shows if it is not the default). Pages with different origins do not mix: https://example.com, https://app.example.com and http://example.com are three origins. Binding the code to the origin is what keeps a fake page from receiving it.
How is it different from List-Unsubscribe?
Both are headers that the mail client reads to offer an action without opening the body. List-Unsubscribe (RFC 2369 and RFC 8058) is an old, widely used standard for leaving a list; Postal adds it to every marketing email. OTP-Token is a draft for filling in a one-time code. They serve different emails: the first, campaigns and automations; the second, code emails.
#References
- draft-goto-otp-token: the Internet-Draft of the
OTP-Tokenheader (version 01). - RFC 9651: Structured Field Values for HTTP, the syntax of the value.
- RFC 6454: The Web Origin Concept, the serialization of the origin.
- RFC 5322: Internet Message Format, the headers and folded lines.
- RFC 2047: MIME part three, the encoded words that must not be used in this value.
See also Send email, Received emails and inboxes, SMTP and SDKs.