openapi: 3.1.0
info:
  title: OpenCharities Student API
  version: 1.0.0
  summary: Stateless fake-data sandbox for the University of Innsbruck x OpenCharities prototypes.
  description: |
    All data is fictional. Nothing is ever stored: every POST validates its input, returns a realistic
    created object with `meta.simulated: true`, and persists nothing. Available until 14 January 2027.
    Send your group key in the `X-OC-Group-Key` header (every endpoint except `/v1/health`).
servers:
  - url: https://sandbox-api.opencharities.com
    description: Sandbox
  - url: http://localhost:3000
    description: Local
security:
  - GroupKey: []
tags:
  - name: Platform
  - name: Aid distribution
  - name: Offchain impact proof
  - name: Employee-driven giving
paths:
  /v1/health:
    get:
      tags: [Platform]
      summary: Liveness check (no key needed)
      security: []
      responses:
        '200':
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: object
                    properties:
                      status: { type: string, const: ok }
                      service: { type: string }
                      time: { type: string, format: date-time }
                  meta: { $ref: '#/components/schemas/Meta' }
  /v1/initiatives:
    get:
      tags: [Platform]
      summary: List initiatives
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: ngoId
          in: query
          description: Filter by NGO id
          schema: { type: string }
        - name: status
          in: query
          description: Filter by status
          schema: { type: string, enum: [active, completed] }
        - name: category
          in: query
          description: Filter by category
          schema: { type: string }
      responses:
        '200':
          description: A page of results.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Initiative' }
                  meta: { $ref: '#/components/schemas/ListMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
  /v1/initiatives/{id}:
    get:
      tags: [Platform]
      summary: Get an initiative
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The resource.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Initiative' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '401': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
  /v1/ngos:
    get:
      tags: [Platform]
      summary: List NGOs
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: country
          in: query
          description: Filter by country
          schema: { type: string }
        - name: kybStatus
          in: query
          description: Filter by KYB status
          schema: { type: string, enum: [approved, in_review, submitted] }
      responses:
        '200':
          description: A page of results.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Ngo' }
                  meta: { $ref: '#/components/schemas/ListMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
  /v1/ngos/{id}:
    get:
      tags: [Platform]
      summary: Get an NGO
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The resource.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Ngo' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '401': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
  /v1/donations:
    get:
      tags: [Platform]
      summary: List donations (newest first)
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: initiativeId
          in: query
          description: Filter by initiative id
          schema: { type: string }
        - name: status
          in: query
          description: Filter by status
          schema: { type: string, enum: [settled, pending, refunded] }
      responses:
        '200':
          description: A page of results.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Donation' }
                  meta: { $ref: '#/components/schemas/ListMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
    post:
      tags: [Platform]
      summary: Create a simulated donation
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DonationInput' }
      responses:
        '201':
          description: Simulated result. Nothing is stored.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Donation' }
                  meta: { $ref: '#/components/schemas/SimulatedMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
  /v1/recipients:
    get:
      tags: [Aid distribution]
      summary: List recipients (IDs only, fictional)
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: region
          in: query
          description: Filter by region
          schema: { type: string }
        - name: cashOutChannel
          in: query
          description: Filter by channel
          schema: { type: string, enum: [mobile_money, bank_transfer, cash_pickup, voucher] }
      responses:
        '200':
          description: A page of results.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Recipient' }
                  meta: { $ref: '#/components/schemas/ListMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
  /v1/payout-batches:
    get:
      tags: [Aid distribution]
      summary: List payout batches
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: ngoId
          in: query
          description: Filter by NGO id
          schema: { type: string }
        - name: status
          in: query
          description: Filter by status
          schema: { type: string, enum: [planned, scheduled, in_progress, completed, reconciling, failed] }
        - name: cashOutChannel
          in: query
          description: Filter by channel
          schema: { type: string, enum: [mobile_money, bank_transfer, cash_pickup, voucher] }
      responses:
        '200':
          description: A page of results.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PayoutBatch' }
                  meta: { $ref: '#/components/schemas/ListMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
    post:
      tags: [Aid distribution]
      summary: Plan a simulated payout batch
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PayoutBatchInput' }
      responses:
        '201':
          description: Simulated result. Nothing is stored.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/PayoutBatch' }
                  meta: { $ref: '#/components/schemas/SimulatedMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
  /v1/payout-batches/{id}/confirmations:
    post:
      tags: [Aid distribution]
      summary: Confirm a recipient payout (simulated)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConfirmationInput' }
      responses:
        '201':
          description: Simulated result. Nothing is stored.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Confirmation' }
                  meta: { $ref: '#/components/schemas/SimulatedMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
  /v1/field-agents:
    get:
      tags: [Offchain impact proof]
      summary: List field agents
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: role
          in: query
          description: Filter by role
          schema: { type: string, enum: [field_nurse, distribution_lead, logistics, community_liaison] }
        - name: region
          in: query
          description: Filter by region
          schema: { type: string }
      responses:
        '200':
          description: A page of results.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/FieldAgent' }
                  meta: { $ref: '#/components/schemas/ListMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
  /v1/delivery-events:
    get:
      tags: [Offchain impact proof]
      summary: List offline-captured delivery events
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: agentId
          in: query
          description: Filter by agent id
          schema: { type: string }
        - name: syncStatus
          in: query
          description: Filter by sync status
          schema: { type: string, enum: [synced, queued, conflict] }
      responses:
        '200':
          description: A page of results.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/DeliveryEvent' }
                  meta: { $ref: '#/components/schemas/ListMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
  /v1/proofs:
    post:
      tags: [Offchain impact proof]
      summary: Hash content (or echo a hash) with a timestamp
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProofInput' }
      responses:
        '201':
          description: Simulated result. Nothing is stored.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Proof' }
                  meta: { $ref: '#/components/schemas/SimulatedMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
  /v1/companies:
    get:
      tags: [Employee-driven giving]
      summary: List companies
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of results.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Company' }
                  meta: { $ref: '#/components/schemas/ListMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
  /v1/giving-rounds:
    get:
      tags: [Employee-driven giving]
      summary: List giving rounds
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: companyId
          in: query
          description: Filter by company id
          schema: { type: string }
        - name: status
          in: query
          description: Filter by status
          schema: { type: string, enum: [open, closed, upcoming] }
      responses:
        '200':
          description: A page of results.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/GivingRound' }
                  meta: { $ref: '#/components/schemas/ListMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
  /v1/giving-rounds/{id}/votes:
    post:
      tags: [Employee-driven giving]
      summary: Cast a simulated quadratic ballot (n votes cost n squared; max 100 points; round must be open)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VoteInput' }
      responses:
        '201':
          description: Simulated result. Nothing is stored.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Vote' }
                  meta: { $ref: '#/components/schemas/SimulatedMeta' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
components:
  securitySchemes:
    GroupKey:
      type: apiKey
      in: header
      name: X-OC-Group-Key
  parameters:
    Limit:
      name: limit
      in: query
      description: Page size, 1 to 100 (default 20).
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from a previous response's meta.nextCursor.
      schema: { type: string }
  responses:
    Error:
      description: Error (400 validation_error / invalid_json / invalid_limit / invalid_cursor, 401 unauthorized, 404 not_found, 405 method_not_allowed, 409 round_not_open).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorBody' }
  schemas:
    Initiative:
      type: object
      properties:
        id: { type: string, examples: [INI-001] }
        ngoId: { type: string }
        title: { type: string }
        summary: { type: string }
        category: { type: string }
        country: { type: string }
        goalEur: { type: number }
        raisedEur: { type: number }
        status: { type: string, enum: [active, completed] }
        startDate: { type: string, format: date }
    Ngo:
      type: object
      properties:
        id: { type: string, examples: [NGO-001] }
        name: { type: string }
        country: { type: string }
        kybStatus: { type: string, enum: [approved, in_review, submitted] }
        contactEmail: { type: string }
        iban: { type: string, description: Reserved test pattern }
    Donation:
      type: object
      properties:
        id: { type: string }
        initiativeId: { type: string }
        amountEur: { type: number }
        currency: { type: string, const: EUR }
        donorRef: { type: [string, 'null'] }
        paymentMethod: { type: string, enum: [ideal, card, bank_transfer] }
        message: { type: [string, 'null'] }
        status: { type: string, enum: [settled, pending, refunded] }
        createdAt: { type: string, format: date-time }
    DonationInput:
      type: object
      required: [initiativeId, amountEur]
      properties:
        initiativeId: { type: string }
        amountEur: { type: number, exclusiveMinimum: 0, maximum: 100000 }
        donorName: { type: string, maxLength: 100 }
        paymentMethod: { type: string, enum: [ideal, card, bank_transfer] }
        message: { type: string, maxLength: 280 }
    Recipient:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        region: { type: string }
        cashOutChannel: { $ref: '#/components/schemas/Channel' }
        phone: { type: string }
    Channel:
      type: string
      enum: [mobile_money, bank_transfer, cash_pickup, voucher]
    PayoutBatch:
      type: object
      properties:
        id: { type: string }
        ngoId: { type: string }
        plannedDate: { type: string, format: date }
        numPayouts: { type: integer }
        totalEur: { type: number }
        cashOutChannel: { $ref: '#/components/schemas/Channel' }
        status: { type: string, enum: [planned, scheduled, in_progress, completed, reconciling, failed] }
        recipientIds: { type: array, items: { type: string }, description: Only on created batches }
        createdAt: { type: string, format: date-time, description: Only on created batches }
    PayoutBatchInput:
      type: object
      required: [ngoId, recipientIds, amountPerRecipientEur, cashOutChannel, plannedDate]
      properties:
        ngoId: { type: string }
        recipientIds: { type: array, minItems: 1, maxItems: 500, items: { type: string } }
        amountPerRecipientEur: { type: number, exclusiveMinimum: 0 }
        cashOutChannel: { $ref: '#/components/schemas/Channel' }
        plannedDate: { type: string, format: date }
    ConfirmationInput:
      type: object
      required: [recipientId, status]
      properties:
        recipientId: { type: string }
        status: { type: string, enum: [received, not_received] }
        confirmedBy: { type: string }
    Confirmation:
      type: object
      properties:
        id: { type: string }
        batchId: { type: string }
        recipientId: { type: string }
        status: { type: string }
        confirmedBy: { type: [string, 'null'] }
        confirmedAt: { type: string, format: date-time }
    FieldAgent:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        role: { type: string }
        region: { type: string }
        phone: { type: string }
    DeliveryEvent:
      type: object
      properties:
        id: { type: string }
        agentId: { type: string }
        lat: { type: number }
        lon: { type: number }
        capturedAt: { type: string }
        syncedAt: { type: string }
        syncDelayHours: { type: number }
        itemCode: { type: string }
        quantity: { type: integer }
        countersignInitials: { type: string }
        syncStatus: { type: string, enum: [synced, queued, conflict] }
    ProofInput:
      type: object
      properties:
        content: { type: string, maxLength: 65536, description: 'Hashed server-side (UTF-8). Send exactly one of content or hash.' }
        hash: { type: string, pattern: '^[0-9a-fA-F]{64}$', description: Already-computed SHA-256 (hex) }
        deliveryEventId: { type: string }
        label: { type: string, maxLength: 100 }
    Proof:
      type: object
      properties:
        id: { type: string }
        algorithm: { type: string, const: sha256 }
        sha256: { type: string }
        source: { type: string, enum: [content, hash] }
        deliveryEventId: { type: [string, 'null'] }
        label: { type: [string, 'null'] }
        timestamp: { type: string, format: date-time }
    Company:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        country: { type: string }
        sector: { type: string }
        employeeCount: { type: integer }
        annualGivingBudgetEur: { type: number }
    GivingRound:
      type: object
      properties:
        id: { type: string }
        companyId: { type: string }
        title: { type: string }
        status: { type: string, enum: [open, closed, upcoming] }
        votingMethod: { type: string, const: quadratic }
        pointsPerEmployee: { type: integer, const: 100 }
        shortlist: { type: array, items: { type: string }, description: NGO ids }
        budgetEur: { type: number }
        opensAt: { type: string, format: date }
        closesAt: { type: string, format: date }
        ballotsCast: { type: integer }
        result: { type: array, items: { type: object }, description: Only on closed rounds }
    VoteInput:
      type: object
      required: [employeeId, allocations]
      properties:
        employeeId: { type: string }
        allocations: { type: array, minItems: 1, maxItems: 10, items: { type: object, required: [ngoId, votes], properties: { ngoId: { type: string }, votes: { type: integer, minimum: 1, maximum: 10 } } } }
    Vote:
      type: object
      properties:
        id: { type: string }
        roundId: { type: string }
        employeeId: { type: string }
        allocations: { type: array, items: { type: object } }
        pointsSpent: { type: integer }
        pointsRemaining: { type: integer }
        castAt: { type: string, format: date-time }
    Meta:
      type: object
      required: [requestId, simulated]
      properties:
        requestId: { type: string }
        simulated: { type: boolean }
    SimulatedMeta:
      type: object
      required: [requestId, simulated]
      properties:
        requestId: { type: string }
        simulated: { type: boolean, const: true }
    ListMeta:
      type: object
      required: [requestId, simulated, nextCursor]
      properties:
        requestId: { type: string }
        simulated: { type: boolean }
        nextCursor: { type: [string, 'null'] }
    ErrorBody:
      type: object
      required: [error, meta]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            fields:
              type: array
              items:
                type: object
                properties:
                  field: { type: string }
                  message: { type: string }
        meta:
          type: object
          required: [requestId]
          properties:
            requestId: { type: string }
