{
  "openapi": "3.1.0",
  "info": {
    "title": "SP/1 Hub Surface",
    "version": "1.0.0",
    "summary": "The HTTP surface a Class H hub exposes.",
    "description": "Describes only the protocol surface: the endpoints another implementation must provide to claim SP/1 Class H, plus the public verification surface any party may call.\n\nDeliberately excluded are portal, admin, consumer-account, billing, and webhook-management endpoints. Those are operator concerns rather than protocol, and describing them would invite integrations against surfaces an operator is free to change. Also excluded is /v1/share/sandbox-consent, which is a demonstration affordance and not part of SP/1.\n\nAuthoritative for shape only. Canonicalization, fingerprints, and the audit chain are normative in the specification itself; where this document and the spec disagree, the spec wins.",
    "license": {
      "name": "CC BY 4.0"
    }
  },
  "servers": [
    {
      "url": "https://api.pryvc.com",
      "description": "PRYVC, the reference hub"
    }
  ],
  "tags": [
    {
      "name": "capture",
      "description": "Turning a form submission into a certificate."
    },
    {
      "name": "verification",
      "description": "Public, unauthenticated checking."
    },
    {
      "name": "withdrawal",
      "description": "The subject withdrawing consent."
    },
    {
      "name": "grants",
      "description": "Account-holder grants and redemption."
    },
    {
      "name": "evidence",
      "description": "Records and portable evidence."
    }
  ],
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Business API key. Scoped to the issuing business; never returns another tenant's records."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "examples": [
              "bad_request",
              "unauthorized",
              "forbidden",
              "not_found",
              "rate_limited"
            ]
          },
          "message": {
            "type": "string"
          }
        }
      },
      "CertificateRequest": {
        "type": "object",
        "required": [
          "client_id",
          "fields",
          "disclosure_text",
          "entities",
          "purpose",
          "page_url"
        ],
        "properties": {
          "client_id": {
            "type": "string",
            "description": "Public client id of the capturing business."
          },
          "fields": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Subject values. Stored encrypted; only a masked form is ever public."
          },
          "disclosure_text": {
            "type": "string",
            "maxLength": 5000,
            "description": "The verbatim text shown to the person."
          },
          "entities": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "minItems": 1,
            "maxItems": 25,
            "description": "Every organization named as receiving the data."
          },
          "entities_rejected": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 25,
            "description": "Recipients the person declined. Omit entirely when the form offered no per-recipient choice: an absent key means 'never asked' and an empty array means 'asked, declined none', and the two must not hash alike."
          },
          "purpose": {
            "type": "string"
          },
          "page_url": {
            "type": "string",
            "format": "uri"
          },
          "device": {
            "type": "object",
            "description": "Optional client-reported device details. Corroboration only, never part of the fingerprint."
          }
        }
      },
      "CertificateIssued": {
        "type": "object",
        "properties": {
          "cert_id": {
            "type": "string"
          },
          "fingerprint": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$"
          },
          "issued_at": {
            "type": "string",
            "format": "date-time"
          },
          "verify_url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "CertificatePublic": {
        "type": "object",
        "description": "The public view. Carries a masked subject only; the subject hash is deliberately not published, because a low-entropy digest would be a confirmation oracle.",
        "properties": {
          "cert_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "revoked"
            ]
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "business_name": {
            "type": "string"
          },
          "entities": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "entities_accepted": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Present only when the form offered a per-recipient choice."
          },
          "entities_rejected": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Present only when the form offered a per-recipient choice."
          },
          "purpose": {
            "type": "string"
          },
          "disclosure_text": {
            "type": "string"
          },
          "disclosure_hash": {
            "type": "string"
          },
          "fingerprint": {
            "type": "string"
          },
          "issued_at": {
            "type": "string",
            "format": "date-time"
          },
          "anchor": {
            "type": [
              "object",
              "null"
            ],
            "description": "External anchor receipt, when one exists."
          },
          "subject": {
            "type": "string",
            "description": "Masked subject. The subject HASH is deliberately never published \u2014 a low-entropy digest would be a confirmation oracle.",
            "examples": [
              "g***@e***.com"
            ]
          },
          "business": {
            "type": "string",
            "description": "Display name of the issuing business."
          },
          "origin_domain": {
            "type": "string"
          },
          "policies": {
            "type": "array",
            "description": "Policy snapshots captured at issuance.",
            "items": {
              "type": "object"
            }
          }
        }
      }
    }
  },
  "paths": {
    "/v1/consent": {
      "post": {
        "tags": [
          "capture"
        ],
        "summary": "Issue a consent certificate",
        "description": "Accountless capture: the subject needs no account with the hub. The submitting origin must match a domain the business has verified, or the request is refused. This is the origin-verification obligation of Class H.\n\nCapture is fail-open from the host page's perspective: a hub failure must never block the visitor's submission.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CertificateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Certificate issued",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CertificateIssued"
                }
              }
            }
          },
          "400": {
            "description": "Invalid payload"
          },
          "403": {
            "description": "Origin is not a verified domain"
          },
          "429": {
            "description": "Rate limited"
          }
        }
      }
    },
    "/v1/certs/{certId}/verify": {
      "get": {
        "tags": [
          "verification"
        ],
        "summary": "Verify a certificate (public)",
        "description": "Unauthenticated by design: a verifier needs no relationship with the hub. Returns the public view plus any anchor receipt.",
        "parameters": [
          {
            "name": "certId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The certificate",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CertificatePublic"
                }
              }
            }
          },
          "404": {
            "description": "No such certificate"
          }
        }
      }
    },
    "/v1/certs/{certId}/opt-out/request": {
      "post": {
        "tags": [
          "withdrawal"
        ],
        "summary": "Request a code to withdraw consent",
        "description": "The subject is accountless, so control is proven by a code emailed to the address recorded on the certificate.\n\nThe response is identical whether or not the address matches. Certificate ids are public, so any difference, including a differing status code when the send itself fails, would let a holder probe for the subject's address. Implementations must not distinguish the two cases.",
        "parameters": [
          {
            "name": "certId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Always returned, match or not",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sent": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Rate limited"
          }
        }
      }
    },
    "/v1/certs/{certId}/opt-out/confirm": {
      "post": {
        "tags": [
          "withdrawal"
        ],
        "summary": "Confirm withdrawal",
        "description": "Flips the certificate to revoked and starts the cease-contact clock of 10 business days. Never deletes: the record and every hash remain, with the withdrawal timestamped, so the chain evidences both the agreement and its end.\n\nA wrong address and a wrong code must return the same failure.",
        "parameters": [
          {
            "name": "certId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "code"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "code": {
                    "type": "string",
                    "pattern": "^[0-9]{6}$"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Consent withdrawn",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "revoked": {
                      "type": "boolean"
                    },
                    "cease_by": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Address or code invalid, indistinguishable by design"
          },
          "429": {
            "description": "Rate limited"
          }
        }
      }
    },
    "/v1/certs": {
      "get": {
        "tags": [
          "evidence"
        ],
        "summary": "List certificates",
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Paged certificates for the calling business"
          },
          "401": {
            "description": "Missing or invalid API key"
          }
        }
      }
    },
    "/v1/certs/{certId}": {
      "get": {
        "tags": [
          "evidence"
        ],
        "summary": "Certificate detail",
        "description": "Includes the decrypted subject, and is therefore restricted to the issuing business.",
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "certId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The certificate"
          },
          "401": {
            "description": "Missing or invalid API key"
          },
          "404": {
            "description": "Not found, or not yours"
          }
        }
      }
    },
    "/v1/certs/{certId}/evidence-pack": {
      "get": {
        "tags": [
          "evidence"
        ],
        "summary": "Export a portable evidence pack",
        "description": "A self-contained record that verifies offline, without the hub. Derived hashes are recomputed from the shipped record rather than read from storage, and export fails if they disagree with the fingerprint sealed at issuance.",
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "certId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Evidence pack, sent as an attachment with no-store"
          },
          "401": {
            "description": "Missing or invalid API key"
          },
          "500": {
            "description": "Recomputed hashes disagree with the sealed fingerprint"
          }
        }
      }
    },
    "/v1/authorize": {
      "get": {
        "tags": [
          "grants"
        ],
        "summary": "Begin a grant authorization",
        "description": "Entry point for the account-holder grant flow. Uses PKCE; the hub records the requesting origin and pins redemption to it.",
        "responses": {
          "200": {
            "description": "Authorization surface"
          },
          "400": {
            "description": "Invalid request"
          }
        }
      }
    },
    "/v1/share/consent": {
      "post": {
        "tags": [
          "grants"
        ],
        "summary": "Approve a grant",
        "description": "The account holder approves the requested fields, purpose, and duration. The hub never ticks this on their behalf.",
        "responses": {
          "200": {
            "description": "Authorization code"
          },
          "401": {
            "description": "Authentication required"
          }
        }
      }
    },
    "/v1/share/redeem": {
      "post": {
        "tags": [
          "grants"
        ],
        "summary": "Redeem an authorization code",
        "description": "Exchanges the code for the granted values. Single use, PKCE-verified, and bound to the origin recorded at authorization.",
        "responses": {
          "200": {
            "description": "Granted values"
          },
          "400": {
            "description": "Invalid or spent code"
          },
          "403": {
            "description": "Origin mismatch"
          }
        }
      }
    },
    "/v1/consumers/{shareId}/current": {
      "get": {
        "tags": [
          "grants"
        ],
        "summary": "Current values for a grant",
        "description": "Always-fresh values while the grant is active. Refuses once the grant has expired, been revoked, or been ceased.",
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "shareId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current values"
          },
          "401": {
            "description": "Missing or invalid API key"
          },
          "409": {
            "description": "Grant is not active"
          }
        }
      }
    },
    "/v1/shares": {
      "get": {
        "tags": [
          "grants"
        ],
        "summary": "List grants",
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Paged grants for the calling business"
          },
          "401": {
            "description": "Missing or invalid API key"
          }
        }
      }
    },
    "/v1/shares/{id}": {
      "get": {
        "tags": [
          "grants"
        ],
        "summary": "Grant detail",
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The grant"
          },
          "401": {
            "description": "Missing or invalid API key"
          },
          "404": {
            "description": "Not found, or not yours"
          }
        }
      }
    },
    "/v1/shares/{id}/revoke": {
      "post": {
        "tags": [
          "grants"
        ],
        "summary": "Revoke a grant",
        "description": "The account holder withdrawing a grant. Starts the cease-contact clock.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Revoked"
          },
          "401": {
            "description": "Authentication required"
          }
        }
      }
    },
    "/v1/shares/{id}/cease-ack": {
      "post": {
        "tags": [
          "grants"
        ],
        "summary": "Acknowledge a cease obligation",
        "description": "The business recording that it has ceased contact, before the deadline. The acknowledgement itself becomes evidence.",
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Acknowledged"
          },
          "401": {
            "description": "Missing or invalid API key"
          }
        }
      }
    },
    "/v1/events": {
      "get": {
        "tags": [
          "grants"
        ],
        "summary": "Outbound event feed",
        "description": "Lifecycle events for the calling business: grant created, expiring, expired, revoked, renewed, subject fields updated, cease obligations, and certificate withdrawals.",
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Paged events"
          },
          "401": {
            "description": "Missing or invalid API key"
          }
        }
      }
    }
  }
}
