> ## Documentation Index
> Fetch the complete documentation index at: https://hack-club-pin-docs-urls.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Record a scan

> Record a QR / NFC / manual scan for a participant. Pass `client_scan_id` for idempotency — a repeat returns the existing scan with `deduplicated: true`. `badge_token` accepts either an assigned NFC badge belonging to the selected event or an active Hack Club passport. Passport scans work regardless of the event's NFC badge issuance setting. The participant can also be identified by `participant_id` (matched as a participant_event id first, then a participant id).



## OpenAPI

````yaml /openapi.yml post /events/{event_id}/scans
openapi: 3.0.3
info:
  title: Attend API
  version: v1
  description: >
    REST API for [Attend](https://attend.hackclub.com), Hack Club's event

    management platform. The API is used by the Attend mobile app and by

    trusted server-to-server integrations.


    ## Authentication


    Almost every endpoint requires a bearer token:


    ```

    Authorization: Bearer <token>

    ```


    Four kinds of token are accepted:


    - **Mobile token** — issued to a signed-in user by `POST /session`. Sets the
      current user. May return the response header
      `X-Token-Refresh-Recommended: true` when the token is nearing expiry;
      call `POST /session/refresh` to rotate it.
    - **Global API token** — a superadmin-issued token. Only honoured while its
      owner is still a global admin. Sets the current user. May be **scoped**
      when issued, in which case it reaches only the endpoints that accept
      that scope and returns `403` everywhere else, read-only endpoints
      included. An unscoped token carries its owner's full access. Scopes are
      noted on the endpoints that accept them; the only one today is
      `bans:write`.
    - **Series API key** — one key for a whole event series, issued from
      **Series → API Keys** in the dashboard by a series owner. Does **not** set
      a current user. It acts as an event API key on *every* event in its
      series, and it is the only credential that can create an event. Scopes do
      not apply — a series key's reach is its series. See the **Series**
      section.
    - **Event API key** — per-event key (`EventApiToken`, or the legacy
      `Event#api_key`). Does **not** set a current user, so it only works on the
      subset of endpoints that don't require one (participant `lookup`,
      `roster`, `create`, and the travel calendar).

    Missing or invalid credentials return `401 { "error": "Unauthorized" }`.


    ### What an API key cannot do


    Neither key kind sets a current user, so endpoints that must attribute work

    to a person stay closed to both — regardless of how broad the key is. A

    series key is wider in *reach* (more events), never in *depth*:


    | Endpoint | Series key | Event key |

    | --- | --- | --- |

    | `GET/POST /series/…` | ✅ its own series | ❌ `403` |

    | `POST /series/{id}/events` | ✅ its own series | ❌ `403` |

    | Participant `lookup`, `roster`, `create` | ✅ any event in the series | ✅
    its own event |

    | `GET /events/{id}/travel` | ✅ any event in the series | ✅ its own event |

    | `GET /events/{id}/participants` (full payload) | ❌ `403` | ❌ `403` |

    | Notes, scans, scan contexts, Slack blasts, NFC badges | ❌ `403` | ❌ `403`
    |


    Either key can be revoked without credentials by POSTing its own secret to

    `POST /tokens/revoke`; Attend emails the key's owner (or, if their account

    is gone, the series owners) when that happens.


    ## Conventions


    - All timestamps are ISO 8601 strings and may be `null` where noted.

    - Errors use the envelope `{ "error": "<message>" }`.

    - `:event_id` path segments accept a numeric id or the event slug on the
      participants endpoints; other endpoints accept the numeric id only.

    > **Note:** Inbound webhook receivers (Postmark, Help Scout, DocuSeal,

    > Slack events) also live under `/api/v1` but are called by third parties,

    > not by API consumers, and are not documented here.
servers:
  - url: https://attend.hackclub.com/api/v1
    description: Production
  - url: http://localhost:3000/api/v1
    description: Local development
security:
  - bearerAuth: []
tags:
  - name: Authentication
    description: Obtain, refresh, and revoke mobile bearer tokens.
  - name: Account
    description: The authenticated user's profile and accessible events.
  - name: Series
    description: >-
      Event series, and the events inside one, addressed with a single series
      API key. Creating an event lives here — an event has to belong to a
      series, and only a series key names one unambiguously.
  - name: Participants
    description: Participant registrations for an event.
  - name: Notes
    description: Staff notes on a participant's registration.
  - name: Scans
    description: QR / NFC / manual scans and check-in.
  - name: Scan Contexts
    description: The scan stations configured for an event.
  - name: Travel Calendar
    description: Chronological participant travel and pickup operations.
  - name: Slack Blasts
    description: Broadcast Slack messages to an event's participants.
  - name: NFC Badges
    description: Provision and manage participant NFC badges.
  - name: Push Tokens
    description: Register device push-notification tokens.
  - name: Bans
    description: The app-wide ban list. Global admins only.
  - name: Travel
    description: >-
      Airport and flight lookups. These endpoints authenticate with the web
      session cookie (Devise), not a bearer token.
paths:
  /events/{event_id}/scans:
    post:
      tags:
        - Scans
      summary: Record a scan
      description: >-
        Record a QR / NFC / manual scan for a participant. Pass `client_scan_id`
        for idempotency — a repeat returns the existing scan with `deduplicated:
        true`. `badge_token` accepts either an assigned NFC badge belonging to
        the selected event or an active Hack Club passport. Passport scans work
        regardless of the event's NFC badge issuance setting. The participant
        can also be identified by `participant_id` (matched as a
        participant_event id first, then a participant id).
      parameters:
        - $ref: '#/components/parameters/EventIdNumeric'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                participant_id:
                  type: integer
                  description: participant_event id or participant id.
                scan_context_id:
                  type: integer
                  description: Required only when the event has multiple scan contexts.
                badge_token:
                  type: string
                client_scan_id:
                  type: string
                  description: Idempotency key.
                source:
                  type: string
                  description: Set to `manual` to record a manual scan.
                scanned_at:
                  type: string
                  format: date-time
                  description: Defaults to now.
      responses:
        '200':
          description: Scan recorded (or an existing scan returned when deduplicated).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScanCreateResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Participant not found for this event.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Missing / invalid scan context, or validation failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  parameters:
    EventIdNumeric:
      name: event_id
      in: path
      required: true
      schema:
        type: integer
      description: Event id.
  schemas:
    ScanCreateResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        deduplicated:
          type: boolean
          description: Present and true when an existing scan was returned.
        first_scan_in_context:
          type: boolean
          description: Absent on a deduplicated response.
        scan:
          $ref: '#/components/schemas/Scan'
        participant:
          $ref: '#/components/schemas/ScanParticipant'
    Error:
      type: object
      properties:
        error:
          type: string
          example: Unauthorized
    Scan:
      type: object
      properties:
        id:
          type: integer
          example: 123
        participant_id:
          type: integer
        participant_event_id:
          type: integer
        scanned_at:
          type: string
          format: date-time
        scanned_by:
          type: string
          example: Jane Staff
        client_scan_id:
          type: string
          nullable: true
        source:
          type: string
          enum:
            - qr
            - nfc
            - manual
        scan_context:
          nullable: true
          allOf:
            - $ref: '#/components/schemas/ScanContext'
        created_at:
          type: string
          format: date-time
    ScanParticipant:
      type: object
      description: Compact participant summary returned when recording a scan.
      properties:
        participant_id:
          type: integer
        participant_event_id:
          type: integer
        display_name:
          type: string
        full_name:
          type: string
        email:
          type: string
          format: email
        slack_user_id:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        pronouns:
          type: string
          nullable: true
        tshirt_size:
          type: string
          nullable: true
        status:
          $ref: '#/components/schemas/ParticipantStatus'
        has_anaphylaxis_risk:
          type: boolean
        requires_refrigeration:
          type: boolean
        allergies:
          type: string
          nullable: true
        medical_conditions:
          type: string
          nullable: true
        medications:
          type: string
          nullable: true
        diet_type:
          type: string
          nullable: true
        life_threatening_allergies:
          type: string
          nullable: true
        cross_contamination_risk:
          type: boolean
        freedom_waiver_granted:
          type: boolean
        high_support_flag:
          type: boolean
        can_leave_unaccompanied:
          type: boolean
        waiver_signed:
          type: boolean
        nfc_badge_token:
          type: string
          nullable: true
        nfc_badge_assigned:
          type: boolean
        groups:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
              color:
                type: string
    ScanContext:
      type: object
      properties:
        id:
          type: integer
          example: 4
        name:
          type: string
          example: Check-in
        checks_in:
          type: boolean
        is_travel_pickup:
          type: boolean
        is_airport:
          type: boolean
          deprecated: true
          description: Deprecated alias for `is_travel_pickup`.
        position:
          type: integer
        starts_at:
          type: string
          format: date-time
          nullable: true
        ends_at:
          type: string
          format: date-time
          nullable: true
    ParticipantStatus:
      type: string
      enum:
        - invited
        - in_progress
        - awaiting_guardian
        - complete
        - withdrawn
        - rejected
  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: The caller cannot access this event or action.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Mobile token, global API token, series API key, or event API key. See
        Authentication above for which endpoints each one reaches.

````