openapi: 3.1.0
info:
  title: TRIGR Partner API
  version: "1.0.0"
  summary: Invite-only Partner API for competition matches, participants, and results.
  description: |
    Machine-to-machine API for authorized TRIGR partners. Credentials are staff-minted.
    Scopes alone are not enough: match access requires a Partner→Match grant; private
    shooter profiles require an explicit Partner→Shooter grant (not implied by match data).

    Sandbox keys (`trigr_test_*`) authenticate only on the sandbox host.
    Production keys (`trigr_live_*`) authenticate only on the production host.
  contact:
    name: TRIGR Partnerships
    url: https://www.trigr.app/developers
  license:
    name: Partner API Terms
    url: https://www.trigr.app/developers/terms

servers:
  - url: https://cibqb6zaym.us-east-2.awsapprunner.com/api
    description: Sandbox (develop Neon). Use trigr_test_* credentials only.
  - url: https://68rbjwzgy2.us-east-2.awsapprunner.com/api
    description: Production. Use trigr_live_* credentials only.

tags:
  - name: Meta
  - name: Matches
  - name: Participants
  - name: Results
  - name: Shooters
  - name: Webhooks

paths:
  /v1/me:
    get:
      tags: [Meta]
      summary: Credential context
      operationId: getPartnerMe
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Authenticated partner context
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PartnerMe"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/scopes:
    get:
      tags: [Meta]
      summary: Enabled scopes for this credential
      operationId: getPartnerScopes
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Scopes and catalog
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/matches:
    get:
      tags: [Matches]
      summary: List authorized matches
      operationId: listMatches
      security:
        - bearerAuth: []
      parameters:
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        "200":
          description: Paginated matches
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Matches]
      summary: Create or update a match by externalId
      operationId: upsertMatch
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpsertMatchRequest"
            examples:
              sample:
                value:
                  externalId: club-match-2026-08-01
                  name: Saturday Classifier
                  matchDate: "2026-08-01"
                  clubName: Example Range
                  discipline: uspsa
                  isFinal: false
      responses:
        "200":
          description: Updated existing match
        "201":
          description: Created match (Partner→Match grant created)
        "409":
          $ref: "#/components/responses/IdempotencyConflict"

  /v1/matches/{matchId}:
    get:
      tags: [Matches]
      summary: Match detail with stages and participants
      operationId: getMatch
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/MatchId"
      responses:
        "200":
          description: Match bundle
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/matches/{matchId}/participants:
    post:
      tags: [Participants]
      summary: Create or update a competition participant
      description: |
        Writes competition participant identity only. Does not grant access to a private
        TRIGR shooter account profile. Display names here never overwrite users.display_name.
      operationId: upsertParticipant
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/MatchId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpsertParticipantRequest"
      responses:
        "200":
          description: Updated
        "201":
          description: Created

  /v1/matches/{matchId}/results:
    get:
      tags: [Results]
      summary: List stage results for a match
      operationId: listResults
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/MatchId"
      responses:
        "200":
          description: Results list
    post:
      tags: [Results]
      summary: Create or update a stage result
      operationId: upsertResult
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/MatchId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpsertResultRequest"
      responses:
        "200":
          description: Updated
        "201":
          description: Created

  /v1/shooters/{shooterUserId}:
    get:
      tags: [Shooters]
      summary: Read a private TRIGR shooter profile
      description: |
        Requires shooter.profile.read **and** an explicit Partner→Shooter grant.
        Never returns email, auth ids, subscription, or locker setupId.
      operationId: getShooterProfile
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ShooterUserId"
      responses:
        "200":
          description: Allowlisted competition profile fields
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShooterProfile"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Shooters]
      summary: Patch allowlisted competition metadata on a shooter profile
      description: |
        Requires shooter.profile.write **and** an explicit Partner→Shooter grant with
        profile.write. Writable: clubName, externalId, slots (sport/division/classification/
        membership metadata). firstName/lastName/email/setupId are denied.
      operationId: patchShooterProfile
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/ShooterUserId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PatchShooterProfileRequest"
      responses:
        "200":
          description: Updated profile
        "400":
          description: Denied or invalid writable fields
        "403":
          $ref: "#/components/responses/Forbidden"

  /v1/webhooks/endpoints:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints for this partner
      operationId: listWebhookEndpoints
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Endpoints (no signing secrets)
    post:
      tags: [Webhooks]
      summary: Register a webhook endpoint
      operationId: createWebhookEndpoint
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWebhookEndpointRequest"
      responses:
        "201":
          description: Created; signing secret shown once

  /v1/webhooks/endpoints/{endpointId}/rotate:
    post:
      tags: [Webhooks]
      summary: Rotate webhook signing secret
      description: New secret is shown once. Update your verifier before the next delivery.
      operationId: rotateWebhookEndpointSecret
      security:
        - bearerAuth: []
      parameters:
        - name: endpointId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: New signing secret (once)
        "404":
          $ref: "#/components/responses/NotFound"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Pass the full API secret as the Bearer token.
        Example (nonfunctional): `Authorization: Bearer trigr_test_examplePublicId_notARealSecret`
        Never put secrets in query strings or paths.

  parameters:
    MatchId:
      name: matchId
      in: path
      required: true
      schema:
        type: string
    ShooterUserId:
      name: shooterUserId
      in: path
      required: true
      schema:
        type: string
      description: TRIGR user id for the private shooter profile (not a competitor row id)
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        maxLength: 200
      description: Safe retries for write operations. Same key + same body replays the original response.

  responses:
    Unauthorized:
      description: Missing or invalid credential, wrong environment, expired, or revoked
    Forbidden:
      description: Authenticated but missing scope or resource grant
    NotFound:
      description: Resource not found or not visible to this partner
    RateLimited:
      description: Too many requests for this credential
    IdempotencyConflict:
      description: Idempotency-Key reused with a different request body

  schemas:
    PartnerMe:
      type: object
      properties:
        partnerId:
          type: string
        credentialId:
          type: string
        environment:
          type: string
          enum: [sandbox, production]
        hostEnvironment:
          type: string
          enum: [sandbox, production]
        scopes:
          type: array
          items:
            type: string
        requestId:
          type: string
    UpsertMatchRequest:
      type: object
      required: [externalId]
      properties:
        externalId:
          type: string
          maxLength: 200
        name:
          type: string
        matchDate:
          type: string
          format: date
        clubName:
          type: string
        locationText:
          type: string
        discipline:
          type: string
          enum: [uspsa, idpa, steel_challenge, ipsc, multigun, other]
        isFinal:
          type: boolean
    UpsertParticipantRequest:
      type: object
      required: [externalId]
      additionalProperties: false
      properties:
        externalId:
          type: string
        firstName:
          type: string
        lastName:
          type: string
        membershipNumber:
          type: string
        division:
          type: string
        classification:
          type: string
        category:
          type: string
        powerFactor:
          type: string
        squad:
          type: string
        overallRank:
          type: integer
        matchPercent:
          type: number
        finalScore:
          type: number
        dq:
          type: boolean
        dnf:
          type: boolean
    UpsertResultRequest:
      type: object
      required: [stageId, competitorId]
      additionalProperties: false
      properties:
        stageId:
          type: string
        competitorId:
          type: string
        totalTimeS:
          type: number
        points:
          type: number
        hitFactor:
          type: number
        aHits:
          type: integer
        cHits:
          type: integer
        dHits:
          type: integer
        misses:
          type: integer
        noShoots:
          type: integer
        procedurals:
          type: integer
        dnf:
          type: boolean
        dq:
          type: boolean
    ShooterProfile:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
        firstName:
          type: string
          nullable: true
        lastName:
          type: string
          nullable: true
        clubName:
          type: string
          nullable: true
        externalId:
          type: string
          nullable: true
          description: Partner-scoped external mapping only
        updatedAt:
          type: string
          format: date-time
          nullable: true
        slots:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              sport:
                type: string
              division:
                type: string
              classification:
                type: string
              powerFactor:
                type: string
                nullable: true
              category:
                type: string
                nullable: true
              membershipNumber:
                type: string
                nullable: true
              primary:
                type: boolean
        requestId:
          type: string
    PatchShooterProfileRequest:
      type: object
      additionalProperties: false
      properties:
        clubName:
          type: string
          nullable: true
        externalId:
          type: string
          nullable: true
        slots:
          type: array
          items:
            type: object
            required: [sport, division]
            additionalProperties: false
            properties:
              sport:
                type: string
              division:
                type: string
              classification:
                type: string
              powerFactor:
                type: string
              category:
                type: string
              membershipNumber:
                type: string
              primary:
                type: boolean
    CreateWebhookEndpointRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          description: HTTPS URL. Private/link-local/metadata destinations are rejected.
        events:
          type: array
          items:
            type: string
            enum:
              - match.created
              - match.updated
              - match.results.updated
        enabled:
          type: boolean
          default: true

  examples:
    fakeCredential:
      summary: Obviously fake credential for docs
      value: trigr_test_examplePublicId_notARealSecretUseYourOwn
