openapi: 3.1.0
info:
  title: otpmagiclink API
  version: 1.0.0
  description: |
    API-first OTP and magic-link verification infrastructure.

    Issue one-time passwords and magic links for any email or phone number.
    Handles token generation, delivery, expiry, rate limiting, replay protection,
    and audit logging — so you don't have to.

    ## Authentication

    All endpoints require a Bearer token:

    ```
    Authorization: Bearer sk_your_api_key
    ```

    Get an API key by signing up at [otpmagiclink.com](https://otpmagiclink.com),
    creating a project, and generating a key from the project's Keys tab.

    ## Sandbox mode

    Create a sandbox project to capture all messages in the database instead
    of sending real emails or SMS. Use the sandbox inbox endpoints to retrieve
    tokens and magic links instantly — no email polling required.

    Sandbox projects are ideal for CI/CD pipelines and local development.

  contact:
    url: https://otpmagiclink.com
  license:
    name: Commercial

servers:
- url: http://localhost:7000
  description: Local development
- url: https://otp-magic-link-639440454128.us-central1.run.app
  description: Production

tags:
- name: Verifications
  description: Create and check OTP and magic link verifications
- name: Sandbox
  description: Sandbox inbox and clock control (sandbox projects only)
- name: Signals
  description: Pre-authentication fraud and risk signals

paths:

  # ─── Verifications ────────────────────────────────────────────────────────────

  /api/v1/verifications:
    post:
      operationId: createVerification
      summary: Create a verification
      description: |
        Issue an OTP or magic link for an email address or phone number.

        - **OTP**: generates a 6-digit code and sends it via email or SMS
        - **MAGIC_LINK**: generates a single-use URL sent via email (EMAIL channel only)

        For sandbox projects, messages are captured in the sandbox inbox instead
        of being delivered. Use `GET /api/v1/sandbox/inbox/:identifier` to retrieve them.
      tags: [ Verifications ]
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateVerificationRequest'
            examples:
              otp_email:
                summary: OTP via email
                value:
                  identifier: user@example.com
                  channel: EMAIL
                  kind: OTP
              magic_link:
                summary: Magic link via email
                value:
                  identifier: user@example.com
                  channel: EMAIL
                  kind: MAGIC_LINK
                  redirectUrl: https://yourapp.com/dashboard
              otp_sms:
                summary: OTP via SMS
                value:
                  identifier: "+15551234567"
                  channel: SMS
                  kind: OTP
      responses:
        '201':
          description: Verification created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateVerificationResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/QuotaExceeded'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/verifications/{id}:
    get:
      operationId: getVerification
      summary: Get verification status
      description: |
        Poll the status of a verification. Useful when you want to check
        whether a user has clicked a magic link without submitting a token.

        Automatically transitions `PENDING` to `EXPIRED` if the TTL has passed.
      tags: [ Verifications ]
      security:
      - bearerAuth: []
      parameters:
      - $ref: '#/components/parameters/verificationId'
      responses:
        '200':
          description: Verification object
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/verifications/{id}/check:
    post:
      operationId: checkVerification
      summary: Submit a token
      description: |
        Submit an OTP token to verify it. Returns `verified: true` on success.

        Failed attempts are counted. After `maxAttempts` failures the verification
        is permanently locked (status `FAILED`) and returns `410`.

        Returns `410` for expired or locked verifications.
        Returns `409` if the verification was already successfully verified.
      tags: [ Verifications ]
      security:
      - bearerAuth: []
      parameters:
      - $ref: '#/components/parameters/verificationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ token ]
              properties:
                token:
                  type: string
                  description: The OTP code submitted by the user
                  example: "482910"
      responses:
        '200':
          description: Token accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckVerificationResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already verified
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '410':
          description: Expired or locked (max attempts reached)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: Invalid token (wrong code) — attempt counted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckFailedResponse'

  # ─── Sandbox ─────────────────────────────────────────────────────────────────

  /api/v1/sandbox/inbox/{identifier}:
    get:
      operationId: getSandboxInbox
      summary: Get sandbox inbox
      description: |
        Returns all messages sent to an identifier in this sandbox project,
        newest first. Use this to retrieve OTP codes in tests without polling
        a real email inbox.

        Only available for sandbox projects.
      tags: [ Sandbox ]
      security:
      - bearerAuth: []
      parameters:
      - name: identifier
        in: path
        required: true
        schema:
          type: string
        description: Email address or phone number
        example: user@example.com
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
        description: Maximum number of messages to return
      responses:
        '200':
          description: Inbox messages
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SandboxInboxResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Not a sandbox project
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/sandbox/inbox/{identifier}/latest-link:
    get:
      operationId: getLatestMagicLink
      summary: Get latest magic link
      description: |
        Returns the most recent magic link sent to an identifier.
        Designed for Playwright and other E2E test frameworks — navigate
        directly to the URL without scraping a real inbox.

        Returns `404` if no magic link has been sent to this identifier yet.

        Only available for sandbox projects.
      tags: [ Sandbox ]
      security:
      - bearerAuth: []
      parameters:
      - name: identifier
        in: path
        required: true
        schema:
          type: string
        description: Email address
        example: user@example.com
      responses:
        '200':
          description: Latest magic link
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LatestLinkResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Not a sandbox project
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No magic link found for this identifier
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/v1/sandbox/clock:
    post:
      operationId: advanceSandboxClock
      summary: Advance sandbox clock
      description: |
        Advance the virtual clock for this sandbox project by the given number
        of seconds. Verification expiry checks use the virtual clock, allowing
        you to test TTL expiry without waiting.

        Maximum advance: 30 days (2,592,000 seconds).

        Only available for sandbox projects.
      tags: [ Sandbox ]
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ seconds ]
              properties:
                seconds:
                  type: integer
                  minimum: 1
                  maximum: 2592000
                  description: Number of seconds to advance the virtual clock
                  example: 660
      responses:
        '200':
          description: Clock advanced
          content:
            application/json:
              schema:
                type: object
                properties:
                  virtualNow:
                    type: string
                    format: date-time
                    description: The new virtual time as an ISO 8601 string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Not a sandbox project
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          $ref: '#/components/responses/ValidationError'

    delete:
      operationId: resetSandboxClock
      summary: Reset sandbox clock
      description: |
        Reset the virtual clock back to real time.

        Only available for sandbox projects.
      tags: [ Sandbox ]
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Clock reset
          content:
            application/json:
              schema:
                type: object
                properties:
                  reset:
                    type: boolean
                    example: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Not a sandbox project
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ─── Signals ─────────────────────────────────────────────────────────────────

  /api/v1/signals/precheck:
    post:
      operationId: precheckIdentifier
      summary: Pre-authentication risk check
      description: |
        Run fraud and risk signals against an identifier before issuing a
        verification. Returns a risk score (0–100) and a list of flags.

        Call this before `POST /api/v1/verifications` to block or challenge
        high-risk identifiers without consuming a verification from your quota.

        **Checks performed:**
        - Disposable email domain detection
        - Per-identifier velocity (20 requests / 10 min per project)
        - Per-IP global velocity (100 requests / 10 min)
      tags: [ Signals ]
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ identifier ]
              properties:
                identifier:
                  type: string
                  description: Email address or phone number to check
                  example: user@example.com
                channel:
                  type: string
                  enum: [ EMAIL, SMS ]
                  description: Optional channel hint for channel-specific checks
      responses:
        '200':
          description: Precheck result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PrecheckResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/QuotaExceeded'
        '422':
          $ref: '#/components/responses/ValidationError'

  # ─── Health ───────────────────────────────────────────────────────────────────

  /api/health:
    get:
      operationId: healthCheck
      summary: Health check
      description: Returns 200 if the API is reachable.
      tags: [ Verifications ]
      responses:
        '200':
          description: API is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

# ─── Components ────────────────────────────────────────────────────────────────

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: |
        API key from your project's Keys tab (prefix `sk_`).

        In Postman: set collection variable `apiKey` or use Authorization → Bearer Token.

  parameters:
    verificationId:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: Verification ID returned by POST /api/v1/verifications
      example: clx1abc23def456

  schemas:

    CreateVerificationRequest:
      type: object
      required: [ identifier, channel, kind ]
      properties:
        identifier:
          type: string
          maxLength: 255
          description: Email address or E.164 phone number
          example: user@example.com
        channel:
          type: string
          enum: [ EMAIL, SMS ]
          description: Delivery channel
        kind:
          type: string
          enum: [ OTP, MAGIC_LINK ]
          description: |
            Verification type.
            `MAGIC_LINK` is only supported for the `EMAIL` channel.
        redirectUrl:
          type: string
          format: uri
          description: |
            Where to redirect after a successful magic link click.
            Required for MAGIC_LINK if your project policy enforces an allowlist.
          example: https://yourapp.com/dashboard
        metadata:
          type: object
          additionalProperties: true
          description: Arbitrary key-value metadata stored with the verification

    CreateVerificationResponse:
      type: object
      properties:
        id:
          type: string
          description: Unique verification ID. Store this to call /check later.
          example: clx1abc23def456
        status:
          type: string
          enum: [ PENDING ]
        channel:
          type: string
          enum: [ EMAIL, SMS ]
        kind:
          type: string
          enum: [ OTP, MAGIC_LINK ]
        identifier:
          type: string
          example: user@example.com
        expiresAt:
          type: string
          format: date-time

    Verification:
      type: object
      properties:
        id:
          type: string
        status:
          type: string
          enum: [ PENDING, VERIFIED, EXPIRED, FAILED ]
        channel:
          type: string
          enum: [ EMAIL, SMS ]
        kind:
          type: string
          enum: [ OTP, MAGIC_LINK ]
        identifier:
          type: string
        attempts:
          type: integer
          description: Number of failed token submission attempts
        maxAttempts:
          type: integer
          description: Maximum allowed failed attempts before lockout
        expiresAt:
          type: string
          format: date-time
        verifiedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time

    CheckVerificationResponse:
      type: object
      properties:
        verified:
          type: boolean
          example: true
        verificationId:
          type: string

    CheckFailedResponse:
      type: object
      properties:
        error:
          type: string
          example: Invalid token
        attemptsRemaining:
          type: integer
          description: How many attempts remain before lockout

    SandboxInboxResponse:
      type: object
      properties:
        messages:
          type: array
          items:
            $ref: '#/components/schemas/SandboxMessage'

    SandboxMessage:
      type: object
      properties:
        id:
          type: string
        identifier:
          type: string
          example: user@example.com
        subject:
          type: string
          nullable: true
        otp:
          type: string
          nullable: true
          description: The 6-digit OTP code, if this was an OTP verification
          example: "482910"
        magicLink:
          type: string
          nullable: true
          description: The full magic link URL, if this was a magic link verification
          example: https://otp-magic-link.run.app/api/v1/verify/magic?token=...
        createdAt:
          type: string
          format: date-time

    LatestLinkResponse:
      type: object
      properties:
        id:
          type: string
        identifier:
          type: string
        magicLink:
          type: string
          description: Full magic link URL ready to navigate to in a browser or test runner
          example: https://otp-magic-link.run.app/api/v1/verify/magic?token=...
        otp:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time

    PrecheckResult:
      type: object
      properties:
        score:
          type: integer
          minimum: 0
          maximum: 100
          description: Risk score. Higher = more suspicious. Block at ≥ 80.
          example: 25
        flags:
          type: array
          items:
            type: string
            enum:
            - disposable_email
            - velocity_exceeded
            - global_velocity_exceeded
          description: List of risk signals that contributed to the score
        allowed:
          type: boolean
          description: False when score ≥ 80. Recommended to block or challenge.
          example: true

    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          description: Human-readable error message

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: Missing or malformed Authorization header

    QuotaExceeded:
      description: |
        Free trial quota exhausted. Upgrade to Pay as you go to continue.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
              usage:
                type: integer
              limit:
                type: integer

    RateLimited:
      description: Per-identifier rate limit exceeded
      headers:
        X-RateLimit-Limit:
          schema:
            type: integer
        X-RateLimit-Remaining:
          schema:
            type: integer
        X-RateLimit-Reset:
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

    ValidationError:
      description: Request body failed validation
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
              details:
                type: object

    NotFound:
      description: Verification not found or does not belong to this project
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
