openapi: 3.1.0
info:
  title: smartest.email integration for smartest.website
  version: '1.0.0'
  description: >-
    Organization-bound OAuth and double opt-in newsletter subscriptions. This
    contract is separate from the immediate-subscription public widget API.
    A confidential client registration and exact callback allowlist are required.
servers:
  - url: https://api.smartest.email
paths:
  /oauth/token:
    post:
      summary: Exchange an S256 authorization code or rotate a refresh token
      description: >-
        Authenticate with client_secret_post or HTTP Basic. Do not send credentials
        in a URL or use both authentication methods. The authorization screen is
        https://smartest.email/oauth/authorize with response_type=code, client_id,
        redirect_uri, scope, state, code_challenge and code_challenge_method=S256.
        The exact production callback is
        https://my.smartest.website/smartest-email/callback.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type]
              properties:
                grant_type: {type: string, enum: [authorization_code, refresh_token]}
                client_id: {type: string, description: Required unless supplied through HTTP Basic authentication.}
                client_secret: {type: string, format: password}
                code: {type: string, description: Required for authorization_code.}
                redirect_uri: {type: string, format: uri, description: Exact registered URI; required for authorization_code.}
                code_verifier: {type: string, minLength: 43, maxLength: 128, description: S256 PKCE verifier; required for authorization_code.}
                refresh_token: {type: string, description: Required for refresh_token.}
                scope: {type: string, description: Optional on refresh; must equal the original granted scopes.}
      responses:
        '200':
          description: Tokens issued; Cache-Control no-store and Pragma no-cache.
          content:
            application/json:
              schema:
                type: object
                required: [access_token, token_type, expires_in, refresh_token, scope]
                properties:
                  access_token: {type: string}
                  token_type: {type: string, const: Bearer}
                  expires_in: {type: integer, minimum: 1, maximum: 3600}
                  refresh_token: {type: string}
                  scope: {type: string, example: 'forms:read subscriptions:write'}
        '400': {$ref: '#/components/responses/OAuthFailure'}
        '401': {$ref: '#/components/responses/OAuthFailure'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '500': {$ref: '#/components/responses/OAuthFailure'}
  /oauth/revoke:
    post:
      summary: Revoke a token and its whole grant family
      description: >-
        Authenticate the confidential client as at /oauth/token. Unknown and
        already invalid tokens succeed without disclosing token validity.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token]
              properties:
                token: {type: string}
                token_type_hint: {type: string, enum: [refresh_token, access_token]}
                client_id: {type: string, description: Required unless supplied through HTTP Basic authentication.}
                client_secret: {type: string, format: password}
      responses:
        '200': {description: Grant revoked or token already invalid; non-cacheable.}
        '400': {$ref: '#/components/responses/OAuthFailure'}
        '401': {$ref: '#/components/responses/OAuthFailure'}
        '429': {$ref: '#/components/responses/RateLimited'}
  /api/v1/integrations/smartest-website/account:
    get:
      summary: Read the connected organization
      security: [{OAuth: ['forms:read']}]
      responses:
        '200':
          description: Stable organization identity, not the authorizing person's ID.
          content:
            application/json:
              schema:
                type: object
                required: [id, name]
                properties:
                  id: {type: string, format: uuid}
                  name: {type: string}
        '401': {$ref: '#/components/responses/IntegrationFailure'}
        '403': {$ref: '#/components/responses/IntegrationFailure'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '500': {$ref: '#/components/responses/IntegrationFailure'}
  /api/v1/integrations/smartest-website/forms:
    get:
      summary: List all eligible active double opt-in forms
      description: No pagination or public widget view increments. Field name casing is preserved.
      security: [{OAuth: ['forms:read']}]
      responses:
        '200':
          description: All eligible forms, or an empty array.
          content:
            application/json:
              schema:
                type: object
                required: [forms]
                properties:
                  forms:
                    type: array
                    items: {$ref: '#/components/schemas/Form'}
        '401': {$ref: '#/components/responses/IntegrationFailure'}
        '403': {$ref: '#/components/responses/IntegrationFailure'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '500': {$ref: '#/components/responses/IntegrationFailure'}
  /api/v1/integrations/smartest-website/forms/{id}/subscribe:
    post:
      summary: Durably accept a pending subscription and queue confirmation
      description: >-
        Existing and suppressed addresses receive the same generic acceptance.
        This operation never reactivates or retags an existing contact. An identical
        idempotent retry repeats the original acceptance without additional mail.
        Pending signups become subscribed only after explicit email confirmation.
      security: [{OAuth: ['subscriptions:write']}]
      parameters:
        - in: path
          name: id
          required: true
          schema: {type: string, format: uuid}
        - in: header
          name: Idempotency-Key
          required: true
          description: Durable key scoped to client, organization and grant. Changed payload or form conflicts.
          schema: {type: string, minLength: 1, maxLength: 255}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/Signup'}
      responses:
        '202':
          description: Durable acceptance, not completed subscription.
          content:
            application/json:
              schema:
                type: object
                required: [accepted]
                properties:
                  accepted: {type: boolean, const: true}
        '400': {$ref: '#/components/responses/IntegrationFailure'}
        '422': {$ref: '#/components/responses/IntegrationFailure'}
        '401': {$ref: '#/components/responses/IntegrationFailure'}
        '403': {$ref: '#/components/responses/IntegrationFailure'}
        '404': {$ref: '#/components/responses/IntegrationFailure'}
        '409': {$ref: '#/components/responses/IntegrationFailure'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '500': {$ref: '#/components/responses/IntegrationFailure'}
        '503': {$ref: '#/components/responses/IntegrationFailure'}
  /api/v1/public/subscriptions/confirm:
    post:
      summary: Confirm from the reader's explicit confirmation-page action
      description: >-
        The email links to /subscribe/confirm#token=... on smartest.email.
        GET requests never confirm a subscription. Completed confirmations are
        idempotent. Suppression and authorization are rechecked before activation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [token]
              properties:
                token: {type: string, minLength: 43, maxLength: 43}
      responses:
        '200':
          description: Generic completed outcome, without disclosing address or subscription status.
          content:
            application/json:
              schema:
                type: object
                required: [confirmed]
                properties:
                  confirmed: {type: boolean, const: true}
        '400': {$ref: '#/components/responses/IntegrationFailure'}
        '409': {$ref: '#/components/responses/IntegrationFailure'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '500': {$ref: '#/components/responses/IntegrationFailure'}
components:
  securitySchemes:
    OAuth:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://smartest.email/oauth/authorize
          tokenUrl: https://api.smartest.email/oauth/token
          refreshUrl: https://api.smartest.email/oauth/token
          scopes:
            forms:read: Read the connected organization and eligible signup forms.
            subscriptions:write: Accept newsletter signups pending email confirmation.
  schemas:
    Form:
      type: object
      required: [id, name, fields, consent_text, privacy_url, double_opt_in]
      properties:
        id: {type: string, format: uuid}
        name: {type: string}
        fields:
          type: array
          items:
            type: object
            required: [name, label, type, required]
            properties:
              name: {type: string, description: Exact accepted data key, including casing.}
              label: {type: string}
              placeholder: {type: string}
              defaultValue: {type: string}
              type: {type: string, enum: [text, email, phone, number, textarea, checkbox, date, hidden, select, radio]}
              required: {type: boolean}
              options: {type: array, items: {type: string}}
              validation:
                type: object
                properties:
                  pattern: {type: string}
                  minLength: {type: integer, minimum: 0}
                  maxLength: {type: integer, minimum: 0}
                  min: {type: number}
                  max: {type: number}
                  message: {type: string}
        consent_text: {type: string, description: Trimmed current required consent field label.}
        privacy_url: {type: string}
        double_opt_in: {type: boolean, const: true}
    Signup:
      type: object
      additionalProperties: false
      required: [data, consent, source, double_opt_in]
      properties:
        data:
          type: object
          description: Only declared fields; consent is excluded. Values must match current form validation.
          required: [email]
          properties:
            email: {type: string, format: email}
          additionalProperties:
            type: [string, boolean]
        consent:
          type: object
          additionalProperties: false
          required: [granted, text, occurred_at]
          properties:
            granted: {type: boolean, const: true}
            text: {type: string, description: Must match the trimmed current consent field label.}
            occurred_at: {type: string, format: date-time, description: Original RFC3339 consent time; queued deliveries may arrive later.}
        source:
          type: object
          additionalProperties: false
          required: [site_id, form_id, submission_id]
          properties:
            site_id: {type: string, format: uuid}
            form_id: {type: string, format: uuid}
            submission_id: {type: string, pattern: '^[0-9]{1,40}$'}
        double_opt_in: {type: boolean, const: true}
    Error:
      type: object
      required: [error, code]
      properties:
        error: {type: string}
        code: {type: string}
  responses:
    IntegrationFailure:
      description: >-
        Structured failure. Retry 5xx with the same idempotency key and unchanged
        payload. For 401, refresh an expired access token once; reconnect if refresh
        fails or authorization was revoked. Respect Retry-After for 429. Correct
        validation, scope, form-availability or conflict failures before retrying
        other 4xx responses.
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    OAuthFailure:
      description: OAuth error response; credentials are never echoed.
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error: {type: string}
              error_description: {type: string}
    RateLimited:
      description: Too many requests. Retry after the indicated delay.
      headers:
        Retry-After:
          schema: {type: string}
