openapi: 3.1.0
info:
  title: FlashSpread Tenant Provisioning API
  version: 1.0.0
  description: |
    Machine-readable specification for partner-driven tenant provisioning.

    **Inbound authentication:** OAuth 2.0 Client Credentials (`POST /api/v1/oauth/token`).

    **Provisioning:** Asynchronous tenant creation via `POST /api/v1/provisions/tenants`.
    The API returns `202 Accepted` immediately and delivers a signed webhook to `callback_url`
    when provisioning completes or fails.

    **Outbound webhook authentication:** HMAC-SHA256 using the partner `client_secret`
    (`PartnerCredential.client_shared`).

    Verified against `fs-backend` implementation (staging: `https://staging-api.flashspreadtest.com`).
  contact:
    name: FlashSpread API Support
  license:
    name: Proprietary

servers:
  - url: https://staging-api.flashspreadtest.com
    description: Staging
  - url: http://localhost:3000
    description: Local development

tags:
  - name: OAuth
    description: Client credentials token issuance
  - name: Provisioning
    description: Tenant provisioning requests
  - name: Webhooks
    description: Outbound callbacks delivered to partner `callback_url`

paths:
  /api/v1/oauth/token:
    post:
      tags: [OAuth]
      operationId: issueOAuthToken
      summary: Issue an OAuth 2.0 access token
      description: |
        Exchange partner `client_id` and `client_secret` for a short-lived Bearer token.
        Only `grant_type=client_credentials` is supported. Tokens expire after 1800 seconds (30 minutes)
        and are scoped to `provisions`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OAuthTokenRequest'
            examples:
              clientCredentials:
                summary: Client credentials request
                value:
                  grant_type: client_credentials
                  client_id: partner-client-id
                  client_secret: partner-client-secret
      responses:
        '200':
          description: Access token issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthTokenResponse'
              examples:
                success:
                  value:
                    access_token: eyJhbGciOiJIUzI1NiJ9.example
                    token_type: Bearer
                    expires_in: 1800
                    scope: provisions
        '401':
          description: Invalid or inactive client credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalidCredentials:
                  summary: Invalid client_id or client_secret
                  value:
                    errors:
                      - message: Invalid OAuth2 client credentials
                inactiveClient:
                  summary: Deactivated partner credential
                  value:
                    errors:
                      - message: Invalid OAuth2 client credentials
        '422':
          description: Unsupported or missing grant_type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                unsupportedGrantType:
                  value:
                    errors:
                      - message: grant_type must be client_credentials

  /api/v1/provisions/tenants:
    post:
      tags: [Provisioning]
      operationId: provisionTenant
      summary: Request asynchronous tenant provisioning
      description: |
        Enqueues tenant provisioning for a child organization under an existing parent tenant.

        **Required headers**
        - `Authorization: Bearer <access_token>` — token from `POST /api/v1/oauth/token`
        - `Idempotency-Key: <UUID>` — unique per logical provisioning attempt; replays with the same key
          return `202 Accepted` without creating duplicate records

        **Idempotency**
        Re-submitting the same `Idempotency-Key` is safe and returns the same `202` response shape.
        The server does not enqueue a second provisioning job for the same key.

        **Rate limiting**
        Limited to 10 requests per minute per OAuth `client_id` on all `/api/v1/provisions/*` routes.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProvisionTenantRequest'
            examples:
              fullRequest:
                summary: Full provisioning request
                value:
                  name: Example Bank
                  external_id: 550e8400-e29b-41d4-a716-446655440000
                  parent_guid: parent-tenant-identifier
                  callback_url: https://partner.example.com/v1/flashspread/provision
                  status: active
              minimalRequest:
                summary: Required fields only (external_id and status omitted)
                value:
                  name: Example Bank
                  parent_guid: parent-tenant-identifier
                  callback_url: https://partner.example.com/v1/flashspread/provision
              nestedTenantParams:
                summary: Nested tenant object (Rails wrap_parameters)
                value:
                  tenant:
                    name: Example Bank
                    external_id: 550e8400-e29b-41d4-a716-446655440000
                    parent_guid: parent-tenant-identifier
                    callback_url: https://partner.example.com/v1/flashspread/provision
                    status: active
      responses:
        '202':
          description: Provisioning request accepted for asynchronous processing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProvisionTenantAcceptedResponse'
              examples:
                accepted:
                  value:
                    status: accepted
                    idempotency_key: 456783ff-f7ad-4999-9220-591664f0bab1
                idempotentReplay:
                  summary: Duplicate Idempotency-Key (safe replay)
                  value:
                    status: accepted
                    idempotency_key: 456783ff-f7ad-4999-9220-591664f0bab1
        '401':
          description: Missing, invalid, or expired Bearer token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missingToken:
                  value:
                    errors:
                      - message: You need to sign in or sign up before continuing.
                invalidToken:
                  value:
                    errors:
                      - message: You need to sign in or sign up before continuing.
        '409':
          description: |
            Conflict — reserved for duplicate partner+tenant combinations.

            **Implementation note (staging):** Duplicate `Idempotency-Key` values currently return
            `202 Accepted` (idempotent replay) rather than `409`. This response is documented for
            partner contract completeness and may be enforced in a future release.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                duplicatePartnerTenant:
                  value:
                    errors:
                      - message: A tenant with this external_id already exists for the partner
        '422':
          description: Missing or invalid request fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missingIdempotencyKey:
                  value:
                    errors:
                      - field: Idempotency-Key
                        message: Idempotency-Key can't be blank
                unknownParentGuid:
                  value:
                    errors:
                      - field: parent_guid
                        message: Tenant's parent is not found
        '429':
          description: Rate limit exceeded (10 requests per minute per client_id)
          headers:
            Retry-After:
              description: Seconds until the client may retry
              schema:
                type: integer
                example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                rateLimited:
                  value:
                    errors:
                      - message: Rate limit exceeded. Try again later.

webhooks:
  provisioningCallback:
    post:
      tags: [Webhooks]
      operationId: provisioningCallback
      summary: Outbound provisioning result callback
      description: |
        FlashSpread POSTs this payload to the `callback_url` supplied in the provisioning request
        after tenant creation completes or fails. Delivery is retried up to 5 times with a 15-second
        HTTP timeout per attempt.

        **Verify the callback**
        1. Read `X-FlashSpread-Timestamp` (Unix epoch seconds, string).
        2. Read the raw request body bytes as `raw_body` (do not parse and re-serialize JSON).
        3. Build `string_to_sign = "{request_id}.{X-FlashSpread-Timestamp}.{raw_body}"`
           where `request_id` in the payload equals the original `Idempotency-Key`.
        4. Compute `HMAC_SHA256(client_secret, string_to_sign)` as lowercase hex.
        5. Compare to `X-FlashSpread-Signature` using constant-time comparison.

        `client_secret` is the same value used to obtain the OAuth token (`PartnerCredential.client_shared`).
      parameters:
        - name: X-FlashSpread-Signature
          in: header
          required: true
          schema:
            type: string
          description: Lowercase hex HMAC-SHA256 digest of `string_to_sign`
        - name: X-FlashSpread-Timestamp
          in: header
          required: true
          schema:
            type: string
          description: Unix timestamp (seconds) used as the second segment of `string_to_sign`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProvisioningCallbackPayload'
            examples:
              succeeded:
                summary: Provisioning succeeded
                value:
                  request_id: 456783ff-f7ad-4999-9220-591664f0bab1
                  external_id: 550e8400-e29b-41d4-a716-446655440000
                  organization:
                    id: provisioned-tenant-identifier
                    name: Example Bank
                    status: active
                  securityKey: abc123security
                  status: succeeded
              failed:
                summary: Provisioning failed
                value:
                  request_id: 456783ff-f7ad-4999-9220-591664f0bab1
                  external_id: null
                  organization: null
                  securityKey: ''
                  status: failed
      responses:
        '200':
          description: Partner acknowledged the callback (any 2xx stops retries)

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        OAuth 2.0 access token from `POST /api/v1/oauth/token`.
        Include as `Authorization: Bearer <access_token>`.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        UUID that uniquely identifies this provisioning attempt. Reuse the same value to safely
        retry without creating duplicate tenants or handshakes.
      schema:
        type: string
        format: uuid
      example: 456783ff-f7ad-4999-9220-591664f0bab1

  schemas:
    OAuthTokenRequest:
      type: object
      required: [grant_type, client_id, client_secret]
      additionalProperties: false
      properties:
        grant_type:
          type: string
          enum: [client_credentials]
          description: Must be `client_credentials`
        client_id:
          type: string
          description: Partner credential identifier issued by FlashSpread
        client_secret:
          type: string
          description: Partner shared secret (`PartnerCredential.client_shared`)
          format: password

    OAuthTokenResponse:
      type: object
      required: [access_token, token_type, expires_in, scope]
      additionalProperties: false
      properties:
        access_token:
          type: string
          description: JWT Bearer token
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          description: Token lifetime in seconds
          example: 1800
        scope:
          type: string
          enum: [provisions]

    ProvisionTenantRequest:
      oneOf:
        - $ref: '#/components/schemas/ProvisionTenantFlatRequest'
        - $ref: '#/components/schemas/ProvisionTenantNestedRequest'

    ProvisionTenantFlatRequest:
      type: object
      required: [name, parent_guid, callback_url]
      properties:
        name:
          type: string
          description: Display name for the provisioned tenant
          example: Example Bank
        external_id:
          type: [string, 'null']
          description: |
            Partner-supplied tenant identifier. When omitted or null, the server assigns one.
            Used as the planned tenant `identifier` when provided.
          example: 550e8400-e29b-41d4-a716-446655440000
        parent_guid:
          type: string
          description: |
            `identifier` of an existing parent tenant in FlashSpread.
            Must match a tenant record in the partner's hierarchy.
          example: parent-tenant-identifier
        callback_url:
          type: string
          format: uri
          description: HTTPS endpoint that receives the provisioning result webhook
          example: https://partner.example.com/v1/flashspread/provision
        status:
          type: string
          enum: [active, inactive]
          default: active
          description: Desired tenant status. Defaults to `active` when omitted.

    ProvisionTenantNestedRequest:
      type: object
      required: [tenant]
      properties:
        tenant:
          type: object
          required: [name, parent_guid, callback_url]
          description: |
            Nested object accepted via Rails `wrap_parameters`.
            Fields mirror the flat request properties.
          properties:
            name:
              type: string
            external_id:
              type: [string, 'null']
            parent_guid:
              type: string
            callback_url:
              type: string
              format: uri
            status:
              type: string
              enum: [active, inactive]

    ProvisionTenantAcceptedResponse:
      type: object
      required: [status, idempotency_key]
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [accepted]
        idempotency_key:
          type: string
          format: uuid
          description: Echo of the `Idempotency-Key` request header

    ProvisioningCallbackPayload:
      type: object
      required: [request_id, status]
      properties:
        request_id:
          type: string
          format: uuid
          description: Same value as the provisioning request `Idempotency-Key`
        external_id:
          type: [string, 'null']
          description: Partner `external_id` from the provisioning request, or null on failure
        organization:
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/ProvisioningOrganization'
          description: Provisioned tenant details; null when `status` is `failed`
        securityKey:
          type: string
          description: |
            API security key for the provisioned tenant admin user on success.
            Empty string when `status` is `failed`.
            Value depends on environment (production or staging credentials).
        status:
          type: string
          enum: [succeeded, failed]

    ProvisioningOrganization:
      type: object
      required: [id, name, status]
      properties:
        id:
          type: string
          description: FlashSpread tenant `identifier`
        name:
          type: string
        status:
          type: string
          enum: [active, inactive]

    ErrorResponse:
      type: object
      required: [errors]
      properties:
        errors:
          type: array
          items:
            $ref: '#/components/schemas/ErrorItem'

    ErrorItem:
      type: object
      required: [message]
      properties:
        field:
          type: string
          description: Present for validation errors (HTTP 422)
        message:
          type: string
