docs/api/openapi.yaml

openapi: 3.0.3
info:
  title: DunningDog API
  version: 1.0.0
  description: Stable v1 API contracts for DunningDog MVP.
  license:
    name: Proprietary
    url: https://dunningdog.com/legal/api-license
x-doc-owner: Founding Engineer
x-doc-status: Draft v1
x-doc-last-reviewed: 2026-02-17
x-doc-linked-adrs:
  - ../adr/ADR-0001-tech-stack.md
  - ../adr/ADR-0002-multi-tenant-model.md
  - ../adr/ADR-0003-job-orchestration.md
x-doc-linked-api-references:
  - ./webhook-contracts.md
  - ./error-model.md
servers:
  - url: https://api.dunningdog.com
    description: Production
  - url: https://api-preview.dunningdog.com
    description: Preview
security:
  - bearerAuth: []
paths:
  /api/stripe/connect/start:
    post:
      summary: Start Stripe OAuth connect flow
      operationId: startStripeConnect
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspaceId]
              properties:
                workspaceId:
                  type: string
      responses:
        "200":
          description: OAuth redirect URL generated
          content:
            application/json:
              schema:
                type: object
                required: [redirectUrl, state]
                properties:
                  redirectUrl:
                    type: string
                    format: uri
                  state:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequestProblem"
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        "403":
          $ref: "#/components/responses/ForbiddenProblem"
        "429":
          $ref: "#/components/responses/TooManyRequestsProblem"
        default:
          $ref: "#/components/responses/ProblemResponse"

  /api/stripe/connect/callback:
    get:
      summary: Handle Stripe OAuth callback
      operationId: stripeConnectCallback
      parameters:
        - name: code
          in: query
          required: true
          schema:
            type: string
        - name: state
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Stripe account connected
          content:
            application/json:
              schema:
                type: object
                required: [connectedAccount, workspace]
                properties:
                  connectedAccount:
                    $ref: "#/components/schemas/ConnectedStripeAccount"
                  workspace:
                    $ref: "#/components/schemas/Workspace"
        "400":
          $ref: "#/components/responses/BadRequestProblem"
        default:
          $ref: "#/components/responses/ProblemResponse"

  /api/webhooks/stripe:
    post:
      summary: Receive Stripe webhook events
      operationId: receiveStripeWebhook
      security: []
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Raw Stripe event payload
      responses:
        "200":
          description: Event accepted or duplicate safely ignored
          content:
            application/json:
              schema:
                type: object
                required: [received, eventId, duplicate]
                properties:
                  received:
                    type: boolean
                  eventId:
                    type: string
                  duplicate:
                    type: boolean
        "400":
          $ref: "#/components/responses/BadRequestProblem"
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        "403":
          $ref: "#/components/responses/ForbiddenProblem"
        "429":
          $ref: "#/components/responses/TooManyRequestsProblem"
        default:
          $ref: "#/components/responses/ProblemResponse"

  /api/dashboard/summary:
    get:
      summary: Get dashboard summary metrics
      operationId: getDashboardSummary
      parameters:
        - name: workspaceId
          in: query
          required: true
          schema:
            type: string
        - name: window
          in: query
          required: false
          schema:
            type: string
            enum: [7d, 30d, 90d, month, lifetime]
            default: month
      responses:
        "200":
          description: Dashboard summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DashboardSummary"
        "400":
          $ref: "#/components/responses/BadRequestProblem"
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        "403":
          $ref: "#/components/responses/ForbiddenProblem"
        "429":
          $ref: "#/components/responses/TooManyRequestsProblem"
        default:
          $ref: "#/components/responses/ProblemResponse"

  /api/dashboard/recoveries:
    get:
      summary: List recovery attempts
      operationId: listRecoveries
      parameters:
        - name: workspaceId
          in: query
          required: true
          schema:
            type: string
        - name: status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/RecoveryStatus"
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: cursor
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Paginated recovery attempts
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/RecoveryAttemptWithOutcome"
                  nextCursor:
                    type: string
                    nullable: true
        "400":
          $ref: "#/components/responses/BadRequestProblem"
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        default:
          $ref: "#/components/responses/ProblemResponse"

  /api/dunning/sequences:
    post:
      summary: Create dunning sequence
      operationId: createDunningSequence
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateSequenceRequest"
      responses:
        "201":
          description: Sequence created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DunningSequence"
        "400":
          $ref: "#/components/responses/BadRequestProblem"
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        default:
          $ref: "#/components/responses/ProblemResponse"

  /api/dunning/sequences/{id}:
    patch:
      summary: Update dunning sequence
      operationId: updateDunningSequence
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateSequenceRequest"
      responses:
        "200":
          description: Sequence updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DunningSequence"
        "400":
          $ref: "#/components/responses/BadRequestProblem"
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        "404":
          $ref: "#/components/responses/NotFoundProblem"
        "429":
          $ref: "#/components/responses/TooManyRequestsProblem"
        default:
          $ref: "#/components/responses/ProblemResponse"

  /api/customer/update-payment-session:
    post:
      summary: Create hosted payment update session
      operationId: createUpdatePaymentSession
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recoveryToken]
              properties:
                recoveryToken:
                  type: string
      responses:
        "200":
          description: Session created
          content:
            application/json:
              schema:
                type: object
                required: [sessionUrl, expiresAt]
                properties:
                  sessionUrl:
                    type: string
                    format: uri
                  expiresAt:
                    type: string
                    format: date-time
        "400":
          $ref: "#/components/responses/BadRequestProblem"
        "404":
          $ref: "#/components/responses/NotFoundProblem"
        default:
          $ref: "#/components/responses/ProblemResponse"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  responses:
    ProblemResponse:
      description: Error response in Problem+JSON format
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    BadRequestProblem:
      description: Bad request
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    UnauthorizedProblem:
      description: Unauthorized
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    ForbiddenProblem:
      description: Forbidden
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    NotFoundProblem:
      description: Not found
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    TooManyRequestsProblem:
      description: Rate limited
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"

  schemas:
    Problem:
      type: object
      required: [type, title, status, code, traceId]
      properties:
        type:
          type: string
          format: uri
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string
        code:
          type: string
        traceId:
          type: string
        meta:
          type: object
          additionalProperties: true

    DeclineType:
      type: string
      enum: [soft, hard]

    RecoveryStatus:
      type: string
      enum: [pending, recovered, failed, abandoned]

    DunningStepStatus:
      type: string
      enum: [scheduled, sent, opened, clicked, converted]

    Workspace:
      type: object
      required: [id, name, ownerUserId, timezone, billingPlan, isActive, createdAt, updatedAt]
      properties:
        id:
          type: string
        name:
          type: string
        ownerUserId:
          type: string
        timezone:
          type: string
        billingPlan:
          type: string
          enum: [starter, pro, growth]
        isActive:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    ConnectedStripeAccount:
      type: object
      required: [id, workspaceId, stripeAccountId, livemode, scopes, connectedAt]
      properties:
        id:
          type: string
        workspaceId:
          type: string
        stripeAccountId:
          type: string
        livemode:
          type: boolean
        scopes:
          type: array
          items:
            type: string
        connectedAt:
          type: string
          format: date-time
        disconnectedAt:
          type: string
          format: date-time
          nullable: true

    SubscriptionAtRisk:
      type: object
      required:
        [id, workspaceId, stripeCustomerId, stripeSubscriptionId, reason, riskDetectedAt]
      properties:
        id:
          type: string
        workspaceId:
          type: string
        stripeCustomerId:
          type: string
        stripeSubscriptionId:
          type: string
        reason:
          type: string
          enum: [card_expiring, payment_failed]
        riskDetectedAt:
          type: string
          format: date-time
        expirationDate:
          type: string
          format: date-time
          nullable: true
        activeRecoveryAttemptId:
          type: string
          nullable: true

    DunningSequenceStep:
      type: object
      required: [id, delayHours, subjectTemplate, bodyTemplate, status]
      properties:
        id:
          type: string
        delayHours:
          type: integer
          minimum: 0
        subjectTemplate:
          type: string
        bodyTemplate:
          type: string
        status:
          $ref: "#/components/schemas/DunningStepStatus"

    DunningSequence:
      type: object
      required: [id, workspaceId, name, isEnabled, steps, createdAt, updatedAt]
      properties:
        id:
          type: string
        workspaceId:
          type: string
        name:
          type: string
        isEnabled:
          type: boolean
        steps:
          type: array
          items:
            $ref: "#/components/schemas/DunningSequenceStep"
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    RecoveryAttempt:
      type: object
      required:
        [id, workspaceId, stripeInvoiceId, stripeCustomerId, declineType, status, amountDueCents, startedAt]
      properties:
        id:
          type: string
        workspaceId:
          type: string
        stripeInvoiceId:
          type: string
        stripeCustomerId:
          type: string
        declineType:
          $ref: "#/components/schemas/DeclineType"
        status:
          $ref: "#/components/schemas/RecoveryStatus"
        amountDueCents:
          type: integer
        recoveredAmountCents:
          type: integer
          nullable: true
        startedAt:
          type: string
          format: date-time
        endedAt:
          type: string
          format: date-time
          nullable: true

    RecoveryOutcome:
      type: object
      required: [id, workspaceId, recoveryAttemptId, outcome, occurredAt]
      properties:
        id:
          type: string
        workspaceId:
          type: string
        recoveryAttemptId:
          type: string
        outcome:
          type: string
          enum: [recovered, failed, abandoned]
        reasonCode:
          type: string
          nullable: true
        occurredAt:
          type: string
          format: date-time

    MetricSnapshot:
      type: object
      required:
        [id, workspaceId, periodStart, periodEnd, failedRevenueCents, recoveredRevenueCents, recoveryRate, atRiskCount, generatedAt]
      properties:
        id:
          type: string
        workspaceId:
          type: string
        periodStart:
          type: string
          format: date-time
        periodEnd:
          type: string
          format: date-time
        failedRevenueCents:
          type: integer
        recoveredRevenueCents:
          type: integer
        recoveryRate:
          type: number
          format: float
        atRiskCount:
          type: integer
        generatedAt:
          type: string
          format: date-time

    DashboardSummary:
      type: object
      required: [workspaceId, window, failedRevenueCents, recoveredRevenueCents, recoveryRate, atRiskCount, activeSequences, generatedAt]
      properties:
        workspaceId:
          type: string
        window:
          type: string
        failedRevenueCents:
          type: integer
        recoveredRevenueCents:
          type: integer
        recoveryRate:
          type: number
          format: float
        atRiskCount:
          type: integer
        activeSequences:
          type: integer
        generatedAt:
          type: string
          format: date-time
        latestSnapshot:
          $ref: "#/components/schemas/MetricSnapshot"
        atRiskPreview:
          type: array
          items:
            $ref: "#/components/schemas/SubscriptionAtRisk"

    RecoveryAttemptWithOutcome:
      type: object
      required: [attempt]
      properties:
        attempt:
          $ref: "#/components/schemas/RecoveryAttempt"
        latestOutcome:
          type: object
          nullable: true
          allOf:
            - $ref: "#/components/schemas/RecoveryOutcome"

    CreateSequenceRequest:
      type: object
      required: [workspaceId, name, isEnabled, steps]
      properties:
        workspaceId:
          type: string
        name:
          type: string
        isEnabled:
          type: boolean
        steps:
          type: array
          minItems: 1
          items:
            type: object
            required: [delayHours, subjectTemplate, bodyTemplate]
            properties:
              delayHours:
                type: integer
                minimum: 0
              subjectTemplate:
                type: string
              bodyTemplate:
                type: string

    UpdateSequenceRequest:
      type: object
      properties:
        name:
          type: string
        isEnabled:
          type: boolean
        steps:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              delayHours:
                type: integer
                minimum: 0
              subjectTemplate:
                type: string
              bodyTemplate:
                type: string