The full picture, in one page
The vocabulary, in one place
Source
An email inbox you want to forward OTPs from. Each source is one mailbox — e.g. one for your work Gmail, one for your bank alerts, one for a Workspace custom domain.
You create a source for "Work Gmail" and a separate source for "Bank Alerts".
Forwarding address
A unique plus-addressed destination we give each source. It looks like fwd+abc123@otp1.me. You point a mail-client filter at it; that is the only filter — otp1 does not run subject or sender matching of its own.
fwd+abc123@otp1.me
Mail-client filter
A rule you set in your own mailbox (Gmail, Outlook, Yahoo, etc.) that forwards OTP-bearing emails to the forwarding address. The filter is the single source of truth for which emails reach otp1 — we never silently drop mail on our side.
A Gmail filter that forwards every "verification code" email to fwd+abc123@otp1.me.
In-app inbox
The list of OTPs you have received, at otp1.me/app. The 50 most recent are shown, with a real-time SSE push so new ones appear at the top within seconds. The inbox is implicit — it is the system’s source of truth and is not a destination you can turn off.
Destination
A place to send the OTP after we extract it — Discord, Slack, an email link, a webhook, or another otp1 user (a share). Every destination lives behind a per-type override rule, so you can route different sources to different places.
Catch-all destination
A destination with no source selected. It fires for OTPs from every source you have. Useful for a default "send to my Discord" rule that you then override per source as needed.
A catch-all Discord destination that posts every OTP into #my-otps.
Source-specific destination
A destination bound to one source. For the matching source, it replaces the catch-all of the same type. Other types are evaluated independently — a catch-all Slack and a source-specific Discord both still fire for the same email.
A source-specific Slack destination on "Bank Alerts" that posts to a private channel.
Source label
A user-chosen display name for a source — e.g. "Work Gmail", "Bank Alerts". It shows up in the in-app inbox, in Discord/Slack embeds, and in webhook payloads alongside the raw source email. It is a display name, not a privacy mechanism: the raw email is always present in webhook destinations.
Share
A binding from one source on your account to another otp1 user. The recipient sees the matching OTPs in their own in-app inbox, marked with a "Shared" badge and your email as the sharer. A recipient cannot reshare further; shares are always source-specific.
From-filter
An optional pattern on a share that narrows it to a specific From: address (exact match) or domain (exact match, no subdomains). A sharer can have multiple filtered shares to the same recipient on the same source, each with a different pattern. The pattern is one-per-share — no wildcards, no subject matching, no lists.
A share filtered to incometax.gov.in — the recipient only sees OTPs whose From: domain is exactly incometax.gov.in, not anything from mail.incometax.gov.in.
Quota
A per-plan daily limit on how many OTPs we extract. Free plans have a small quota; Pro and Business have larger ones. Exceeding the quota on a given UTC day pauses extraction for the rest of the day — the email is still stored, but no OTP is parsed and no deliveries fire.
Retention
How long we keep the email and the extracted OTP. The default is 7 days, adjustable per plan. A rolling sweep purges anything older. otp1 keeps the OTP and a small set of metadata fields — not the full email body.
What happens to an OTP, from sender to recipient
- 1
The sender (a bank, a vendor, an auth provider)
Sends a one-time password to your email address — the same one you configured as a source. Nothing about the original send changes; otp1 sits downstream of your mailbox.
- 2
Your mail client (Gmail, Outlook, Yahoo, …)
Receives the email. The mail-client filter you set up in that mailbox matches the message and forwards it to the source’s forwarding address (fwd+<nanoid>@otp1.me).
- 3
The inbound mail worker
Receives the forwarded email at otp1.me, then calls our internal API with the raw RFC 5322 body and a shared-secret header. It also handles bounces back to the upstream sender if we reject.
- 4
otp1 (the API)
Does a fast pre-flight: auth, parse the To: nanoid, look up the source, run a state-gate, then asynchronously parse the email, extract the OTP, dedupe, check quota, persist, deliver to destinations, fan out to shares, and emit the SSE event for the inbox.
- 5
Your destinations
Each active destination of a type that "wins" the per-type override receives the OTP. The in-app inbox always shows it. Webhook destinations get a signed JSON POST. Discord and Slack get formatted embeds. Email-link destinations get a short-lived link.
- 6
Your shares
For every active share on the source whose From: address matches the share’s from-filter (or where the filter is null), a separate shared_emails row is written into the recipient’s in-app inbox. They get a real-time push too.
Everything you can do with a source
- Multiple sources, one accountAdd as many sources as your plan allows (Free=1, Pro=20, Business=100). Each one gets its own forwarding address and its own destinations. Sources are independent — pausing one does not affect the others.
- Provider detectionWhen you add a source, we detect the email provider (Gmail, Yahoo, Live/Outlook, Zoho, or a generic custom-domain path) from a static domain table plus an MX-host suffix fallback, and render a step-by-step setup guide for the mail-client filter. Unknown providers fall through to the generic guide.
- Source labelsGive a source a human-friendly name — "Work Gmail", "Bank Alerts", "Gov Portals". The label appears in the in-app inbox, in Discord/Slack embeds, and in webhook payloads alongside the raw email. The label is a display name only; the source email is always present in webhook destinations.
- Send testA button on each source that sends a fixed test email from noreply+test@otp1.me into the pipeline. The body is a known value so you can confirm the downstream half (extraction, delivery) end-to-end. Quota is not consumed; the test row is purged at the next retention sweep.
- Verify ForwardingA separate button that sends a real probe to your mailbox from noreply+probe@otp1.me. Your mail-client filter is what must match it and forward it back. The round-trip shows up as a verification-badged row in your inbox — its presence is the only success signal, its absence means the filter is misconfigured.
- Pause, edit, or deleteSources can be paused (with a reason — manual, over_source_limit, over_plan_limit) or deleted. Deletion is a hard cascade: emails and deliveries for that source are removed in the same transaction. Paused sources stop accepting inbound mail; the upstream sender is bounced with a clear reason.
Where OTPs go, and how routing works
The six destination types
Every destination belongs to one of these types. Some are gated by plan; the in-app inbox is universal.
In-app inbox
All plansImplicit — not a destination you toggle on or off.
The system’s source of truth. Every received OTP appears here, with a real-time SSE push. The 50 most recent are listed; "Load older" paginates further back, up to the retention window.
Email link
All plansA short-lived, opaque link that opens the OTP in a browser.
Tokens are 32-byte base64url, expire in 10 minutes, and are single-page (re-loadable until expiry or "Mark as used"). Free = link only, no plaintext. Paid = configurable plaintext in the body, with a warning that plaintext disables view tracking.
Discord
All plansA formatted embed posted to a Discord webhook URL.
Title, source label, bold OTP, color, relative timestamp, footer. The webhook URL is stored encrypted at rest. Failures retry with exponential backoff (1m, 5m, 25m); three consecutive permanent failures trip a 24-hour circuit-breaker.
Slack
All plansA Block Kit message posted to a Slack incoming webhook.
Header, section with bold OTP, context with relative timestamp. Same retry / circuit-breaker posture as Discord. URL is encrypted at rest.
Webhook
Pro, BusinessA signed JSON POST to a URL you own.
Payload: event_id, attempt, otp, source_id, source_label, source_email, received_at. Headers: X-otp1-Signature: t=<unix_ts>,v1=<hex_hmac(secret, ts.body)>, and X-otp1-Event-Id. The signing secret is shown once on creation; we store a hash and fingerprint, never the plaintext.
Share with another otp1 user
All plansHand the OTP to a recipient’s in-app inbox.
Not a destination row — modeled separately. The recipient sees the OTP with a "Shared" badge and your email as the sharer. Optional from-filter narrows the share to a specific From: address or domain. See the Sharing section below.
Per-type override (the routing rule)
Per-type override is how otp1 decides which destinations fire for a given email. The same rule applies independently to each destination type, so a catch-all Discord and a source-specific Slack can both fire for the same email.
- Each destination type is evaluated independently against the triggering source.
- If you have any active source-specific destination of type T, only those fire for that source. The catch-all of type T is skipped.
- Otherwise, the active catch-all destinations of type T fire.
- A paused destination of type T is treated as absent — the catch-all of type T fires in its place.
- Within the winning scope, every active destination fires. Two catch-all email links → two links, not one.
- No OTP extracted → no deliveries fire for any type.
- Shares are exempt from this rule — they are always source-specific and follow their own fan-out logic.
Reliability
- Retries. Failures retry with exponential backoff: 1 minute, 5 minutes, 25 minutes. Three attempts total.
- Circuit-breaker. Three consecutive permanent failures (HTTP 4xx that aren\u2019t 408/429) put the destination behind a 24-hour circuit-breaker. No further deliveries attempt until it resets.
- Per-attempt timeouts. 5 seconds to connect, 10 seconds total. A hung destination cannot stall the worker.
- Error visibility. Permanent failures are surfaced in the UI as a delivery-error row with the response snippet and a "retry" path.
Hand OTPs to another otp1 user
The four-step lifecycle
- 1
Sharer — Creates an invite
You pick a source, type the recipient’s email (they must be an otp1 user, or sign up via the invite link), and optionally enter a from-filter. The API validates the pattern and creates a pending share_request. The recipient is sent a one-shot invite email disclosing the filter, if any.
- 2
Recipient — Accepts (or rejects) the invite
Pending invites show up in the recipient’s in-app inbox as a banner with Accept / Reject buttons. There is no email-based accept link — it is in-app only. Rejecting is a hard-delete of the pending row; you can be re-invited immediately. Accepting creates an active source_share row.
- 3
Both sides — OTPs flow automatically
For every email processed on the sharer’s source, the pipeline evaluates each active source_share on that source. If the share has no filter, it always matches. If it has a from-filter, the From: address must match the pattern. Each matching share gets its own shared_emails row in the recipient’s inbox, with a real-time SSE push.
- 4
Recipient — Revokes (or sharer revokes)
The recipient can revoke an active share at any time. Revocation is a hard-delete of the source_share row, which cascades the shared_emails rows. There is no re-share on the recipient side: shares are leaves in the trust tree.
Caps
Two independent per-sharer caps. The pending cap is uniform across plans; the active cap scales with the plan.
Pending invites
A sharer can have at most 5 pending share_requests at any time, summed across all sources, recipients, and filters. Pending invites expire at 7 days and are hard-deleted by a daily cron.
Active shares
Free=3, Pro=50, Business=200 active source_shares per sharer. Per-filter shares do not multiply the cap — the cap is a simple total count. Downgrading past the cap auto-revokes the oldest shares in the same transaction as the plan change.
From-filter: share only OTPs from a specific sender
A share is, by default, an unfiltered "share all OTPs from this source" binding. The from-filter lets the sharer narrow that to a specific From: address or domain, so the recipient sees only the OTPs the sharer wants them to see.
Pattern format
One string per share. Detection is by the presence of @:
- A value with an @ sign is treated as an exact-email pattern (e.g. noreply@incometax.gov.in).
- A value with no @ sign is treated as a bare-domain pattern (e.g. incometax.gov.in).
- Exact-email match is case-insensitive equality on the full From: address after both sides are lowercased and trimmed.
- Domain match is case-insensitive equality on the domain part of the From: address. Subdomains do not match — incometax.gov.in matches noreply@incometax.gov.in but not noreply@mail.incometax.gov.in.
- Empty or malformed From: addresses silently fail to match filtered shares. Share-all shares still match.
- No wildcards, no regex, no multiple addresses per share, no subject matching. One pattern per share.
- The pattern is immutable after creation. To change it, the sharer revokes the share and creates a new one with a different pattern.
- The recipient can see the from-filter value on every matched inbox row and in the detail view. They cannot see which OTPs were filtered out — only the scope this share matches.
How we keep the data footprint small
- No subject or sender scanningotp1 does not run subject-keyword or sender-allowlist filters on inbound mail. Your mail-client filter is the single source of truth — if an email reaches fwd+<nanoid>@otp1.me, we process it. The only automatic drops are the self-send loop break (inbound From: @otp1.me) and the inbound dedupe (re-ingestions of the same Message-ID within the retention window).
- Encrypted at restDestination configs — Discord URLs, Slack URLs, webhook URLs and signing secrets, etc. — are encrypted with AES-256-GCM (per-row IV, master key from the DEST_CONFIG_KEY env). Webhook signing secrets are stored as a hash plus a fingerprint; we can verify a destination but we cannot read its plaintext secret.
- Signed webhook payloadsEvery webhook POST carries X-otp1-Signature: t=<unix_ts>,v1=<hex_hmac(secret, ts.body)> and X-otp1-Event-Id: <uuid>. The receiving system should verify the HMAC before trusting the payload — the secret is shared out-of-band and is shown once on creation.
- 7-day default retentionEmails and extracted OTPs are purged on a rolling sweep. The default is 7 days; Pro and Business let you set a longer or shorter window. otp1 keeps the OTP and a small set of metadata fields (From:, To:, Subject:, received_at, Message-ID, normalized text) — not the full email body. We are an OTP service, not an email archive.
- Daily quotaA per-plan daily limit on how many OTPs we extract. Hitting the quota pauses extraction for the rest of the UTC day — the email is still stored with delivery_status = skipped_quota, but no OTP is parsed and no deliveries fire. Quota counters reset at UTC midnight. Recipients of shares from a quota-paused sharer see a per-source-share "paused due to quota" indicator on their next inbox load.
- Soft-delete with 30-day graceAccount deletion is a soft-delete: user_profile.deleted_at is set, inbound mail is rejected, and a purge job hard-deletes the row (and cascades) after 30 days. If the user logs in again during the grace window — by redeeming a magic link from a fresh email to signup@otp1.me — the deletion is automatically cancelled and the account is fully restored. There is no explicit "cancel deletion" button.
- Real-time pushThe in-app inbox subscribes to a single Server-Sent Events stream that emits otp.received events for both own and shared rows. The type discriminator is in the payload (own | shared), so the same connection handles both. A polling fallback (GET /api/emails/since=<id>, every 10s) covers flaky networks.
- Plan limitsThree plans — Free, Pro, Business — with limits on sources (1/20/100), active shares (3/50/200), destinations, daily quota, and retention. Hitting any cap returns a uniform 400 payload with an upgrade-prompt modal. Downgrading past a cap auto-pauses / deactivates the overflow in the same transaction, with a per-side-effect count surfaced on the next page load.