openapi: 3.1.0
info:
  title: BA | Stamp API
  version: "1.0"
  summary: Programmatic blockchain timestamping with legal evidentiary value
  description: |
    The BA | Stamp API lets you submit document hashes for blockchain
    anchoring on Polygon (primary, confirms in seconds) plus Bitcoin via
    OpenTimestamps (secondary, redundant). Files never leave the caller's
    machine — only the SHA-256 hash is sent.

    ## Getting started

    From zero to your first anchored timestamp in under five minutes.

    **1. Create an account.** Sign up at <https://bastamp.com/signin>.
    Every new account gets **1 free credit** so you can stamp your first
    document without a card on file.

    **2. Mint an API key.** Go to <https://bastamp.com/account/api-keys>,
    click *Create a new key*, give it a name (e.g. `ci-pipeline`), and
    copy the value. The plain key is shown **once** — store it somewhere
    safe (1Password, GitHub Actions secret, etc.). Format:
    `ba_live_<32 hex chars>`.

    **3. Stamp your first file.** Hash the file locally with SHA-256 and
    `POST` the hash. The file itself never leaves your machine.

    ```bash
    HASH=0x$(sha256sum my-document.pdf | cut -d' ' -f1)
    curl https://bastamp.com/api/v1/stamps \
      -H "Authorization: Bearer $BASTAMP_API_KEY" \
      -H "Content-Type: application/json" \
      -d "{\"contentHash\":\"$HASH\",\"fileName\":\"my-document.pdf\"}"
    ```

    Response: an `ownershipId` and `status: "pending"`. The on-chain
    anchor lands at the next 5-minute batch tick.

    **4. Get the certificate** once anchored:

    ```bash
    curl -L https://bastamp.com/api/v1/stamps/$HASH/certificate \
      -H "Authorization: Bearer $BASTAMP_API_KEY" \
      -o certificate.pdf
    ```

    Returns `409 not_anchored` while pending — retry after the next batch
    or subscribe to a webhook (below).

    **5. (recommended) Subscribe to webhooks** so you don't poll. Add an
    endpoint at <https://bastamp.com/account/webhooks>, pick the events
    you care about (`stamp.anchored`, `batch.anchored`, `ots.upgraded`),
    and verify each delivery's HMAC signature on your side. See the
    *Webhooks* section below for the payload shape and verification code.

    **6. Buy more credits** when the free credit runs out — same packs
    apply whether you use the dashboard or the API:
    <https://bastamp.com/#pricing>. Volume / SLA / contracted pricing
    via Enterprise sales.

    ### Use BA | Stamp from CI

    For GitHub Actions there's a wrapper around this API:
    <https://github.com/BA-BlockchainAnalysis/stamp-action>. One YAML
    block stamps every release artifact, with idempotency tied to the
    workflow run id so retries don't double-charge.

    ```yaml
    - uses: BA-BlockchainAnalysis/stamp-action@v1
      with:
        api-key: ${{ secrets.BASTAMP_API_KEY }}
        files: dist/*.tar.gz
    ```

    ## Authentication
    Every request must include `Authorization: Bearer ba_live_<32 hex>`.
    Manage keys at <https://bastamp.com/account/api-keys>. The key's plain
    value is shown only once at creation; we store a SHA-256 hash at rest.

    ## Idempotency
    All `POST` endpoints accept an optional `Idempotency-Key` header
    (1..255 chars). Replaying the same key with the same payload returns
    the cached response (`Idempotent-Replayed: true`). Replaying with a
    different payload returns `409 idempotency_conflict`. Cache TTL is 24h.

    ## Errors
    Every non-2xx response has the shape
    `{ "error": { "type": "<machine_code>", "message": "<human>" } }`.

    ## Asynchronous anchoring
    Stamps are batched and anchored on-chain every 5 minutes. A new stamp
    starts in `status: "pending"`; the certificate is only available once
    `status: "anchored"`. Subscribe to the `stamp.anchored` webhook to be
    notified, or poll `GET /v1/stamps/{hash}`.

    ## AI provenance

    The same `POST /stamps` endpoint covers AI-generated content with a
    standard manifest convention. The caller builds a canonical JSON
    manifest committing to the model + prompt hash + output hash +
    generation params, hashes it locally, and anchors the hash. **The
    prompt and output themselves never leave your machine.**

    Manifest schema (schema id `bastamp.ai-provenance/v1`):

    ```json
    {
      "schema": "bastamp.ai-provenance/v1",
      "model": "gpt-5",
      "modelVersion": "2026-04-15",
      "promptHash": "0x<sha256 of prompt>",
      "outputHash": "0x<sha256 of output>",
      "generatedAt": "2026-05-12T12:00:00.000Z",
      "params": { "temperature": 0.7, "seed": 42 },
      "metadata": { "requestId": "..." }
    }
    ```

    Canonicalization rule: sort keys alphabetically at every depth, no
    whitespace, standard JSON escaping. This must be byte-identical
    across producer and verifier; the open-source verifier and the SDK
    use the same algorithm.

    Using the official TypeScript SDK (one call):

    ```ts
    import { BAStamp } from "@bastamp/sdk";

    const client = new BAStamp({ apiKey: process.env.BASTAMP_API_KEY! });
    const r = await client.aiProvenance.attest({
      model: "gpt-5",
      prompt: userPrompt,        // hashed locally
      output: completion.text,   // hashed locally
      params: { temperature: 0.7, seed: 42 },
    });
    // r.manifest      → save with the AI output
    // r.manifestHash  → anchored on chain (1 credit)
    ```

    Raw HTTP equivalent (any language with SHA-256 + HTTP):

    ```bash
    MANIFEST='{"generatedAt":"2026-05-12T12:00:00.000Z","model":"gpt-5","outputHash":"0x...","promptHash":"0x...","schema":"bastamp.ai-provenance/v1"}'
    HASH=0x$(printf '%s' "$MANIFEST" | sha256sum | cut -d' ' -f1)

    curl https://bastamp.com/api/v1/stamps \
      -H "Authorization: Bearer $BASTAMP_API_KEY" \
      -H "Content-Type: application/json" \
      -d "{\"contentHash\":\"$HASH\",\"mimeType\":\"application/x-bastamp-ai-provenance\",\"fileName\":\"ai-provenance-gpt-5.json\"}"
    ```

    The MIME marker `application/x-bastamp-ai-provenance` lets the
    public verify page recognize the manifest format and render
    model + prompt hash + output hash + params inline when a verifier
    drops the file. Without the marker the endpoint still accepts the
    stamp; only the rendering changes.

    See <https://bastamp.com/use-cases/ai-provenance> for the EU AI
    Act Art. 50 framing and a full integration example.

    ## Multi-file projects

    `POST /stamps/batch` anchors every file in a project AND a project
    manifest binding them all into one on-chain group. **N + 1 credits
    for N files** — same per-file pricing as bulk upload, plus one
    additional credit for the manifest anchor that gives the project its
    own URL. Per-file verification works two ways: the standard
    `/verify/<file-hash>` URL (because each file has its own stamp) or
    via membership check on the manifest. Max project size: 99 files
    (batch endpoint cap of 100, leaving room for the manifest). All N+1
    stamps share the same on-chain batch — atomic project.

    Manifest schema (schema id `bastamp.project/v1`):

    ```json
    {
      "schema": "bastamp.project/v1",
      "name": "Case 2025-0042",
      "description": "Exhibits A through G",
      "createdAt": "2026-05-13T12:00:00.000Z",
      "files": [
        { "name": "exhibit-a.pdf", "sha256": "0x...", "size": 184326 },
        { "name": "exhibit-b.pdf", "sha256": "0x...", "size": 92044 }
      ],
      "metadata": { "matterId": "..." }
    }
    ```

    Same canonicalization rule as `bastamp.ai-provenance/v1` (sorted keys,
    no whitespace). The hash of the canonicalized manifest is what we
    anchor.

    Using the official TypeScript SDK:

    ```ts
    import { BAStamp } from "@bastamp/sdk";
    import { readFile } from "node:fs/promises";

    const client = new BAStamp({ apiKey: process.env.BASTAMP_API_KEY! });
    const r = await client.projects.stamp({
      name: "Case 2025-0042",
      description: "Exhibits A through G",
      files: [
        { name: "exhibit-a.pdf", content: await readFile("exhibit-a.pdf") },
        { name: "exhibit-b.pdf", content: await readFile("exhibit-b.pdf") },
      ],
    });
    // r.manifest      → save with the project (verification artifact)
    // r.manifestHash  → anchored on chain (project URL)
    // r.fileStamps[]  → one per file, each has its own /verify/<contentHash>
    // r.creditsCharged → typically N + 1 (less if any files were already owned)
    ```

    For private files: pre-hash and pass `sha256` per file instead of
    `content` — the bytes never enter the SDK.

    The MIME marker `application/x-bastamp-project` lets the public verify
    page recognize the manifest format, render the file list inline, and
    expose a per-file membership check ("drop a file from this project, I
    tell you whether its SHA-256 is in the manifest"). Without the marker
    the endpoint still accepts the stamp; only the rendering changes.

    See <https://bastamp.com/use-cases/projects> for the legal-/IP-/
    archive-use framing.

    ## Verifying a webhook signature

    Each delivery carries `X-BAStamp-Signature: t=<unix>,v1=<hex>` where
    `v1` is `HMAC-SHA256(secret, "<t>.<rawBody>")`. Reject if the
    timestamp skew is > 5 minutes or the HMAC doesn't match.

    Node example:

    ```js
    import { createHmac, timingSafeEqual } from "node:crypto";

    function verify(secret, rawBody, header, toleranceSec = 300) {
      const parts = Object.fromEntries(
        header.split(",").map((p) => p.split("=").map((s) => s.trim())),
      );
      const ts = Number(parts.t);
      if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
      const expected = createHmac("sha256", secret)
        .update(`${ts}.${rawBody}`)
        .digest("hex");
      const a = Buffer.from(expected, "hex");
      const b = Buffer.from(parts.v1, "hex");
      return a.length === b.length && timingSafeEqual(a, b);
    }
    ```

  contact:
    name: BA | Stamp support
    url: https://bastamp.com/support
servers:
  - url: https://bastamp.com/api/v1
    description: Production
security:
  - apiKey: []
tags:
  - name: Stamps
    description: Create and inspect timestamps.
  - name: Account
    description: Account-level resources.

paths:
  /stamps:
    post:
      tags: [Stamps]
      summary: Create a stamp
      description: |
        Submits a single SHA-256 hash for anchoring. Charges 1 credit on
        success. Returns `200` with `duplicate: true` if the caller already
        owns this stamp (no charge).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contentHash]
              properties:
                contentHash:
                  $ref: "#/components/schemas/ContentHash"
                fileName:
                  type: string
                  description: Original filename, stored as ownership metadata only.
                  example: "contract-2026.pdf"
                fileSize:
                  type: integer
                  description: Bytes, metadata only.
                mimeType:
                  type: string
                  example: "application/pdf"
                jurisdiction:
                  type: string
                  description: ISO 3166-1 alpha-2; controls the legal-framing block on the certificate.
                  example: "IT"
                locale:
                  type: string
                  description: BCP-47 locale for the certificate PDF.
                  example: "it"
            examples:
              minimal:
                summary: Minimal request
                value:
                  contentHash: "0x538187406b405e9039253cd009f4ba1965934a5f24eca0a8e0a692ff0bc8693e"
              full:
                summary: With metadata
                value:
                  contentHash: "0x538187406b405e9039253cd009f4ba1965934a5f24eca0a8e0a692ff0bc8693e"
                  fileName: "contract-2026.pdf"
                  fileSize: 184326
                  mimeType: "application/pdf"
                  jurisdiction: "IT"
                  locale: "it"
      responses:
        "201":
          description: Stamp created — first time this caller stamps this hash.
          headers:
            Idempotent-Replayed:
              schema: { type: string, enum: ["true"] }
              description: Present (value `true`) when the response was served from the idempotency cache.
          content:
            application/json:
              schema:
                type: object
                properties:
                  stamp:
                    $ref: "#/components/schemas/StampResult"
                  creditsCharged:
                    type: integer
                    example: 1
        "200":
          description: "Caller already owns this stamp; `duplicate: true`, no charge."
          content:
            application/json:
              schema:
                type: object
                properties:
                  stamp:
                    $ref: "#/components/schemas/StampResult"
                  creditsCharged:
                    type: integer
                    example: 0
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/NoCredits" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /stamps/batch:
    post:
      tags: [Stamps]
      summary: Create up to 100 stamps in one call
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [contentHash]
                    properties:
                      contentHash: { $ref: "#/components/schemas/ContentHash" }
                      fileName: { type: string }
                      fileSize: { type: integer }
                      mimeType: { type: string }
                      jurisdiction: { type: string }
                jurisdiction:
                  type: string
                  description: Default applied to every item that doesn't set its own.
                locale:
                  type: string
                  description: Locale stored on each ownership row.
            example:
              items:
                - contentHash: "0x447587b9407a847586644df9c2b61a434f7356e018e380538f0900648dcef65a"
                  fileName: "report.pdf"
                - contentHash: "0x8d466826725e6df301101dbd578095204ca5526134f94be3922dfb30387e7866"
                  fileName: "annex.pdf"
              jurisdiction: "IT"
              locale: "it"
      responses:
        "201":
          description: Batch processed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/StampResult" }
                  creditsCharged:
                    type: integer
                  duplicateCount:
                    type: integer
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/NoCredits" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /stamps/{hash}:
    get:
      tags: [Stamps]
      summary: Get a stamp's status and anchors
      parameters:
        - in: path
          name: hash
          required: true
          schema: { $ref: "#/components/schemas/ContentHash" }
      responses:
        "200":
          description: Stamp found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stamp" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /stamps/{hash}/certificate:
    get:
      tags: [Stamps]
      summary: Download the certificate PDF
      description: |
        Returns `application/pdf` once the stamp is anchored. While pending,
        returns `409 not_anchored` — retry after the next batch (~5 min).
      parameters:
        - in: path
          name: hash
          required: true
          schema: { $ref: "#/components/schemas/ContentHash" }
        - in: query
          name: locale
          required: false
          schema: { type: string, default: en }
        - in: query
          name: jurisdiction
          required: false
          schema: { type: string }
      responses:
        "200":
          description: PDF stream.
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Stamp is still pending.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /stamps/{hash}/forensic-packet:
    get:
      tags: [Stamps]
      summary: Download a forensic packet ZIP for legal use
      description: |
        Returns a single ZIP with everything a court, auditor, or expert
        witness needs to verify the stamp without trusting bastamp.com:

        - `certificate.pdf` — localized human-readable certificate.
        - `proof.json` — machine-readable Merkle proof + on-chain anchor
          data, usable by the open-source verifier at
          [stamp-verify/stamp-verify](https://github.com/stamp-verify/stamp-verify).
        - `README.txt` — plain-language step-by-step verification
          instructions (recompute SHA-256, inspect Polygon tx,
          run stamp-verify CLI).

        Returns `409 not_anchored` while the stamp is still pending —
        retry after the next batch (~5 min).
      parameters:
        - in: path
          name: hash
          required: true
          schema: { $ref: "#/components/schemas/ContentHash" }
        - in: query
          name: locale
          required: false
          schema: { type: string, default: en }
          description: BCP-47 locale for the certificate PDF and README.
        - in: query
          name: jurisdiction
          required: false
          schema: { type: string }
          description: ISO 3166-1 alpha-2 — drives the certificate's legal framing.
      responses:
        "200":
          description: ZIP archive.
          content:
            application/zip:
              schema:
                type: string
                format: binary
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Stamp is still pending.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /account:
    get:
      tags: [Account]
      summary: Get current credit balance
      responses:
        "200":
          description: Account snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  email: { type: string }
                  credits: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }

webhooks:
  stamp.anchored:
    post:
      summary: A stamp owned by you has been anchored on Polygon
      description: |
        Fired once per (stamp, owner) pair after the 5-minute batch confirms
        on Polygon. Stable `eventId` on `evt_stamp.anchored_<ownershipId>`
        so retries dedupe.
      requestBody:
        $ref: "#/components/requestBodies/WebhookEnvelope"
      responses:
        "2XX":
          description: Acknowledged. Any non-2xx triggers retry per schedule.

  batch.anchored:
    post:
      summary: A batch containing your stamps has been anchored
      requestBody:
        $ref: "#/components/requestBodies/WebhookEnvelope"
      responses:
        "2XX":
          description: Acknowledged.

  ots.upgraded:
    post:
      summary: OTS proof confirmed on Bitcoin for one of your batches
      description: |
        Fired when the OTS calendar's transaction is mined and we set
        `bitcoinBlockHeight` on the batch. Use this to update your local
        record with the second anchor.
      requestBody:
        $ref: "#/components/requestBodies/WebhookEnvelope"
      responses:
        "2XX":
          description: Acknowledged.

  webhook.test:
    post:
      summary: Test event sent from the dashboard "Send test" button
      description: |
        Identical signing path as production events; type is intentionally
        distinct so consumer dispatch by type doesn't trigger real workflows.
      requestBody:
        $ref: "#/components/requestBodies/WebhookEnvelope"
      responses:
        "2XX":
          description: Acknowledged.

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: ba_live_<32 hex>
      description: |
        Send `Authorization: Bearer ba_live_<32 hex>`. Mint and revoke at
        /account/api-keys.

  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      schema: { type: string, minLength: 1, maxLength: 255 }
      description: |
        Client-chosen unique key (UUIDv4 recommended). Replays with the
        same key+payload return the cached response within 24h.

  schemas:
    ContentHash:
      type: string
      pattern: "^0x[0-9a-f]{64}$"
      description: 0x-prefixed lowercase SHA-256.
      example: "0x538187406b405e9039253cd009f4ba1965934a5f24eca0a8e0a692ff0bc8693e"

    StampResult:
      type: object
      properties:
        ownershipId: { type: string, example: "cmotyqtoz0001t8hevuimbddy" }
        stampId: { type: string, example: "cmotyqtn60000t8hepz3shirz" }
        contentHash: { $ref: "#/components/schemas/ContentHash" }
        fileName: { type: string, nullable: true }
        duplicate:
          type: boolean
          description: true when the caller already owned this stamp; no credit was charged.

    Stamp:
      type: object
      properties:
        contentHash: { $ref: "#/components/schemas/ContentHash" }
        status:
          type: string
          enum: [pending, anchored]
        createdAt: { type: string, format: date-time }
        merkleProof:
          oneOf:
            - type: array
              items: { type: string }
            - { type: "null" }
        anchor:
          oneOf:
            - $ref: "#/components/schemas/Anchor"
            - { type: "null" }

    Anchor:
      type: object
      properties:
        chain:
          type: string
          enum: [polygon, polygon-amoy]
        merkleRoot: { type: string }
        txHash: { type: string }
        blockNumber: { type: integer }
        blockTime: { type: string, format: date-time }
        bitcoin:
          type: object
          properties:
            status:
              type: string
              enum: [pending, confirmed]
            blockHeight: { type: integer, nullable: true }
            blockTime: { type: string, format: date-time, nullable: true }

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, message]
          properties:
            type:
              type: string
              description: Machine-readable code (snake_case).
              example: invalid_request
            message:
              type: string
              description: Human-readable detail.

    WebhookSignatureHeader:
      type: string
      description: |
        `X-BAStamp-Signature: t=<unix>,v1=<hex>` where `v1` is
        `HMAC-SHA256(secret, "<t>.<rawBody>")`. Tolerance is 5 minutes.
      example: "t=1778106323,v1=3219969a58026f58ee1529edae45583af907739c61a5259be3b97d745c9bd2e4"

    WebhookEnvelope:
      type: object
      required: [id, type, created, data]
      properties:
        id:
          type: string
          description: Stable per-event id; safe to dedupe on.
          example: "evt_stamp.anchored_cmotyqtoz0001t8hevuimbddy"
        type:
          type: string
          example: "stamp.anchored"
        created:
          type: integer
          description: Unix timestamp.
          example: 1778106323
        data:
          type: object
          additionalProperties: true
          description: Event-specific payload. Shape depends on `type`.

  requestBodies:
    WebhookEnvelope:
      description: |
        BA Stamp will POST this envelope to your endpoint with the signed
        signature header. Verify by recomputing
        `HMAC-SHA256(your_secret, "<t>.<rawBody>")` and comparing to `v1`.
      required: true
      content:
        application/json:
          schema: { $ref: "#/components/schemas/WebhookEnvelope" }

  responses:
    InvalidRequest:
      description: Malformed payload, hash, or parameter.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              type: invalid_request
              message: "contentHash must match /^0x[0-9a-f]{64}$/"
    Unauthorized:
      description: Missing or invalid API key.
      headers:
        WWW-Authenticate:
          schema: { type: string, example: 'Bearer realm="bastamp"' }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              type: unauthorized
              message: "missing Authorization: Bearer header"
    NoCredits:
      description: Account has fewer credits than the request requires.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              type: no_credits
              message: "account has no remaining credits"
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              type: not_found
              message: "no stamp for this hash"
    IdempotencyConflict:
      description: Idempotency-Key reused with a different payload.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              type: idempotency_conflict
              message: "Idempotency-Key reused with a different payload"
