> ## Documentation Index
> Fetch the complete documentation index at: https://docs.peridot.id/llms.txt
> Use this file to discover all available pages before exploring further.

# Report an application activity event (server-to-server)

> Submit a supported activity event for a user so PeridotID can verify
it and award XP (ANT-89 M3). Server-to-server ONLY: authenticate with
the app's `client_id` + `client_secret` in the body. Cookie/JWT
sessions are NEVER accepted here (401 without valid app credentials).

Restricted to Verified apps with explicit XP permission and an
admin-approved event type (403 otherwise, nothing written). The task
is resolved server-side (`event_type` → active task, app-scoped wins);
XP comes from the task's configured reward — the request MUST NOT
carry any xp/amount field (stripped at the boundary, never read).

Idempotent per app: retries with the same (`client_id`, `event_id`)
return `duplicate` without a second award. Unknown subjects and
failed verifications share the generic `not_eligible` rejection (no
PID oracle). Awards are release-gated: 503 while
`PID_XP_INTAKE_ENABLED` is not exactly `"true"`.

Rate limits: global default plus per-app (60/min) and
per-app+subject (30/min) tiers.




## OpenAPI

````yaml /openapi.yaml post /experience/events
openapi: 3.0.3
info:
  title: PeridotID API
  version: 0.1.0
  description: >
    Gaming Identity Platform — Authentication, Identity, and Profile for the
    Peridot ecosystem.


    ## Base URL

    All endpoints are prefixed with `/v1`.


    | Environment | Base URL |

    |---|---|

    | Production | `https://api.peridot.id/v1` |

    | Sandbox | `https://sandbox-api.peridot.id/v1` |

    | Local dev | `http://localhost:3300/v1` |


    ## Authentication

    Authentication is **cookie-based**. After a successful Google sign-in the
    API sets two

    `HttpOnly` cookies:


    - `pid_access` — short-lived JWT access token (15 min), sent on every
    request.

    - `pid_refresh` — long-lived rotating refresh token (30 days), only sent to
    `/v1/auth`.


    Browsers handle these automatically; non-browser clients must send

    `Cookie: pid_access=<token>` on each request and call `/v1/auth/refresh`
    before the

    access token expires.
  contact:
    name: PeridotID
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT
servers:
  - url: https://api.peridot.id/v1
    description: Production
  - url: https://sandbox-api.peridot.id/v1
    description: Sandbox
  - url: http://localhost:3300/v1
    description: Local development
security: []
tags:
  - name: auth
    description: Login, logout, token refresh, and machine (client-credentials) tokens
  - name: identity
    description: The user's identity across products
  - name: profile
    description: The user's public profile
  - name: leveling
    description: >-
      PeridotID Level + XP reads (ANT-89). Centralized authority — PeridotID
      alone awards XP; all authorized apps read Level.
  - name: wallet
    description: >
      The user's wallet, associated with their PID. **Deprecated (V3 surface)**
      — preserved

      for existing clients, backed by `linked_address` chain accounts. Use
      `/account` and

      the smart account instead.
  - name: account
    description: >-
      PeridotID account — owns chain accounts (smart account PDA, legacy linked
      address)
  - name: meta
    description: |
      Deployment metadata: self-describing environment endpoints (sandbox vs
      production). Public, server-derived, never echoing client input.
  - name: fiat
    description: |
      The internal IDR fiat ledger: balances, statements, sends, and money-in.
      Top-ups enter through a hosted DOKU Checkout page (no Sub-Account) and
      credit the internal ledger once the payment is corroborated. Any identity
      can send to any identity (escrow/payouts). Fees: global 0.1% (min Rp100,
      no cap) plus optional per-app fees that stack and credit the app. Bank
      withdrawals (redemption) are disabled for now.
  - name: apps
    description: |
      Third-party apps ("Sign in with PeridotID"): client_id, allowed origins,
      optional backend secret, ledger callbacks (deferred), and a per-app fee
      schedule. Owner-scoped — a non-owner gets 404. App backends authenticate
      server-side with `POST /v1/auth/token` (client credentials).
  - name: credentials
    description: >
      Signing authorities (secp256r1 passkeys). Google OAuth proves identity; a

      passkey authorizes the wallet. The first credential registers freely; each
      additional

      credential requires approval by an existing credential
      (existing-credential approval rule).
  - name: permissions
    description: >
      EVM permission grants (scoped P-256 session keys, WHITEPAPER.md §10).
      Validation is pure

      hygiene + canonical id/challenge computation; submission-time account
      binding

      is enforced by the relay path.
  - name: session-keys
    description: >
      SVM gameplay sessions (Ed25519 session keys in isolated PDAs,
      WHITEPAPER.md §11).

      Validation is pure hygiene + canonical address/challenge computation;

      submission-time vault binding is enforced by the relay path.
  - name: openapi
    description: Machine-readable API specification
paths:
  /experience/events:
    post:
      tags:
        - leveling
      summary: Report an application activity event (server-to-server)
      description: |
        Submit a supported activity event for a user so PeridotID can verify
        it and award XP (ANT-89 M3). Server-to-server ONLY: authenticate with
        the app's `client_id` + `client_secret` in the body. Cookie/JWT
        sessions are NEVER accepted here (401 without valid app credentials).

        Restricted to Verified apps with explicit XP permission and an
        admin-approved event type (403 otherwise, nothing written). The task
        is resolved server-side (`event_type` → active task, app-scoped wins);
        XP comes from the task's configured reward — the request MUST NOT
        carry any xp/amount field (stripped at the boundary, never read).

        Idempotent per app: retries with the same (`client_id`, `event_id`)
        return `duplicate` without a second award. Unknown subjects and
        failed verifications share the generic `not_eligible` rejection (no
        PID oracle). Awards are release-gated: 503 while
        `PID_XP_INTAKE_ENABLED` is not exactly `"true"`.

        Rate limits: global default plus per-app (60/min) and
        per-app+subject (30/min) tiers.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - client_id
                - client_secret
                - event_id
                - user_id
                - event_type
              properties:
                client_id:
                  type: string
                  description: Public app identifier (`pidapp_*`)
                client_secret:
                  type: string
                  description: >-
                    Confidential-client secret (`pidsk_*`; never logged, never
                    echoed)
                event_id:
                  type: string
                  description: App-scoped idempotency key (dedup is per app + event_id)
                user_id:
                  type: string
                  pattern: ^[a-z0-9_]{3,20}@pid$
                  description: Subject user PID
                event_type:
                  type: string
                  description: Approved event type (e.g. `onboarding_completed`)
                resource_id:
                  type: string
                  description: Checker input (e.g. achievement/resource reference)
            example:
              client_id: pidapp_abc123
              client_secret: pidsk_...
              event_id: evt_123
              user_id: ifal@pid
              event_type: onboarding_completed
      responses:
        '200':
          description: Processing result (awarded, duplicate, or rejected with a reason)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/XpIntakeResult'
              examples:
                awarded:
                  value:
                    status: awarded
                    awarded_xp: 50
                    level: 2
                    leveledUp: true
                duplicate:
                  value:
                    status: duplicate
                    awarded_xp: 0
                    level: 2
                    reason: duplicate_event
                rejected:
                  value:
                    status: rejected
                    awarded_xp: 0
                    reason: not_eligible
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/XpIntakeDisabled'
      security: []
components:
  schemas:
    XpIntakeResult:
      type: object
      required:
        - status
        - awarded_xp
      description: >
        Event-intake processing result. `awarded_xp` is always the server-side
        task reward (clients can never submit XP); `reason` is a
        machine-readable cause that never oracles PID existence.
      properties:
        status:
          type: string
          enum:
            - awarded
            - duplicate
            - rejected
        awarded_xp:
          type: integer
          minimum: 0
        level:
          type: integer
          minimum: 1
          description: Authoritative level after processing (awarded/duplicate)
        leveledUp:
          type: boolean
        reason:
          type: string
          description: >-
            Machine-readable cause (duplicate_event, not_eligible, task_limit,
            cooldown, quota_exceeded, suspended, no_active_task,
            task_not_available, app_not_permitted)
    ApiError:
      type: object
      required:
        - statusCode
        - message
      properties:
        statusCode:
          type: integer
        message:
          oneOf:
            - type: string
            - type: array
              items:
                type: string
  responses:
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            statusCode: 400
            message:
              - >-
                handle must be 3-20 chars: lowercase letters, numbers,
                underscore
    Unauthorized:
      description: Missing, expired, or invalid access/refresh token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            statusCode: 401
            message: Unauthorized
    Forbidden:
      description: Authenticated but lacking the required role (e.g. admin)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            statusCode: 403
            message: Admin access required
    RateLimited:
      description: Too many requests
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            statusCode: 429
            message: 'ThrottlerException: Too Many Requests'
    XpIntakeDisabled:
      description: >-
        XP intake is release-gated off (PID_XP_INTAKE_ENABLED is not exactly
        "true")
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            statusCode: 503
            message: XP intake is disabled

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.