How it works

The full picture, in one page

A walkthrough of what otp1 does, the vocabulary of the moving parts, and the operational details that determine what happens at the edges.
Concepts

The vocabulary, in one place

A glossary of the moving parts. The rest of this page refers back to these terms.

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.

End-to-end

What happens to an OTP, from sender to recipient

Six handoffs. The wall-clock latency from "email lands in your inbox" to "OTP shows up at your destination" is typically a few seconds.
  1. 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. 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. 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. 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. 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. 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.

Sources

Everything you can do with a source

  • Multiple sources, one account
    Add 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 detection
    When 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 labels
    Give 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 test
    A 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 Forwarding
    A 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 delete
    Sources 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.
Destinations

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 plans

Implicit — 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 plans

A 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 plans

A 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 plans

A 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, Business

A 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 plans

Hand 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.
A worked example
You have a catch-all Discord destination and a source-specific Slack destination on Source A. An OTP arrives from Source A: Slack fires (source-specific wins for Slack type), Discord does not fire for that email (catch-all of type Discord is suppressed by the existence of a source-specific Discord? No — Discord wasn't even configured source-specific, so catch-all Discord still fires). An OTP arrives from Source B: Slack does not fire (no source-specific Slack for B), Discord fires (catch-all).

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.
Sharing

Hand OTPs to another otp1 user

The share system is the way to give a person (not a system) access to your OTPs. It is in-app only — no plaintext email, no Discord/Slack/webhook routing.

The four-step lifecycle

  1. 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. 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. 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. 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.

A worked example
You share your "Gov Portals" source with your accountant. You add two filtered shares on that source: one narrowed to incometax.gov.in (so your accountant sees only the income-tax OTPs), one narrowed to gst.gov.in (so they see only the GST OTPs). You can also add a third unfiltered share on the same source if you want them to see everything else. Each share is its own row; the recipient sees the filter as a chip on every matched inbox row.

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.
Disclosure to the recipient
The from-filter is shown to the recipient — in the invite email, in the share list, and as a chip on every matched inbox row. The recipient can see the scope; they cannot see the OTPs that were filtered out, only the ones this share matches. Do not use the filter to obscure the existence of a relationship — the recipient will know the filter is there.
Privacy & ops

How we keep the data footprint small

The boring parts of OTP forwarding are the most important. This section is the full list of the operational details worth knowing before you trust otp1 with your codes.
  • No subject or sender scanning
    otp1 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 rest
    Destination 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 payloads
    Every 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 retention
    Emails 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 quota
    A 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 grace
    Account 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 push
    The 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 limits
    Three 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.

Ready to forward your first OTP?

Send a one-line email to get started. No password, no card, no install.