{
  "openapi": "3.1.0",
  "info": {
    "title": "CAIRL Public Integration API",
    "version": "0.8.0",
    "description": "Public contract for CAIRL hosted verification, OAuth token exchange, userinfo claims, token revocation, Shopify verification exchange, and outbound webhook events."
  },
  "servers": [
    {
      "url": "https://app.cairl.co",
      "description": "Production"
    },
    {
      "url": "https://staging-app.cairl.co",
      "description": "Staging"
    }
  ],
  "paths": {
    "/verify/start": {
      "get": {
        "summary": "Start hosted verification",
        "description": "Redirect a user to CAIRL's hosted verification flow using OAuth 2.0 Authorization Code with PKCE.",
        "operationId": "startHostedVerification",
        "parameters": [
          {
            "name": "client_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "redirect_uri",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uri"
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 16
            }
          },
          {
            "name": "scope",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "examples": ["age:18+ age_assurance:tx identity:verified"]
            }
          },
          {
            "name": "code_challenge",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "code_challenge_method",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "const": "S256"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Redirects the user into the hosted CAIRL verification flow."
          },
          "400": {
            "description": "Missing, malformed, or unauthorized authorization parameter."
          }
        }
      }
    },
    "/api/oauth/token": {
      "post": {
        "summary": "Exchange authorization code for access token",
        "operationId": "exchangeOAuthCode",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "$ref": "#/components/schemas/OAuthTokenRequest"
              }
            },
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OAuthTokenRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Access token issued.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthTokenResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request, grant, redirect URI, or scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "Invalid client credentials.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthErrorResponse"
                }
              }
            }
          },
          "402": {
            "description": "Insufficient wallet balance; no token is issued."
          },
          "403": {
            "description": "Client inactive or enrollment blocked."
          },
          "429": {
            "description": "Token or partner spend rate limit exceeded."
          },
          "503": {
            "description": "Spend rate-limit service unavailable; request fails closed."
          }
        }
      }
    },
    "/api/oauth/userinfo": {
      "get": {
        "summary": "Read issuance-time verified claims",
        "operationId": "getOAuthUserinfo",
        "security": [
          {
            "BearerAccessToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Verified claims snapshot evaluated when the access token was issued.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserinfoResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, expired, revoked, or unknown bearer token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Userinfo rate limit exceeded."
          }
        }
      }
    },
    "/api/oauth/revoke": {
      "post": {
        "summary": "Revoke an OAuth access token",
        "description": "Implements RFC 7009 token revocation. Unknown, expired, or already revoked tokens still return 200.",
        "operationId": "revokeOAuthToken",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "$ref": "#/components/schemas/OAuthRevokeRequest"
              }
            },
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OAuthRevokeRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Revocation accepted. Response body is empty."
          },
          "400": {
            "description": "Malformed request body or missing required parameter."
          },
          "401": {
            "description": "Invalid client credentials."
          },
          "429": {
            "description": "Token endpoint rate limit exceeded."
          }
        }
      }
    },
    "/api/verify/hvf-session/{session_id}": {
      "get": {
        "summary": "Query hosted verification session status",
        "operationId": "getHostedVerificationSession",
        "security": [
          {
            "PartnerApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "session_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "examples": ["hvf_abc123"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Session status and metadata.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HvfSessionResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key."
          },
          "403": {
            "description": "API key does not own this session."
          },
          "404": {
            "description": "Session not found or expired."
          },
          "500": {
            "description": "Internal server error."
          }
        }
      }
    },
    "/api/integrations/shopify/verify/exchange": {
      "post": {
        "summary": "Exchange Shopify hosted verification code",
        "description": "**Not available in the default deployment (BLD-2950).** The Shopify integration is gated behind SHOPIFY_INTEGRATION_ENABLED, which is off unless a deploy sets it explicitly. While it is off this endpoint returns 404 with the body {\"error\":\"Not found\"} for every request, and any hvf_ token issued by the install or lookup endpoints is refused at /verify/start. Do not build against this contract without first confirming with CAIRL that the integration is enabled for your deployment.",
        "deprecated": true,
        "operationId": "exchangeShopifyVerificationCode",
        "parameters": [
          {
            "name": "X-CAIRL-Integration-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "examples": ["shopify"]
            }
          },
          {
            "name": "X-CAIRL-Timestamp",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "description": "Unix seconds used in the HMAC canonical message."
            }
          },
          {
            "name": "X-CAIRL-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^sha256=[0-9a-f]{64}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ShopifyExchangeRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verified Shopify buyer claims.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ShopifyExchangeResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request body."
          },
          "401": {
            "description": "Invalid HMAC signature or missing signing headers."
          },
          "404": {
            "description": "Shop or authorization code not found (bodies: shop_not_found, invalid_code) — or the Shopify integration is disabled for this deployment, which is the default and answers {\"error\":\"Not found\"}."
          },
          "422": {
            "description": "Verification did not produce a positive claim or enrollment was blocked."
          },
          "429": {
            "description": "Integration or partner spend rate limit exceeded."
          },
          "503": {
            "description": "Spend rate-limit service unavailable; request fails closed."
          }
        }
      }
    }
  },
  "webhooks": {
    "cairlEvents": {
      "post": {
        "summary": "Receive CAIRL outbound webhook events",
        "description": "CAIRL sends signed HTTP POST requests to the webhook URL configured on a partner API key.",
        "parameters": [
          {
            "name": "X-CAIRL-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^sha256=[0-9a-f]{64}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/VerificationSessionCompletedEvent"
                  },
                  {
                    "$ref": "#/components/schemas/VerificationSessionFailedEvent"
                  },
                  {
                    "$ref": "#/components/schemas/EnrollmentCreatedEvent"
                  },
                  {
                    "$ref": "#/components/schemas/VaeResolvedEvent"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Partner accepted the event. Any 2xx response acknowledges delivery."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "BearerAccessToken": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT"
      },
      "PartnerApiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Partner API key in the Authorization header."
      }
    },
    "schemas": {
      "ClaimsMap": {
        "type": "object",
        "description": "Verified claims keyed by requested claim identifier. CAIRL returns claims and policy-versioned age-assurance receipts, not raw identity documents or default raw PII.",
        "additionalProperties": {
          "oneOf": [
            {
              "type": "boolean"
            },
            {
              "type": "string"
            },
            {
              "type": "number"
            },
            {
              "type": "null"
            }
          ]
        },
        "examples": [
          {
            "age_18_plus": true,
            "identity_verified": true,
            "age_assurance_tx_age_over_18": true,
            "age_assurance_tx_age_over_16": true,
            "age_assurance_tx_threshold_met": true,
            "age_assurance_tx_decision_result": "pass",
            "age_assurance_tx_assurance_level": "standard",
            "age_assurance_tx_policy_version": "tx-hb1181-2026-06-22",
            "age_assurance_tx_evidence_receipt_id": "aar_tx_0123456789abcdef01234567"
          }
        ]
      },
      "OAuthTokenRequest": {
        "type": "object",
        "required": [
          "grant_type",
          "code",
          "client_id",
          "client_secret",
          "redirect_uri",
          "code_verifier"
        ],
        "properties": {
          "grant_type": {
            "type": "string",
            "const": "authorization_code"
          },
          "code": {
            "type": "string"
          },
          "client_id": {
            "type": "string"
          },
          "client_secret": {
            "type": "string"
          },
          "redirect_uri": {
            "type": "string",
            "format": "uri"
          },
          "code_verifier": {
            "type": "string"
          }
        }
      },
      "OAuthTokenResponse": {
        "type": "object",
        "required": ["access_token", "token_type", "expires_in", "scope"],
        "properties": {
          "access_token": {
            "type": "string"
          },
          "token_type": {
            "type": "string",
            "const": "Bearer"
          },
          "expires_in": {
            "type": "integer",
            "const": 3600
          },
          "scope": {
            "type": "string"
          }
        }
      },
      "UserinfoResponse": {
        "type": "object",
        "required": ["sub", "evaluated_at", "claims", "meta", "mode"],
        "properties": {
          "sub": {
            "type": "string",
            "description": "Stable, per-partner pseudonymous subject. Test credentials return a random pws_v1_test_ value and the keyless sandbox a random pws_v1_demo_ value."
          },
          "mode": {
            "type": "string",
            "enum": ["test", "live"],
            "description": "Whether the token was issued to a test credential (synthetic claim values, no billing) or a live one."
          },
          "evaluated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Token issuance timestamp. Stable for the lifetime of the token."
          },
          "claims": {
            "$ref": "#/components/schemas/ClaimsMap"
          },
          "meta": {
            "type": "object",
            "required": [
              "claims_requested",
              "claims_resolved",
              "claims_null",
              "claims_ignored"
            ],
            "properties": {
              "claims_requested": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "claims_resolved": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "claims_null": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "claims_ignored": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "OAuthRevokeRequest": {
        "type": "object",
        "required": ["token", "client_id", "client_secret"],
        "properties": {
          "token": {
            "type": "string"
          },
          "token_type_hint": {
            "type": "string",
            "enum": ["access_token"]
          },
          "client_id": {
            "type": "string"
          },
          "client_secret": {
            "type": "string"
          }
        }
      },
      "OAuthErrorResponse": {
        "type": "object",
        "required": ["error", "error_description"],
        "properties": {
          "error": {
            "type": "string"
          },
          "error_description": {
            "type": "string"
          }
        }
      },
      "HvfSessionResponse": {
        "type": "object",
        "required": ["success", "session"],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "session": {
            "type": "object",
            "required": [
              "sessionId",
              "status",
              "scopes",
              "isTestMode",
              "createdAt",
              "expiresAt"
            ],
            "properties": {
              "sessionId": {
                "type": "string"
              },
              "status": {
                "type": "string",
                "enum": ["pending", "authenticated", "verified", "complete"]
              },
              "scopes": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "isTestMode": {
                "type": "boolean"
              },
              "createdAt": {
                "type": "string",
                "format": "date-time"
              },
              "expiresAt": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        }
      },
      "ShopifyExchangeRequest": {
        "type": "object",
        "required": ["code", "redirect_uri", "shop_domain", "customer_id"],
        "properties": {
          "code": {
            "type": "string"
          },
          "redirect_uri": {
            "type": "string",
            "format": "uri"
          },
          "shop_domain": {
            "type": "string",
            "examples": ["merchant.myshopify.com"]
          },
          "customer_id": {
            "type": "string"
          }
        }
      },
      "ShopifyExchangeResponse": {
        "type": "object",
        "description": "Test and live exchanges return the same field set. Only the mode marker, the derivation of sub and enrollment_id, and the evidence behind the claim values differ.",
        "required": [
          "verified",
          "mode",
          "claims",
          "verification_level",
          "sub",
          "enrollment_id"
        ],
        "properties": {
          "verified": {
            "type": "boolean",
            "const": true
          },
          "mode": {
            "type": "string",
            "enum": ["test", "live"],
            "description": "Whether the authorization code came from a test credential (synthetic claim values, no enrollment, no billing) or a live one."
          },
          "claims": {
            "$ref": "#/components/schemas/ClaimsMap"
          },
          "verification_level": {
            "type": "string"
          },
          "sub": {
            "type": "string",
            "description": "Per-partner pseudonymous subject. In live mode this is the same stable pairwise value the OAuth token returns as sub. In test mode it is a random pws_v1_test_ value minted per exchange, so test runs are not correlatable to a later live exchange."
          },
          "enrollment_id": {
            "type": "string",
            "description": "Enrollment row id in live mode. In test mode a synthetic test_enr_ value, because no enrollment is created."
          }
        }
      },
      "VerificationSessionCompletedEvent": {
        "type": "object",
        "required": [
          "event",
          "event_id",
          "session_id",
          "partner_id",
          "pairwise_sub",
          "status",
          "scopes",
          "completed_at",
          "mode"
        ],
        "properties": {
          "event": {
            "type": "string",
            "const": "verification.session.completed"
          },
          "event_id": {
            "type": "string"
          },
          "session_id": {
            "type": "string"
          },
          "partner_id": {
            "type": "string"
          },
          "pairwise_sub": {
            "type": "string",
            "description": "Per-partner pairwise subject. In live mode this is the same value as the OAuth token sub, stable within your integration and not correlatable to the same user on another partner. In test mode (mode: \"test\") it is an independently generated pws_v1_test_ value that does not match the token sub and changes on every run; never join test events to tokens by this field."
          },
          "status": {
            "type": "string",
            "const": "complete"
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "completed_at": {
            "type": "string",
            "format": "date-time"
          },
          "mode": {
            "type": "string",
            "enum": ["test", "live"],
            "description": "Whether the event came from a test credential (synthetic claim values, no billing) or a live one. A test event's pairwise_sub is minted fresh per event and is not the stable live subject."
          }
        }
      },
      "VerificationSessionFailedEvent": {
        "type": "object",
        "required": [
          "event",
          "event_id",
          "session_id",
          "partner_id",
          "failure_reason",
          "failed_at",
          "mode"
        ],
        "properties": {
          "event": {
            "type": "string",
            "const": "verification.session.failed"
          },
          "event_id": {
            "type": "string"
          },
          "session_id": {
            "type": "string"
          },
          "partner_id": {
            "type": "string"
          },
          "failure_reason": {
            "type": "string"
          },
          "failed_at": {
            "type": "string",
            "format": "date-time"
          },
          "mode": {
            "type": "string",
            "enum": ["test", "live"],
            "description": "Whether the event came from a test credential or a live one."
          }
        }
      },
      "EnrollmentCreatedEvent": {
        "type": "object",
        "required": [
          "event",
          "event_id",
          "enrollment_id",
          "partner_id",
          "pairwise_sub",
          "created_at",
          "mode"
        ],
        "properties": {
          "event": {
            "type": "string",
            "const": "enrollment.created"
          },
          "event_id": {
            "type": "string"
          },
          "enrollment_id": {
            "type": "string"
          },
          "partner_id": {
            "type": "string"
          },
          "pairwise_sub": {
            "type": "string",
            "description": "Per-partner pairwise subject. In live mode this is the same value as the OAuth token sub, stable within your integration and not correlatable to the same user on another partner. In test mode (mode: \"test\") it is an independently generated pws_v1_test_ value that does not match the token sub and changes on every run; never join test events to tokens by this field."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "mode": {
            "type": "string",
            "enum": ["test", "live"],
            "description": "Always \"live\": enrollment is suppressed for test credentials, so this event only describes a real enrollment."
          }
        }
      },
      "VaeResolvedEvent": {
        "type": "object",
        "required": [
          "event",
          "event_id",
          "partner_id",
          "pairwise_sub",
          "claims",
          "resolved_at",
          "mode"
        ],
        "properties": {
          "event": {
            "type": "string",
            "const": "vae.resolved"
          },
          "event_id": {
            "type": "string"
          },
          "partner_id": {
            "type": "string"
          },
          "pairwise_sub": {
            "type": "string",
            "description": "Per-partner pairwise subject. In live mode this is the same value as the OAuth token sub, stable within your integration and not correlatable to the same user on another partner. In test mode (mode: \"test\") it is an independently generated pws_v1_test_ value that does not match the token sub and changes on every run; never join test events to tokens by this field."
          },
          "claims": {
            "$ref": "#/components/schemas/ClaimsMap"
          },
          "resolved_at": {
            "type": "string",
            "format": "date-time"
          },
          "mode": {
            "type": "string",
            "enum": ["test", "live"],
            "description": "Always \"live\": billable access events are suppressed for test credentials, so this event only describes a real charge."
          }
        }
      }
    }
  }
}
