openapi: 3.1.0
info:
  title: smartest.email integration for WordPress
  version: '0.1.0'
  description: >-
    Connect an individual WordPress installation to one workspace using an
    explicitly approved public OAuth client and PKCE S256. This is separate from
    smartest.website client registration and the public single opt-in widget API.
    The plugin keeps tokens on the WordPress server. No shared client secret or
    API key is required. Use the hosted approval page at
    https://smartest.email/oauth/wordpress with redirect_uri, state,
    code_challenge and code_challenge_method=S256. It signs in the account holder,
    displays the destination site and requires explicit workspace approval.
    Tokens cannot create campaigns or read the contact list. The platform checks
    the authorizing user's current status and workspace permissions on each use.
servers:
  - url: https://api.smartest.email
paths:
  /api/v1/wordpress/authorize:
    get:
      summary: Validate a WordPress connection request for the approval page
      description: >-
        Requires an account session, not an integration access token. Reading
        this endpoint creates no client or connection. Only workspaces where
        the account has settings:edit, form:view and contact:create are eligible.
        Unknown or duplicate query parameters are rejected. The production
        callback must use HTTPS and end with /wp-admin/admin-post.php with
        the sole query parameter action=smartest_email_callback. Subdirectory
        installations are supported. Wildcards, fragments, credentials and
        arbitrary callback query parameters are rejected. HTTP loopback is
        available only when the server runs in development or test mode.
      security: [{AccountSession: []}]
      parameters:
        - in: query
          name: redirect_uri
          required: true
          schema: {type: string, format: uri}
        - in: query
          name: state
          required: true
          schema: {type: string}
          description: Fresh unpredictable state bound to the initiating WordPress administrator session.
        - in: query
          name: code_challenge
          required: true
          schema: {type: string, minLength: 43, maxLength: 43}
        - in: query
          name: code_challenge_method
          required: true
          schema: {type: string, const: S256}
      responses:
        '200':
          description: Valid request and eligible workspaces; no authorization has been granted.
          content:
            application/json:
              schema:
                type: object
                required: [client, site_origin, site_host, redirect_uri, scope, scopes, organizations]
                properties:
                  client:
                    type: object
                    properties:
                      name: {type: string, const: WordPress}
                  site_origin: {type: string, format: uri}
                  site_host: {type: string}
                  redirect_uri: {type: string, format: uri}
                  scope: {type: string, const: 'forms:read subscriptions:write'}
                  scopes: {type: array, items: {type: string, enum: ['forms:read', 'subscriptions:write']}}
                  organizations:
                    type: array
                    items:
                      type: object
                      required: [id, name]
                      properties:
                        id: {type: string, format: uuid}
                        name: {type: string}
        '400': {$ref: '#/components/responses/OAuthFailure'}
        '401': {description: Account session is missing, invalid, expired or revoked.}
        '403': {$ref: '#/components/responses/OAuthFailure'}
    post:
      summary: Explicitly approve or decline a WordPress site connection
      description: >-
        Approval registers a public client for this installation, binds a
        short-lived authorization code to the exact callback and S256 challenge,
        and returns a redirect_url containing client_id, code and unchanged state.
        Declining returns error=access_denied and unchanged state without
        registering a client. Invalid requests never return a redirect URL.
        Approval requires settings:edit, form:view and contact:create in the
        selected workspace. A maximum of 25 live WordPress connections per
        workspace is enforced (409 connection_limit); revoke an unused connection
        before adding another. No server administrator permission bypass applies.
      security: [{AccountSession: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [redirect_uri, state, code_challenge, code_challenge_method, approved]
              properties:
                redirect_uri: {type: string, format: uri}
                state: {type: string}
                code_challenge: {type: string, minLength: 43, maxLength: 43}
                code_challenge_method: {type: string, const: S256}
                approved: {type: boolean}
                organization_id: {type: string, format: uuid, description: Required when approved is true.}
      responses:
        '200':
          description: Redirect to the validated WordPress callback; it must validate state before exchanging a code.
          content:
            application/json:
              schema:
                type: object
                required: [redirect_url]
                properties:
                  redirect_url: {type: string, format: uri}
        '400': {$ref: '#/components/responses/OAuthFailure'}
        '401': {description: Account session is missing, invalid, expired or revoked.}
        '403': {$ref: '#/components/responses/OAuthFailure'}
        '409': {$ref: '#/components/responses/OAuthFailure'}
        '429': {$ref: '#/components/responses/RateLimited'}
        '500': {$ref: '#/components/responses/OAuthFailure'}
  /oauth/token:
    post:
      summary: Exchange an S256 authorization code or rotate a refresh token
      description: >-
        WordPress uses a public client. Supply client_id in the form body and
        do not send client_secret or HTTP Basic authentication. Exchange codes
        server-side within five minutes using the original PKCE verifier and
        exact callback. Codes are single-use. Access tokens last up to one hour;
        refresh tokens last up to 30 days, with an absolute grant lifetime of
        90 days. Serialize refreshes, save each new token pair atomically and
        discard the old refresh token. Reuse of a rotated token revokes the
        whole grant family. A failed refresh requires reconnecting. Never put
        tokens or the PKCE verifier in browser bundles, URLs or logs.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id]
              properties:
                grant_type: {type: string, enum: [authorization_code, refresh_token]}
                client_id: {type: string}
                code: {type: string, description: Required for authorization_code.}
                redirect_uri: {type: string, format: uri, description: Required for authorization_code; exact approved callback.}
                code_verifier: {type: string, minLength: 43, maxLength: 128, description: Required for authorization_code.}
                refresh_token: {type: string, description: Required for refresh_token.}
                scope: {type: string, description: Optional on refresh; must equal the originally granted scopes.}
      responses:
        '200':
          description: New token pair; Cache-Control no-store and Pragma no-cache.
          content:
            application/json:
              schema:
                type: object
                required: [access_token, refresh_token, token_type, expires_in, scope]
                properties:
                  access_token: {type: string}
                  refresh_token: {type: string}
                  token_type: {type: string, const: Bearer}
                  expires_in: {type: integer, minimum: 1, maximum: 3600}
                  scope: {type: string, const: '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 the WordPress connection and its token family
      description: >-
        Supply client_id and token in the form body without client_secret or
        HTTP Basic authentication. Unknown or already revoked tokens return
        success without disclosing validity when client_id identifies a known
        registered client. An unknown client_id returns 401. Users can also revoke the connection
        in smartest.email Settings → Connected apps. Revocation prevents new
        authenticated submissions and refreshes, but does not unsubscribe contacts.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [client_id, token]
              properties:
                client_id: {type: string}
                token: {type: string}
                token_type_hint: {type: string, enum: [refresh_token, access_token]}
      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/wordpress/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/wordpress/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/wordpress/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:
    AccountSession:
      type: http
      scheme: bearer
      bearerFormat: JWT
    OAuth:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://smartest.email/oauth/wordpress
          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}
