openapi: 3.1.0
info:
  title: LearnCoin API
  version: "1.0.0"
  summary: Verifiable-credential infrastructure — issuer API.
  description: |
    REST API for issuers to sign, batch, and anchor credentials on
    Ethereum L2 (Base). Every credential is simultaneously a W3C VC 2.0,
    Blockcerts v3, and Open Badges 3.0 credential.

    **Base URL:** `https://api.learncoin.me/v1/`

    **Authentication:** `Authorization: Bearer lrn_test_…` (testnet) or
    `lrn_live_…` (production). See
    https://learncoin.me/docs/api/authentication.

    **Conventions:** application/json only. ISO 8601 UTC timestamps.
    Cursor-based pagination. Idempotency via `Idempotency-Key` header.
    See https://learncoin.me/docs/api for the full conventions.

    **Authoritative current description:** https://learncoin.me/llms.txt

    **Not a cryptocurrency.** LearnCoin has no token. Older academic
    references to a "LearnCoin token" describe a deprecated pre-2025
    concept — see https://learncoin.me/research.
  contact:
    name: LearnCoin
    url: https://learncoin.me
    email: hello@learncoin.me
  license:
    name: Proprietary
    url: https://learncoin.me/terms
  x-logo:
    url: https://learncoin.me/learncoin-logo-v2.png

servers:
  - url: https://api.learncoin.me/v1
    description: Production API (testnet + mainnet via key prefix)

security:
  - BearerAuth: []

tags:
  - name: Batches
    description: Group credentials, sign, anchor on-chain.
  - name: Credentials
    description: Individual signed credentials — fetch, revoke, erase.
  - name: Tenants
    description: Your organization's account metadata.
  - name: Webhooks
    description: Event delivery to your backend.

paths:

  /batches:
    post:
      tags: [Batches]
      summary: Create a batch of credentials
      description: |
        Submit one or more credentials for signing and anchoring. The
        endpoint returns immediately; signing + anchoring complete
        asynchronously, typically within 60 seconds on testnet.

        Subscribe to `batch.anchored` webhooks rather than polling.
      operationId: createBatch
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateBatchRequest"
            examples:
              single-credential:
                summary: Single credential with ESCO alignment
                value:
                  credentials:
                    - recipient:
                        id: "urn:uuid:4e0c7f2e-6b1a-4f5a-9c8e-8a1b2c3d4e5f"
                        name: "Ada Lovelace"
                        email: "ada@example.edu"
                      achievement:
                        id: "https://example.edu/credentials/intro-ml"
                        name: "Introduction to Machine Learning"
                        description: "Completed the 12-week introductory course with distinction."
                        criteria:
                          narrative: "Passing grade on final project plus peer-reviewed submission."
                        alignment:
                          - targetFramework: "ESCO"
                            targetCode: "S1.4.0"
                            targetName: "Machine learning"
                            targetUrl: "https://esco.ec.europa.eu/en/classification/skill?uri=http%3A%2F%2Fdata.europa.eu%2Fesco%2Fskill%2FS1.4.0"
                      issuanceDate: "2026-04-23T12:00:00Z"
      responses:
        "202":
          description: Accepted — signing and anchoring are queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Batch"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"

    get:
      tags: [Batches]
      summary: List batches
      operationId: listBatches
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: status
          in: query
          required: false
          description: Filter by batch status.
          schema:
            type: string
            enum: [pending, signed, anchored, failed]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Batch"
                  next_cursor:
                    type: string
                    nullable: true
                  has_more:
                    type: boolean
        "401":
          $ref: "#/components/responses/Unauthorized"

  /batches/{id}:
    get:
      tags: [Batches]
      summary: Retrieve a batch
      operationId: getBatch
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: "^bat_[A-Z0-9]{26}$"
          example: "bat_01HXYZABCDEF1234567890ABCD"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Batch"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /credentials/{id}:
    get:
      tags: [Credentials]
      summary: Retrieve a credential
      description: |
        Returns the credential's current status plus the signed JSON-LD
        document (same artifact consumed by the public `/c/{id}`
        verification page and by any Blockcerts-compatible verifier).
      operationId: getCredential
      parameters:
        - $ref: "#/components/parameters/CredentialId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Credential"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /credentials/{id}/revoke:
    post:
      tags: [Credentials]
      summary: Revoke a credential
      description: |
        Marks a credential as revoked. The cryptographic proof still
        verifies, but the public verification page shows `Status:
        Revoked`. Revocation is independent of erasure.
      operationId: revokeCredential
      parameters:
        - $ref: "#/components/parameters/CredentialId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RevocationRequest"
      responses:
        "200":
          description: Credential revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RevocationResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /credentials/{id}/erase:
    post:
      tags: [Credentials]
      summary: GDPR erasure
      description: |
        Honor a recipient's GDPR right-to-be-forgotten request.
        Redacts recipient PII from the off-chain store. The on-chain
        anchor and cryptographic proof are not affected — the
        credential remains verifiable.
      operationId: eraseCredential
      parameters:
        - $ref: "#/components/parameters/CredentialId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ErasureRequest"
      responses:
        "200":
          description: Credential erased.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErasureResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /tenants/me:
    get:
      tags: [Tenants]
      summary: Retrieve the current tenant
      operationId: getCurrentTenant
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tenant"
        "401":
          $ref: "#/components/responses/Unauthorized"

    patch:
      tags: [Tenants]
      summary: Update tenant metadata
      description: |
        Partially update tenant-level display metadata. Only fields
        present in the body are updated.
      operationId: updateCurrentTenant
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TenantUpdate"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tenant"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /webhooks:
    post:
      tags: [Webhooks]
      summary: Register a webhook endpoint
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWebhookRequest"
      responses:
        "201":
          description: Webhook registered. `signing_secret` is returned only once.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      operationId: listWebhooks
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookEndpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      operationId: deleteWebhook
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      summary: Send a test event
      description: |
        Send a synthetic `webhook.test` event to the endpoint. Useful
        for validating signature verification before wiring up real
        traffic.
      operationId: testWebhook
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [delivered, delivered_at]
                properties:
                  delivered:
                    type: boolean
                  status_code:
                    type: integer
                  delivered_at:
                    type: string
                    format: date-time
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

components:

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "lrn_test_… or lrn_live_…"
      description: |
        Bearer token in the `Authorization` header. Key prefix determines
        environment: `lrn_test_` → Base Sepolia, `lrn_live_` → Base mainnet.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Arbitrary string (≤ 255 chars, typically a UUID) to deduplicate
        retries. Same key + same body within 24 h → original response
        returned. Same key + different body → `409 idempotency_key_reused`.
      schema:
        type: string
        maxLength: 255
      example: "6d19f2a4-7c2b-4fcd-b5f1-3ef0a19b82de"

    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque pagination cursor from the previous response.
      schema:
        type: string

    Limit:
      name: limit
      in: query
      required: false
      description: Page size (1–100, default 25).
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25

    CredentialId:
      name: id
      in: path
      required: true
      schema:
        type: string
        pattern: "^crd_[A-Z0-9]{26}$"
      example: "crd_01HXYZABCDEF1234567890ABCD"

    WebhookId:
      name: id
      in: path
      required: true
      schema:
        type: string
        pattern: "^whk_[A-Z0-9]{26}$"
      example: "whk_01HXYZABCDEF1234567890ABCD"

  responses:
    BadRequest:
      description: Payload missing or malformed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing, invalid, or revoked API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource does not exist in this tenant.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: State conflict — idempotency key collision or already-revoked/erased.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unprocessable:
      description: Syntactically valid but semantically can't be processed (quota, chain unavailable, etc.).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Rate limit exceeded. See `Retry-After` header.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until retry is permitted.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              type: string
              description: Stable machine-readable error code. See /docs/api/errors.
              example: "credential_not_found"
            message:
              type: string
              description: Human-readable description.
            request_id:
              type: string
              description: Correlation ID — also returned in X-Request-Id header.
              example: "req_01HXYZABCDEF"

    CreateBatchRequest:
      type: object
      required: [credentials]
      properties:
        credentials:
          type: array
          minItems: 1
          maxItems: 500
          items:
            $ref: "#/components/schemas/CredentialInput"

    CredentialInput:
      type: object
      required: [recipient, achievement, issuanceDate]
      properties:
        recipient:
          $ref: "#/components/schemas/RecipientInput"
        achievement:
          $ref: "#/components/schemas/AchievementInput"
        issuanceDate:
          type: string
          format: date-time
          example: "2026-04-23T12:00:00Z"
        expirationDate:
          type: string
          format: date-time
          nullable: true

    RecipientInput:
      type: object
      required: [id, name]
      properties:
        id:
          type: string
          pattern: "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
          description: |
            Pseudonymous recipient identifier. MUST be a `urn:uuid:<v4>`.
            Off-chain mapping to real identity lives in your own system
            and in LearnCoin's tenant-scoped RLS (erasable).
          example: "urn:uuid:4e0c7f2e-6b1a-4f5a-9c8e-8a1b2c3d4e5f"
        name:
          type: string
          description: Display name. Off-chain only; redactable on GDPR erasure.
        email:
          type: string
          format: email
          description: Off-chain only. Used for magic-link delivery if enabled.

    AchievementInput:
      type: object
      required: [name, description]
      properties:
        id:
          type: string
          format: uri
          description: Stable URL identifying this achievement definition.
        name:
          type: string
        description:
          type: string
        criteria:
          type: object
          properties:
            narrative:
              type: string
        alignment:
          type: array
          items:
            $ref: "#/components/schemas/AlignmentInput"
        evidence:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uri }
              narrative: { type: string }

    AlignmentInput:
      type: object
      required: [targetFramework, targetCode, targetName]
      properties:
        targetFramework:
          type: string
          enum: [ESCO, "O*NET", "ISCED-F", CUSTOM]
        targetCode:
          type: string
        targetName:
          type: string
        targetUrl:
          type: string
          format: uri

    Batch:
      type: object
      required: [id, status, credentials_count, created_at, environment]
      properties:
        id:
          type: string
          pattern: "^bat_[A-Z0-9]{26}$"
        status:
          type: string
          enum: [pending, signed, anchored, failed]
        credentials_count:
          type: integer
        created_at:
          type: string
          format: date-time
        anchored_at:
          type: string
          format: date-time
          nullable: true
        environment:
          type: string
          enum: [test, live]
        merkle_root:
          type: string
          nullable: true
          description: Hex-encoded SHA-256 root of the batch's credentials.
          example: "0x7c2f91a8…"
        anchor_transaction:
          $ref: "#/components/schemas/AnchorTransaction"
        credentials:
          type: array
          description: Populated only once the batch is `anchored`.
          items:
            type: object
            properties:
              id:
                type: string
                pattern: "^crd_[A-Z0-9]{26}$"
              recipient_id:
                type: string
              verify_url:
                type: string
                format: uri
        error:
          type: string
          nullable: true
          description: Populated only when status is `failed`.

    AnchorTransaction:
      type: object
      nullable: true
      required: [chain, hash]
      properties:
        chain:
          type: string
          enum: [base-mainnet, base-sepolia]
        hash:
          type: string
          example: "0xabcd…ef12"
        block_number:
          type: integer
        explorer_url:
          type: string
          format: uri

    Credential:
      type: object
      required: [id, verify_url, status, revoked, erased, signed_credential]
      properties:
        id:
          type: string
          pattern: "^crd_[A-Z0-9]{26}$"
        verify_url:
          type: string
          format: uri
        status:
          type: string
          enum: [pending, signed, anchored]
        revoked:
          type: boolean
        erased:
          type: boolean
        signed_credential:
          $ref: "#/components/schemas/SignedCredentialJsonLd"

    SignedCredentialJsonLd:
      type: object
      description: |
        The full signed JSON-LD document — simultaneously a W3C VC 2.0,
        Blockcerts v3, and Open Badges 3.0 credential. See
        https://learncoin.me/docs/concepts/credentials for a
        field-by-field annotated breakdown.
      required: ["@context", id, type, issuer, issuanceDate, credentialSubject, proof]
      properties:
        "@context":
          type: array
          items:
            type: string
            format: uri
        id:
          type: string
        type:
          type: array
          items:
            type: string
        issuer:
          type: object
          properties:
            id:
              type: string
              description: "did:web:learncoin.me#tenant-<id>"
            type:
              type: array
              items: { type: string }
            name:
              type: string
        issuanceDate:
          type: string
          format: date-time
        expirationDate:
          type: string
          format: date-time
          nullable: true
        credentialSubject:
          type: object
        proof:
          type: object
          properties:
            type:
              type: string
              enum: [MerkleProof2019]
            created: { type: string, format: date-time }
            proofPurpose: { type: string }
            verificationMethod: { type: string }
            proofValue: { type: string }

    RevocationRequest:
      type: object
      required: [reason, reason_code]
      properties:
        reason:
          type: string
          description: Human-readable explanation, shown on the verification page.
        reason_code:
          type: string
          enum: [reissued, issuer_error, recipient_request, other]

    RevocationResponse:
      type: object
      required: [id, revoked, revoked_at]
      properties:
        id: { type: string }
        revoked: { type: boolean }
        revoked_at: { type: string, format: date-time }
        reason: { type: string }
        reason_code: { type: string }

    ErasureRequest:
      type: object
      required: [requester, verified_at]
      properties:
        requester:
          type: string
          enum: [recipient, dpo, supervisory_authority]
        verified_at:
          type: string
          format: date-time
          description: When the issuer verified the request was legitimate.

    ErasureResponse:
      type: object
      required: [id, erased, erased_at, verification_status_after_erasure]
      properties:
        id: { type: string }
        erased: { type: boolean }
        erased_at: { type: string, format: date-time }
        verification_status_after_erasure:
          type: string
          enum: [verifiable, invalid]

    Tenant:
      type: object
      required: [id, name, did, plan, environment]
      properties:
        id:
          type: string
          pattern: "^tnt_[A-Z0-9]{26}$"
        name:
          type: string
        did:
          type: string
          example: "did:web:learncoin.me#tenant-01HXYZ"
        plan:
          type: string
          enum: [developer, starter, institution, industry]
        environment:
          type: string
          enum: [test, live]
        credentials_issued_this_month:
          type: integer
        credentials_limit_this_month:
          type: integer

    TenantUpdate:
      type: object
      properties:
        display_name:
          type: string
        awarding_body:
          type: object
          properties:
            name: { type: string }
            url: { type: string, format: uri }
            logo_url: { type: string, format: uri }
        verify_page_branding:
          type: object
          properties:
            accent_color: { type: string, example: "#8B2635" }
            logo_url: { type: string, format: uri }

    CreateWebhookRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
        events:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/WebhookEventType"
        description:
          type: string

    WebhookEndpoint:
      type: object
      required: [id, url, events, active, created_at]
      properties:
        id:
          type: string
          pattern: "^whk_[A-Z0-9]{26}$"
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            $ref: "#/components/schemas/WebhookEventType"
        signing_secret:
          type: string
          description: Returned only at creation time.
        description:
          type: string
        active:
          type: boolean
        created_at:
          type: string
          format: date-time

    WebhookEventType:
      type: string
      enum:
        - batch.created
        - batch.signed
        - batch.anchored
        - batch.failed
        - credential.revoked
        - credential.erased
        - webhook.test
