{
  "openapi": "3.1.0",
  "info": {
    "title": "PlotDR Product API",
    "version": "1.0.0",
    "description": "PlotDR's customer-facing API: EUDR (EU Deforestation Regulation) plot\ndue-diligence checks, backed by a self-hosted mirror of Hansen/GLAD/RADD/\nWDPA/SBTN Natural Lands/peat/primary-forest/fire-alert/NASA OPERA\nDIST-ALERT and DIST-ANN data.\n\nUsed identically by external API integrators (Bearer API key) and by\nPlotDR's own website UI (session cookie); every endpoint accepts either\ncredential and resolves to the same organization/user identity.\n\nThis document describes the API *contract* only. It is intentionally\nsilent on implementation language, framework, or datastore. Those are a\nseparate decision.\n\n**Scope of `checks` results**: risk screening covers geospatial\ndeforestation and fire-alert signals only. A\n`compliant` or `non_compliant` verdict is one input to, not a\nsubstitute for, the full risk assessment required under EUDR\nArticle 10(2), most of whose ~14 criteria are non-geospatial and not\nassessed by this API.\n",
    "contact": {
      "name": "PlotDR"
    }
  },
  "servers": [
    {
      "url": "https://api.plotdr.ch/v1"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "cookieAuth": []
    }
  ],
  "tags": [
    {
      "name": "auth",
      "description": "Website session auth."
    },
    {
      "name": "org",
      "description": "Organization profile, team members, API keys."
    },
    {
      "name": "commodities",
      "description": "EUDR commodity reference data (read-only, seeded)."
    },
    {
      "name": "suppliers",
      "description": "Supply-chain suppliers."
    },
    {
      "name": "plots",
      "description": "Registered plots of land."
    },
    {
      "name": "checks",
      "description": "EUDR risk checks (registered-plot or ad hoc)."
    },
    {
      "name": "batches",
      "description": "Async portfolio-scale checks."
    },
    {
      "name": "reports",
      "description": "Async due-diligence report generation."
    },
    {
      "name": "billing",
      "description": "Stripe subscription checkout and customer portal."
    },
    {
      "name": "support",
      "description": "In-product problem reports filed from the dashboard. Unlike `/contact`, the caller is authenticated, so the reporter's identity is derived server-side rather than taken from the payload."
    },
    {
      "name": "health",
      "description": "Unauthenticated liveness/readiness probes. Unlike every other path in this document, these are mounted at the service root, not under `/v1` (see the path-level `servers` override on each); they back PlotDR's own uptime monitoring, not product API consumers."
    },
    {
      "name": "contact",
      "description": "Unauthenticated marketing-site contact form submission. Like `/health*`, mounted at the service root, not under `/v1`. Consumed by the PlotDR marketing website, not product API consumers."
    }
  ],
  "paths": {
    "/health": {
      "servers": [
        {
          "url": "https://api.plotdr.ch",
          "description": "Root (not under /v1, unlike every other path here)."
        }
      ],
      "get": {
        "tags": [
          "health"
        ],
        "summary": "Liveness probe",
        "description": "200 whenever the process is up and serving requests. No dependency checks; do not use this to infer DB/Redis/object-store/worker health, use `/health/ready` for that.",
        "security": [],
        "responses": {
          "200": {
            "description": "Process is alive.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status"
                  ],
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "ok"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/health/ready": {
      "servers": [
        {
          "url": "https://api.plotdr.ch",
          "description": "Root (not under /v1, unlike every other path here)."
        }
      ],
      "get": {
        "tags": [
          "health"
        ],
        "summary": "Readiness probe",
        "description": "200 only if every dependency probe (database, Redis, object store, and the background worker's Redis heartbeat) passes; 503 otherwise, with `checks` naming which probe(s) failed. The worker heartbeat is deliberately folded into readiness, so an outage in background job processing (report generation, batch screening) is visible here too, not just database or cache health. The result is cached for 5 seconds server side to bound load from repeated probing.",
        "security": [],
        "responses": {
          "200": {
            "description": "All dependencies healthy.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReadinessStatus"
                }
              }
            }
          },
          "503": {
            "description": "One or more dependencies unhealthy; see `checks`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReadinessStatus"
                }
              }
            }
          }
        }
      }
    },
    "/contact": {
      "servers": [
        {
          "url": "https://api.plotdr.ch",
          "description": "Root (not under /v1, unlike every other path here)."
        }
      ],
      "post": {
        "tags": [
          "contact"
        ],
        "summary": "Submit the marketing-site contact form",
        "description": "Unauthenticated. Rate-limited per client IP (5 requests per hour); a limit hit responds 429. `website` is a honeypot field: a real visitor never sees or fills it, so any non-empty value is treated as bot traffic and silently dropped. The response is still 202 either way, so a bot can't distinguish a dropped submission from a delivered one.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "email",
                  "message"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200
                  },
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "message": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 5000
                  },
                  "website": {
                    "type": "string",
                    "description": "Honeypot field; leave empty."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Request accepted (email sent unless the honeypot field was filled)."
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/signup": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Create a new organization and its first admin user",
        "description": "One-shot bootstrap: creates the Organization and its first User with\nrole `admin` (unverified), and sends a verification email to that\naddress. No session is established. The user must verify their email\n(via `POST /auth/verify-email`) before they can log in. There is no\nseparate \"create organization\" endpoint; every organization comes into\nexistence through this call.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "org_name",
                  "email",
                  "password",
                  "accept_terms"
                ],
                "properties": {
                  "org_name": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "password": {
                    "type": "string",
                    "format": "password",
                    "minLength": 12
                  },
                  "locale": {
                    "type": "string",
                    "description": "Preferred email language (an EU locale code); defaults to en."
                  },
                  "accept_terms": {
                    "type": "boolean",
                    "description": "Must be true -- the signer accepts the Terms of Service and Privacy Policy (the Data Processing Agreement applies by reference). Recorded against the new User with a timestamp and document version."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Organization and admin user (unverified) created; a verification email has been sent. No session is established.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/auth/login": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Website session login",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "password"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "password": {
                    "type": "string",
                    "format": "password"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Session established (cookie set).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Email not verified; the user must verify their email before logging in.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/auth/me": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Get the currently logged-in user",
        "description": "Session-only (an API key has no associated user and gets 401 here) --\nexists so the dashboard can re-derive the logged-in user's identity\nafter a reload, without a fresh login. `/orgs/me` returns the\n*organization's* profile, not the user's own email/role.\n",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/auth/logout": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "End the current website session",
        "responses": {
          "204": {
            "description": "Logged out."
          }
        }
      }
    },
    "/auth/password-reset/request": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Request a password-reset email",
        "description": "Always responds 202, whether or not `email` belongs to a registered\naccount -- this never leaks account existence. If it does, a\ntime-limited reset token is emailed to that address.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Request accepted (email sent only if the address is registered)."
          }
        }
      }
    },
    "/auth/password-reset/confirm": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Consume a password-reset token and set a new password",
        "description": "Consumes the single-use token from the reset email, sets the new\npassword, and revokes the token and all of the user's existing\nsessions.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token",
                  "new_password"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  },
                  "new_password": {
                    "type": "string",
                    "format": "password",
                    "minLength": 12
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Password updated",
            "token and existing sessions revoked.": null
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/auth/change-password": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Change the current user's password",
        "description": "Requires a logged-in session (not an API key) and the correct\ncurrent password.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "current_password",
                  "new_password"
                ],
                "properties": {
                  "current_password": {
                    "type": "string"
                  },
                  "new_password": {
                    "type": "string",
                    "format": "password",
                    "minLength": 12
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Password updated",
            "other active sessions revoked.": null
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Caller is authenticated via API key (session required), or the supplied current password is incorrect.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/auth/invite/accept": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Accept an invitation, set a password, and log in",
        "description": "Consumes the single-use invite token emailed to a newly-invited user,\nsets the account's initial password, revokes the token and any existing\nsessions, and establishes a fresh session (sets the `plotdr_session`\ncookie) so the invitee is logged in immediately. Rate-limited per IP.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token",
                  "new_password"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  },
                  "new_password": {
                    "type": "string",
                    "format": "password",
                    "minLength": 12
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Password set, invitee logged in (session cookie set).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/auth/verify-email": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Confirm an email-verification token",
        "description": "Consumes the single-use token from the verification email and marks the\naccount's email as verified. Does not establish a session (no\n`plotdr_session` cookie is set): the user must sign in afterwards, so a\nverification link cannot be used to sign a victim in to someone else's\naccount. Rate-limited per IP.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Email verified. No session is created; the user must sign in.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/auth/verify-email/resend": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Resend an email-verification email",
        "description": "Always responds 202, whether or not `email` belongs to a registered,\nstill-unverified account -- this never leaks account existence or\nverification status. If it does, a fresh verification token is emailed\nto that address. Rate-limited per IP and per account.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Request accepted (email sent only if the address is an unverified account)."
          }
        }
      }
    },
    "/orgs/me": {
      "get": {
        "tags": [
          "org"
        ],
        "summary": "Get the authenticated organization's profile",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Organization"
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "org"
        ],
        "summary": "Update the organization's profile",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "country": {
                    "type": "string",
                    "pattern": "^[A-Z]{2}$",
                    "description": "ISO 3166-1 alpha-2"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Organization"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/orgs/me/delete": {
      "post": {
        "tags": [
          "org"
        ],
        "summary": "Delete the whole organization (self-service account deletion)",
        "description": "Irreversible. Cancels any active Stripe subscription, deletes every API key and session (immediate lockout), anonymizes every user, and replaces the organization's name with the tombstone `deleted-<id>`, clearing its country. The response's `org_id` is the reference to quote when asking for a safe-mode deletion's archived records later. `mode: safe` archives Suppliers/Plots only. It does not schedule automatic deletion: the underlying Supplier, Plot and Check records remain until an erasure request is handled or PlotDR staff runs the retention purge. That purge removes Check records 6 calendar years after the screening they record, and Supplier and Plot records 5 calendar years after they were archived, once no Check under them is still within its own 6 years. This follows the 5-year record-keeping period in EUDR Article 9(1), which runs from when the product is placed on the market or exported, a date PlotDR does not track, plus a 1-year margin for the time between screening and placing on the market. `mode: full` additionally hard-deletes every Supplier/Plot/Check and every generated report file immediately, which may leave the organization without the due-diligence records EUDR requires it to keep. Admin (session) only.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "mode",
                  "confirm_org_name"
                ],
                "properties": {
                  "mode": {
                    "type": "string",
                    "enum": [
                      "safe",
                      "full"
                    ],
                    "description": "`safe` archives suppliers and plots, which are removed later by the retention purge or on request. `full` also permanently deletes every supplier, plot, check and generated report file immediately. Irreversible."
                  },
                  "confirm_org_name": {
                    "type": "string",
                    "description": "Must equal the organization's current name."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deleted. Summary of what was archived/purged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeleteOrgResult"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/orgs/me/users": {
      "get": {
        "tags": [
          "org"
        ],
        "summary": "List team members",
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserList"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "tags": [
          "org"
        ],
        "summary": "Invite a teammate",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "role"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "role": {
                    "$ref": "#/components/schemas/UserRole"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Invited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/orgs/me/users/{user_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/UserId"
        }
      ],
      "delete": {
        "tags": [
          "org"
        ],
        "summary": "Remove a teammate",
        "responses": {
          "204": {
            "description": "Removed."
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/orgs/me/api-keys": {
      "get": {
        "tags": [
          "org"
        ],
        "summary": "List API keys",
        "description": "Secrets are never returned again after creation; only the prefix and metadata are shown.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKeyList"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "tags": [
          "org"
        ],
        "summary": "Create an API key",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Human label, e.g. 'CI integration'."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. `secret` is present only in this response; store it now,\nit cannot be retrieved again.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKeyCreated"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/orgs/me/api-keys/{key_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/KeyId"
        }
      ],
      "delete": {
        "tags": [
          "org"
        ],
        "summary": "Revoke an API key",
        "responses": {
          "204": {
            "description": "Revoked."
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/commodities": {
      "get": {
        "tags": [
          "commodities"
        ],
        "summary": "List EUDR commodities",
        "description": "Read-only reference data seeded and maintained by PlotDR, not org-editable.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommodityList"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/commodities/{commodity_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CommodityId"
        }
      ],
      "get": {
        "tags": [
          "commodities"
        ],
        "summary": "Get a commodity",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Commodity"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/suppliers": {
      "get": {
        "tags": [
          "suppliers"
        ],
        "summary": "List suppliers",
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/IncludeArchived"
          },
          {
            "$ref": "#/components/parameters/Search"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupplierList"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "tags": [
          "suppliers"
        ],
        "summary": "Register a supplier",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupplierInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Supplier"
                }
              }
            }
          }
        }
      }
    },
    "/suppliers/{supplier_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/SupplierId"
        }
      ],
      "get": {
        "tags": [
          "suppliers"
        ],
        "summary": "Get a supplier",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Supplier"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "suppliers"
        ],
        "summary": "Update a supplier",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupplierInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Supplier"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "suppliers"
        ],
        "summary": "Archive a supplier",
        "description": "Soft delete; sets `archived_at` on the supplier and, in the same transaction and with the same timestamp, on every plot under it that is not archived yet. A plot archived earlier keeps its own `archived_at`. Checks referencing the supplier's plots remain intact. An archived supplier cannot take a new plot or have a plot moved to it (`POST /plots` and `PATCH /plots/{plot_id}` return 422), and its archived plots can no longer be checked (see `DELETE /plots/{plot_id}`).",
        "responses": {
          "204": {
            "description": "Archived."
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/suppliers/{supplier_id}/purge": {
      "parameters": [
        {
          "$ref": "#/components/parameters/SupplierId"
        }
      ],
      "post": {
        "tags": [
          "suppliers"
        ],
        "summary": "Permanently erase a supplier (GDPR Art. 17)",
        "description": "Hard-deletes an already-archived supplier plus every plot/check under it. Irreversible; distinct from `DELETE`, which only archives. Admin (session) only.",
        "responses": {
          "204": {
            "description": "Purged."
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/plots": {
      "get": {
        "tags": [
          "plots"
        ],
        "summary": "List plots",
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/IncludeArchived"
          },
          {
            "$ref": "#/components/parameters/Search"
          },
          {
            "name": "supplier_id",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "commodity_id",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlotList"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "tags": [
          "plots"
        ],
        "summary": "Register a plot",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlotInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Plot"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/plots/{plot_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PlotId"
        }
      ],
      "get": {
        "tags": [
          "plots"
        ],
        "summary": "Get a plot",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Plot"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "plots"
        ],
        "summary": "Update a plot",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlotInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Plot"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "plots"
        ],
        "summary": "Archive a plot",
        "description": "Soft delete; sets `archived_at`. Past checks/reports referencing this plot remain intact and reconstructable, per EUDR due-diligence record-keeping expectations. An archived plot can no longer be checked: `POST /plots/{plot_id}/checks` returns 409 (type `https://plotdr.ch/problems/archived`) and a batch skips it with reason `plot_archived`. `GET /plots/{plot_id}`, its check history and reports on its past checks keep working.",
        "responses": {
          "204": {
            "description": "Archived."
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/plots/{plot_id}/purge": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PlotId"
        }
      ],
      "post": {
        "tags": [
          "plots"
        ],
        "summary": "Permanently erase a plot (GDPR Art. 17)",
        "description": "Hard-deletes an already-archived plot plus every check that references it. Irreversible; distinct from `DELETE`, which only archives. Admin (session) only.",
        "responses": {
          "204": {
            "description": "Purged."
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/plots/{plot_id}/checks": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PlotId"
        }
      ],
      "get": {
        "tags": [
          "plots",
          "checks"
        ],
        "summary": "Check history for this plot",
        "description": "The plot-compliance-history / evolution view: every past check run against this plot, newest first.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CheckList"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "tags": [
          "checks"
        ],
        "summary": "Run a risk check against this plot",
        "description": "Also rate-limited per org, independent of the free-tier monthly quota below -- see the 429 response. An archived plot is never screened: the request returns 409 with type `https://plotdr.ch/problems/archived`, creates no check and uses no quota (the rate-limit token is still spent, as for a 404). Its past checks stay readable through `GET`.",
        "responses": {
          "201": {
            "description": "Check completed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Check"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/CheckQuotaOrRateLimited"
          }
        }
      }
    },
    "/checks": {
      "get": {
        "tags": [
          "checks"
        ],
        "summary": "List checks for the current org",
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CheckList"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "tags": [
          "checks"
        ],
        "summary": "Run an ad hoc risk check",
        "description": "Checks an unregistered geometry directly. Does not require a Plot\nand is not attached to any plot's check history, but the resulting\nCheck still gets an ID and can be used to generate a Report.\n\nAlso rate-limited per org, independent of the free-tier monthly\nquota below -- see the 429 response.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AdHocCheckInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Check completed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Check"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/CheckQuotaOrRateLimited"
          }
        }
      }
    },
    "/checks/{check_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/CheckId"
        }
      ],
      "get": {
        "tags": [
          "checks"
        ],
        "summary": "Get a check",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Check"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/batches": {
      "get": {
        "tags": [
          "batches"
        ],
        "summary": "List batches for the current org",
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BatchList"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "tags": [
          "batches"
        ],
        "summary": "Submit a portfolio-scale batch of checks",
        "description": "Async. Returns immediately with status `pending`; poll `GET /batches/{id}` until `complete` or `failed`.\nAlso rate-limited per org, weighted by item count, independent of the free-tier monthly quota below -- see the 429 response.\nAt most 500 items per request. (A 1,000-feature limit applies to a future file-upload feature; no such upload exists today.) An item whose geometry is over a screening budget is not an error: it becomes a skipped Check (`needs_review`, `skip_reason` set) listed in `check_ids` like any other, and counts against the quota.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchInput"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Batch"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/CheckQuotaOrRateLimited"
          }
        }
      }
    },
    "/batches/{batch_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/BatchId"
        }
      ],
      "get": {
        "tags": [
          "batches"
        ],
        "summary": "Get batch status and results",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Batch"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/reports": {
      "get": {
        "tags": [
          "reports"
        ],
        "summary": "List reports for the current org",
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReportList"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "post": {
        "tags": [
          "reports"
        ],
        "summary": "Generate a due-diligence report",
        "description": "Async. Wraps a single Check or a Batch of checks into a downloadable\nartifact. Poll `GET /reports/{id}` until `complete`, then use\n`download_url`.\n\nRate-limited per org: `free`/`pro` orgs (session-only) get a lower\nper-minute budget than `api_pro` (the only plan that can hold an API\nkey, sized for automated volume). A limit hit is a 429 with\n`Retry-After` and `X-RateLimit-*` headers.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReportInput"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Report"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/reports/{report_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ReportId"
        }
      ],
      "get": {
        "tags": [
          "reports"
        ],
        "summary": "Get report status and download link",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Report"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/billing/prices": {
      "get": {
        "tags": [
          "billing"
        ],
        "summary": "Plan prices as configured in Stripe",
        "description": "Authenticated by session or API key; any member of the organization may read it (prices are not sensitive, so it is not admin-only). Reads the configured Stripe Price objects and returns one entry per plan and interval whose price id is configured and usable; an unconfigured price is omitted, so `prices` is empty when billing is not set up. Amounts are in minor units of `currency` (for example cents). The answer is cached in-process for about an hour. When Stripe cannot be read the response is a 503 problem with a generic `detail`, never Stripe's own error text; that failure is remembered for about a minute (so an outage is not hammered), then Stripe is tried again.",
        "responses": {
          "200": {
            "description": "The configured plan prices.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanPrices"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/billing/checkout": {
      "post": {
        "tags": [
          "billing"
        ],
        "summary": "Start a Stripe Checkout session to subscribe",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "plan",
                  "interval"
                ],
                "properties": {
                  "plan": {
                    "type": "string",
                    "enum": [
                      "pro",
                      "api_pro"
                    ]
                  },
                  "interval": {
                    "type": "string",
                    "enum": [
                      "monthly",
                      "annual"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout session URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BillingUrl"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "The org already has a non-free plan or a live Stripe subscription; use the customer portal to change it.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        }
      }
    },
    "/billing/portal": {
      "post": {
        "tags": [
          "billing"
        ],
        "summary": "Open the Stripe Customer Portal",
        "responses": {
          "200": {
            "description": "Portal session URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BillingUrl"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "The org has no Stripe customer yet.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        }
      }
    },
    "/support": {
      "post": {
        "tags": [
          "support"
        ],
        "summary": "File an in-product problem report",
        "description": "Authenticated. The reporter's organization, user and plan are read from the credentials, never from the payload; a session user's own address is always used as the reply address even if `reply_email` is supplied. API keys have no user behind them, so those callers must set `reply_email` explicitly or the request is rejected. The `context` block is machine-populated by the dashboard (current route, the RFC 7807 `detail`/`instance` of a failing request, the check/plot in view); over-long fields there are truncated to 500 characters rather than rejected. The user agent is read from the request header, not the body. Rate-limited to 10 reports per hour per organization; a 429 carries `Retry-After` and the `X-RateLimit-*` headers.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupportReportInput"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Report received and accepted for delivery."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/SupportRateLimited"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key issued via `POST /orgs/me/api-keys`, for external integrators. API-key access requires the organization to be on the `api_pro` plan; a key belonging to any other plan is rejected with `403` on every endpoint (see the `Forbidden` response). Session (cookie) auth is not plan-gated."
      },
      "cookieAuth": {
        "type": "apiKey",
        "in": "cookie",
        "name": "plotdr_session",
        "description": "Session cookie set by `POST /auth/login`, for the PlotDR website UI. Same identity resolution as bearerAuth."
      }
    },
    "parameters": {
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "Opaque pagination cursor from a previous response's `next_cursor`. An invalid cursor is a 422 `validation-error` whose `errors` names `cursor`."
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 200,
          "default": 50
        }
      },
      "IncludeArchived": {
        "name": "include_archived",
        "in": "query",
        "schema": {
          "type": "boolean",
          "default": false
        }
      },
      "Search": {
        "name": "q",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string"
        },
        "description": "Case-insensitive substring match against the resource's name/external_ref."
      },
      "UserId": {
        "name": "user_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "KeyId": {
        "name": "key_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "CommodityId": {
        "name": "commodity_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "SupplierId": {
        "name": "supplier_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "PlotId": {
        "name": "plot_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "CheckId": {
        "name": "check_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "BatchId": {
        "name": "batch_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "ReportId": {
        "name": "report_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "OrgId": {
        "name": "org_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      }
    },
    "responses": {
      "NotFound": {
        "description": "Resource not found.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid credentials.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "ValidationError": {
        "description": "Request failed validation (e.g. invalid geometry). Every input-validation 422 has `type` `https://plotdr.ch/problems/validation-error`, `title` \"Validation error\", and an `errors` array naming each offending field: a body that does not match the schema, an invalid geometry (inside a batch, `field` is the item's path such as `geometries.0.geometry`), an id in the body that does not reference a resource in your organization (such as a report's `check_id` or `batch_id`), a report over a batch that is not `complete` or has no checks, a batch or report that names both or neither of its two alternatives (`field` null), an invalid query parameter such as `cursor`, and a password shorter than 12 characters. A few 422s keep `type` `about:blank`, `title` \"Unprocessable Content\" and no `errors`, with the reason in `detail`: removing your own account or the organization's last admin, and a staff user deactivating their own account (refusals on the current state, not on the input); an unknown, expired or revoked emailed token (one deliberately uniform answer); and `POST /billing/checkout` with a plan or interval that has no configured price.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Authenticated but not permitted. Returned when an API key authenticates an organization whose `plan` is not `api_pro` (API access requires the API Pro plan), and when a session admin attempts a plan-gated action such as creating an API key on a non-`api_pro` organization.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "PaymentRequired": {
        "description": "The requested action requires a paid plan. Returned when a free-plan org requests the `dds` (TRACES-ready DDS) report format.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "BadGateway": {
        "description": "The upstream billing provider (Stripe) returned an error or was unreachable. The request was well-formed; retry later.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "The prices could not be read from the upstream billing provider (Stripe) right now. `detail` is generic and never carries the provider's own error text. Retry later (a failure is remembered for about a minute before Stripe is tried again).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Conflict": {
        "description": "The request conflicts with the resource's current state (e.g. attempting to purge a supplier/plot that hasn't been archived yet, type `https://plotdr.ch/problems/not-archived`, or to run a check on an archived plot, type `https://plotdr.ch/problems/archived`).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "SupportRateLimited": {
        "description": "More than 10 problem reports filed by this organization within the last hour. Distinct from `QuotaExceeded`: this is abuse throttling, not a plan limit, and carries a `Retry-After` header.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "QuotaExceeded": {
        "description": "Free-tier monthly check quota exceeded. The `free` plan is limited to a fixed number of checks per calendar month; upgrade to Pro for unlimited screening. Batch submissions count the whole batch (plus any in-flight batch demand) against the quota. `type` is always `https://plotdr.ch/problems/quota-exceeded` -- this is the stable discriminator a client should key off, not `status` alone (other 429s, e.g. signup/login rate limiting, are not this).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Per-org, per-minute request budget exceeded (burst/infra protection, distinct from `QuotaExceeded`'s monthly plan limit -- every plan gets a budget here, `api_pro` just gets a much higher one, sized for automated volume rather than session-only dashboard use). `type` is `about:blank`, matching the signup/login rate limiters.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "CheckQuotaOrRateLimited": {
        "description": "A 429 here is one of two independent causes, both returning the same `Problem` shape -- key off `type`, not `status`, to tell them apart: `https://plotdr.ch/problems/quota-exceeded` is `QuotaExceeded` (the free-tier *monthly* check cap; paid plans never hit this one), and `about:blank` is `RateLimited` (the per-org, per-minute burst/infra budget every plan draws from, sized higher for `api_pro`). `POST /batches` draws from its own, separately-ceilinged version of this budget, weighted by the batch's item count (same accounting as `QuotaExceeded`'s monthly count) rather than by request count -- otherwise no plan's plain per-request budget could ever admit a single legitimately-sized batch.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      }
    },
    "schemas": {
      "Problem": {
        "type": "object",
        "description": "RFC 7807 problem details. Branch on `type` and `status`. `type` is either a URI under `https://plotdr.ch/problems/`, which opens that type's documentation page, or `about:blank`. `title` is fixed per problem type and never carries per-request data: for a typed problem it is the type's name (for example \"Validation error\" or \"Monthly check quota exceeded\"), and for `about:blank` it is the HTTP status phrase (for example \"Not Found\" or \"Unprocessable Content\"). The specifics of an occurrence are in `detail`.",
        "required": [
          "type",
          "title",
          "status"
        ],
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "example": "https://plotdr.ch/problems/validation-error"
          },
          "title": {
            "type": "string",
            "example": "Validation error"
          },
          "status": {
            "type": "integer",
            "example": 422
          },
          "detail": {
            "type": "string",
            "example": "geometry is not a valid GeoJSON shape (e.g. self-intersecting polygon or overlapping parts)"
          },
          "instance": {
            "type": "string",
            "format": "uri",
            "example": "/v1/plots"
          },
          "errors": {
            "type": "array",
            "description": "Structured per-field validation errors. Present whenever `type` is `https://plotdr.ch/problems/validation-error`, which every input-validation 422 uses: a request-body shape violation and a rule checked by the API itself (for example, an invalid geometry, an id that does not reference an existing resource, or an invalid `cursor`) alike. Absent for every other problem type.",
            "items": {
              "type": "object",
              "required": [
                "field",
                "message"
              ],
              "properties": {
                "field": {
                  "type": "string",
                  "nullable": true,
                  "description": "Dotted path of the offending value in the request (e.g. \"email\", \"geometries.0.commodity_id\" or the query parameter \"cursor\"), or null for an error about the request as a whole."
                },
                "message": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "ReadinessStatus": {
        "type": "object",
        "required": [
          "status",
          "checks"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ready",
              "not_ready"
            ]
          },
          "checks": {
            "type": "object",
            "description": "Per-dependency probe result. `true` means healthy.",
            "required": [
              "database",
              "redis",
              "object_store",
              "worker"
            ],
            "properties": {
              "database": {
                "type": "boolean"
              },
              "redis": {
                "type": "boolean"
              },
              "object_store": {
                "type": "boolean"
              },
              "worker": {
                "type": "boolean",
                "description": "Whether the background worker's Redis heartbeat key is present (written every 30 seconds). `false` here with the rest healthy means background job processing (report generation, batch screening) is stalled or down."
              }
            }
          }
        }
      },
      "BillingUrl": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "PlanPrice": {
        "type": "object",
        "required": [
          "plan",
          "interval",
          "amount_minor",
          "currency"
        ],
        "properties": {
          "plan": {
            "type": "string",
            "enum": [
              "pro",
              "api_pro"
            ]
          },
          "interval": {
            "type": "string",
            "enum": [
              "monthly",
              "annual"
            ]
          },
          "amount_minor": {
            "type": "integer",
            "description": "The price in minor units of `currency` (for example cents)."
          },
          "currency": {
            "type": "string",
            "description": "Lowercase ISO 4217 currency code, as Stripe returns it (for example `chf`)."
          }
        }
      },
      "PlanPrices": {
        "type": "object",
        "required": [
          "prices"
        ],
        "properties": {
          "prices": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlanPrice"
            }
          }
        }
      },
      "UserRole": {
        "type": "string",
        "enum": [
          "admin",
          "member"
        ]
      },
      "Organization": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "country": {
            "type": "string",
            "nullable": true,
            "description": "ISO 3166-1 alpha-2"
          },
          "plan": {
            "type": "string",
            "enum": [
              "free",
              "pro",
              "api_pro"
            ],
            "description": "Subscription tier. `free` (screening capped per month, watermarked reports), `pro` (unlimited screening, clean reports + DDS export), `api_pro` (adds API-key access). Set out-of-band until billing ships."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "checks_used_this_month": {
            "type": "integer",
            "description": "Checks created this calendar month plus outstanding batch demand (batches accepted but not yet fulfilled by the worker)."
          },
          "checks_quota": {
            "type": "integer",
            "nullable": true,
            "description": "Monthly check cap for the free plan. `null` means unlimited (any paid plan), not \"not computed\"."
          }
        },
        "required": [
          "checks_used_this_month",
          "checks_quota"
        ]
      },
      "DeleteOrgResult": {
        "type": "object",
        "properties": {
          "org_id": {
            "type": "string",
            "format": "uuid",
            "description": "The deleted organization's id, the only identifier it keeps once its name is replaced with a tombstone."
          },
          "subscriptions_cancelled": {
            "type": "integer"
          },
          "users_anonymized": {
            "type": "integer"
          },
          "suppliers_archived": {
            "type": "integer"
          },
          "plots_archived": {
            "type": "integer"
          },
          "suppliers_purged": {
            "type": "integer"
          },
          "plots_purged": {
            "type": "integer"
          },
          "checks_purged": {
            "type": "integer"
          }
        }
      },
      "User": {
        "type": "object",
        "required": [
          "id",
          "org_id",
          "email",
          "role",
          "is_staff",
          "email_verified",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "org_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "role": {
            "$ref": "#/components/schemas/UserRole"
          },
          "is_staff": {
            "type": "boolean",
            "description": "Reserved for PlotDR's own staff accounts. Customer integrations can ignore it."
          },
          "email_verified": {
            "type": "boolean"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "UserList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/User"
            }
          },
          "next_cursor": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "ApiKey": {
        "type": "object",
        "description": "Metadata only; the secret is never included after creation.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "prefix": {
            "type": "string",
            "description": "First few characters, for identification in lists."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "revoked_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "last_used_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Last time this key was presented as a Bearer credential. Null if never used."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Reserved for future use; no key currently has an expiry set."
          }
        }
      },
      "ApiKeyCreated": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ApiKey"
          },
          {
            "type": "object",
            "properties": {
              "secret": {
                "type": "string",
                "description": "Full API key secret. Shown once, here only."
              }
            }
          }
        ]
      },
      "ApiKeyList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ApiKey"
            }
          },
          "next_cursor": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "Commodity": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "enum": [
              "cattle",
              "cocoa",
              "coffee",
              "oil_palm",
              "rubber",
              "soy",
              "wood"
            ]
          },
          "hs_codes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Combined Nomenclature/HS headings EUDR Annex I lists as \"relevant products\" for this commodity. An \"ex \" prefix means only part of that heading is in scope -- narrowing to the operator's specific product code is the operator's job, not asserted here.\n"
          },
          "description": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "CommodityList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Commodity"
            }
          },
          "next_cursor": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "SupplierInput": {
        "type": "object",
        "required": [
          "name",
          "country"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "country": {
            "type": "string",
            "pattern": "^[A-Z]{2}$",
            "description": "ISO 3166-1 alpha-2"
          },
          "tax_id": {
            "type": "string",
            "nullable": true
          },
          "contact_email": {
            "type": "string",
            "format": "email",
            "nullable": true
          },
          "contact_phone": {
            "type": "string",
            "nullable": true
          },
          "external_ref": {
            "type": "string",
            "nullable": true,
            "description": "Customer's own identifier for this supplier."
          }
        }
      },
      "Supplier": {
        "allOf": [
          {
            "$ref": "#/components/schemas/SupplierInput"
          },
          {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "org_id": {
                "type": "string",
                "format": "uuid"
              },
              "archived_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "created_at": {
                "type": "string",
                "format": "date-time"
              },
              "updated_at": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        ]
      },
      "SupplierList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Supplier"
            }
          },
          "next_cursor": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "Geometry": {
        "type": "object",
        "description": "GeoJSON Geometry (Point, Polygon or MultiPolygon only), WGS84. A MultiPolygon's parts must not overlap (an invalid shape is a 422), and its area is the sum of its parts with holes subtracted.\nThere is no cap on a geometry's size, but one past any of these screening budgets is stored and NOT screened: its check is skipped and returned as `needs_review` with `summary_code` `geometry_over_budget` and the budget in `skip_reason`, instead of a 422. Budgets: more than 100,000 coordinate positions (`coordinate_budget`), more than 100,000 ha (`area_budget`), or a bounding box of more than 25 million 10 m pixels (`pixel_budget`; this catches a sparse MultiPolygon whose parts are far apart). A bounding box counts as the larger of its area and the square on its longer side, so one more than 50 km long on a side is over budget however thin it is. A value exactly at a limit is within budget. A skipped check still counts against the check quota. The request body is still limited to 10 MB.\n",
        "required": [
          "type",
          "coordinates"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "Point",
              "Polygon",
              "MultiPolygon"
            ]
          },
          "coordinates": {}
        },
        "examples": [
          {
            "type": "Polygon",
            "coordinates": [
              [
                [
                  10,
                  5
                ],
                [
                  10.01,
                  5
                ],
                [
                  10.01,
                  5.01
                ],
                [
                  10,
                  5.01
                ],
                [
                  10,
                  5
                ]
              ]
            ]
          },
          {
            "type": "MultiPolygon",
            "coordinates": [
              [
                [
                  [
                    10,
                    5
                  ],
                  [
                    10.01,
                    5
                  ],
                  [
                    10.01,
                    5.01
                  ],
                  [
                    10,
                    5.01
                  ],
                  [
                    10,
                    5
                  ]
                ]
              ],
              [
                [
                  [
                    10.1,
                    5
                  ],
                  [
                    10.11,
                    5
                  ],
                  [
                    10.11,
                    5.01
                  ],
                  [
                    10.1,
                    5.01
                  ],
                  [
                    10.1,
                    5
                  ]
                ]
              ]
            ]
          }
        ]
      },
      "PlotCore": {
        "type": "object",
        "required": [
          "supplier_id",
          "commodity_id",
          "geometry"
        ],
        "properties": {
          "supplier_id": {
            "type": "string",
            "format": "uuid"
          },
          "commodity_id": {
            "type": "string",
            "format": "uuid"
          },
          "geometry": {
            "$ref": "#/components/schemas/Geometry"
          },
          "external_ref": {
            "type": "string",
            "nullable": true,
            "description": "Customer's own identifier for this plot."
          }
        }
      },
      "PlotInput": {
        "allOf": [
          {
            "$ref": "#/components/schemas/PlotCore"
          },
          {
            "type": "object",
            "properties": {
              "asserted_area_ha": {
                "type": "number",
                "nullable": true,
                "description": "Required when geometry is a Point (ignored for a Polygon or MultiPolygon, whose area is computed from its coordinates) -- checked against the EUDR Art. 2(28) 4-hectare polygon threshold. A Point over 4 ha is rejected with a 422. Input only -- not echoed back on `Plot`.\n"
              }
            }
          }
        ]
      },
      "Plot": {
        "allOf": [
          {
            "$ref": "#/components/schemas/PlotCore"
          },
          {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "org_id": {
                "type": "string",
                "format": "uuid"
              },
              "area_ha": {
                "type": "number",
                "description": "Computed from geometry (and, for a Point, asserted_area_ha) at registration time."
              },
              "archived_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "created_at": {
                "type": "string",
                "format": "date-time"
              },
              "updated_at": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        ]
      },
      "PlotList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Plot"
            }
          },
          "next_cursor": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "AdHocCheckInput": {
        "type": "object",
        "required": [
          "geometry",
          "commodity_id"
        ],
        "properties": {
          "geometry": {
            "$ref": "#/components/schemas/Geometry"
          },
          "commodity_id": {
            "type": "string",
            "format": "uuid"
          },
          "asserted_area_ha": {
            "type": "number",
            "nullable": true,
            "description": "Required when geometry is a Point -- see PlotInput.asserted_area_ha."
          }
        }
      },
      "RiskStatus": {
        "type": "string",
        "enum": [
          "compliant",
          "non_compliant",
          "needs_review"
        ],
        "description": "needs_review covers cases like stale/missing layer data for the plot's location, where an automated compliant/non_compliant verdict would be unsafe to assert.\nGeospatial deforestation/fire-alert screening only; one input to, not a substitute for, the full EUDR Article 10(2) risk assessment.\n"
      },
      "LayerContribution": {
        "type": "string",
        "enum": [
          "none",
          "alert",
          "overlap",
          "not_covered"
        ]
      },
      "CheckLayerResult": {
        "type": "object",
        "properties": {
          "layer_name": {
            "type": "string",
            "example": "glad_integrated_alerts"
          },
          "as_of_version": {
            "type": "string",
            "nullable": true,
            "description": "The layer version this row was screened against. Null means the layer could not be resolved for this check: its query failed, or PlotDR holds no copy of the layer yet. The row's contribution is then none, but it means unavailable, not \"nothing found\". The dashboard and PDF reports show such a row as Unavailable, CSV reports write unavailable in the contribution's place, and the JSON report format adds a boolean unavailable to every layer row (true for such a row). GET /checks/{id} itself does not carry that field, so API consumers test as_of_version for null.\n"
          },
          "as_of_date": {
            "type": "string",
            "format": "date",
            "nullable": true,
            "description": "When PlotDR mirrored this layer version, not when the upstream data itself is from -- see data_date for that. Despite the field name, this is an ingest timestamp; kept as-is for API compatibility.\n"
          },
          "data_date": {
            "type": "string",
            "format": "date",
            "nullable": true,
            "description": "The upstream data's own currency (e.g. a Hansen GFC release year, a GFW alerts dataset's publish date), when derivable for this layer. Null when the layer's version carries no derivable date (e.g. sbtn_natural_lands_binary) -- never a guess. Absent (parses as null) on checks created before this field existed.\n"
          },
          "contribution": {
            "$ref": "#/components/schemas/LayerContribution"
          },
          "is_stale": {
            "type": "boolean",
            "default": false,
            "description": "True if PlotDR has not confirmed its copy of this layer is current within the layer's expected refresh interval plus a grace period, which ranges from a few days for daily alert layers to about a year for annually published datasets. This measures when the copy was last confirmed current, not `as_of_date`: a layer whose source never changes can have an old `as_of_date` and still not be stale. Drives the Check-level summary_code layers_stale. False (not necessarily fresh) on checks created before this field existed.\n"
          },
          "affects_verdict": {
            "type": "boolean",
            "description": "True if this layer's result can change risk_status: its contribution is read by a risk-aggregation rule, or (hansen_datamask) its no-data/water reading routes the check to needs_review. False means the row is informational: shown for context, never read by a verdict rule (hansen_gain, the standalone hansen_treecover2000 row, primary_forest, peat_ml_global, sbtn_natural_lands_binary, jrc_global_forest_cover_2020, jrc_forest_types_2020, opera_dist_alert_veg_dist_status, opera_dist_ann_veg_dist_status). An informational layer can still gate the verdict through its availability and freshness; see gates_verdict. Always present; derived from layer_name for checks created before this field existed.\n"
          },
          "gates_verdict": {
            "type": "boolean",
            "description": "True if this layer being unresolved, or past its expected freshness (is_stale), routes the check to needs_review (summary_code layers_unresolved or layers_stale) whatever the layer shows. False only for a layer that affects the verdict in no way at all (today opera_dist_alert_veg_dist_status and opera_dist_ann_veg_dist_status): its is_stale is still reported, for transparency, but never moves the verdict. affects_verdict true with gates_verdict false does not occur. Always present; derived from layer_name for checks created before this field existed.\n"
          },
          "details": {
            "type": "object",
            "description": "Raw evidence backing the contribution (pixel values, intersecting feature attributes, etc.); for due-diligence drill-down. Informational only: none of the area or count keys below is read by a verdict rule. A raster layer carries `pixel_values` (the distinct values under the plot) and, since #457, `pixel_counts` (object: pixel value -> number of valid pixels with that value) and `area_ha` (object: pixel value -> hectares those pixels cover). Pixel area comes from each tile's own CRS (exact for a projected tile, authalic per row for a geographic one). The plot is masked with all-touched semantics, so the summed area slightly overstates the plot's own geodesic area. Where a layer's tiles overlap (OPERA's MGRS tiles overlap their neighbours), each pixel is counted once: a later tile leaves out the pixels whose centre lies inside an earlier tile's footprint. Within one CRS (one UTM zone) this is exact; across two UTM zones the grids differ, so the total can be off by up to about one pixel along the seam. An object key is the pixel value as a string. The opera_dist_ann_veg_dist_status row (#405) always carries empty `pixel_counts` and `area_ha`: its yearly tiles share one footprint, so per-value counts would cover only one year, and summing the years would count a disturbance once per year it appears in. Its `disturbances` block carries the evidence instead. There is no annual summary for 2021 or 2022: a disturbance first detected in 2022 and confirmed in 2023 can appear in the 2023 summary with its 2022 date, which is not 2022 coverage. A year appears only once NASA publishes its annual summary and PlotDR's yearly update has copied it. If any year's status tile cannot be matched to its own date tile, the block lists the status classes without any year's dates. A paired raster layer (hansen_lossyear, sbtn_natural_lands_classification) carries `pixel_pairs` and, since #457, `pair_breakdown`: a list of `{values: [a, b], pixel_count, area_ha}` covering exactly the pairs published in `pixel_pairs`. A vector layer carries `intersecting_features`, each with, since #457, `intersection_area_ha` (geodesic hectares of the feature within the plot; 0 for a point or line; null if the area could not be computed, in which case the feature is still listed). All of these keys are absent on checks created before #457.\n"
          }
        }
      },
      "Check": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "org_id": {
            "type": "string",
            "format": "uuid"
          },
          "plot_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Null for ad hoc checks."
          },
          "geometry": {
            "$ref": "#/components/schemas/Geometry"
          },
          "commodity_id": {
            "type": "string",
            "format": "uuid"
          },
          "risk_status": {
            "$ref": "#/components/schemas/RiskStatus"
          },
          "summary": {
            "type": "string",
            "description": "Human-readable verdict summary."
          },
          "summary_code": {
            "type": "string",
            "nullable": true,
            "enum": [
              "layers_unresolved",
              "layers_stale",
              "hansen_loss",
              "sbtn_forest_loss",
              "wdpa_overlap",
              "glad_alert",
              "fire_alert",
              "hansen_lossyear_not_covered",
              "compliant_wdpa_screened",
              "compliant_no_wdpa",
              "compliant_partial_coverage",
              "geometry_over_budget"
            ],
            "description": "Machine-readable label for which risk-aggregation rule produced `summary`; lets a client (e.g. the dashboard) render a translated string instead of the fixed English `summary` text. `geometry_over_budget` is not a risk-aggregation rule: it marks a check that was skipped because its geometry is over a screening budget (see `skip_reason`). Null on checks created before this field existed.\n"
          },
          "area_ha": {
            "type": "number",
            "nullable": true,
            "description": "The geometry's area in hectares at check time (a Point's is the `asserted_area_ha`). Null on checks created before this field existed.\n"
          },
          "skip_reason": {
            "type": "string",
            "nullable": true,
            "enum": [
              "coordinate_budget",
              "area_budget",
              "pixel_budget"
            ],
            "description": "Set when the geometry was over a screening budget and the check was skipped instead of run: the check is then `needs_review` with `summary_code` `geometry_over_budget` and no `layers`. `coordinate_budget`: more than 100,000 coordinate positions; `area_budget`: more than 100,000 ha; `pixel_budget`: a bounding box of more than 25 million 10 m pixels, or more than 50 km on its longer side. Null for a screened check.\n"
          },
          "eudr_cutoff_date": {
            "type": "string",
            "format": "date",
            "default": "2020-12-31",
            "description": "Cutoff date this check's forest-loss logic was evaluated against."
          },
          "eudr_scope_disclaimer": {
            "type": "string",
            "description": "Fixed compliance-scope caveat, identical across the PDF/CSV/JSON report formats and this response: this screening covers only the geospatial deforestation/fire-alert signals it queries, one input to, not a substitute for, the full EUDR Article 10(2) risk assessment.\n"
          },
          "layers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CheckLayerResult"
            }
          },
          "attributions": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Dataset attribution notices required by the licences of the layers that contributed to this check (e.g. Hansen/GLAD CC BY 4.0). Derived from `layers`; only layers with a confirmed, resolved attribution requirement are represented.\n"
          },
          "created_by": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "User who ran the check, for audit trail. Null for checks run via API key (no associated user)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CheckList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Check"
            }
          },
          "next_cursor": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "JobStatus": {
        "type": "string",
        "enum": [
          "pending",
          "running",
          "complete",
          "failed"
        ]
      },
      "BatchInput": {
        "type": "object",
        "description": "Provide either plot_ids (checks against registered plots) or geometries (ad hoc), not both.",
        "properties": {
          "plot_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "maxItems": 500
          },
          "geometries": {
            "type": "array",
            "maxItems": 500,
            "items": {
              "type": "object",
              "required": [
                "geometry",
                "commodity_id"
              ],
              "properties": {
                "geometry": {
                  "$ref": "#/components/schemas/Geometry"
                },
                "commodity_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "asserted_area_ha": {
                  "type": "number",
                  "nullable": true,
                  "description": "Required when geometry is a Point -- see PlotInput.asserted_area_ha."
                }
              }
            }
          }
        }
      },
      "BatchItemError": {
        "type": "object",
        "required": [
          "index",
          "plot_id",
          "reason"
        ],
        "properties": {
          "index": {
            "type": "integer",
            "description": "Position of the skipped item in the original plot_ids/geometries submission (plot_ids first, then geometries)."
          },
          "plot_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "reason": {
            "type": "string",
            "enum": [
              "plot_not_found",
              "plot_archived",
              "invalid_geometry",
              "unknown_commodity"
            ],
            "description": "plot_not_found covers a missing, malformed, or another org's plot_id alike -- distinguishing them would let a caller enumerate plot_ids that exist in another org. plot_archived means the plot is the caller's own but archived: an archived plot is never screened. Another org's plot gives plot_not_found whether or not it is archived."
          }
        }
      },
      "Batch": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "total": {
            "type": "integer"
          },
          "completed": {
            "type": "integer"
          },
          "check_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Populated incrementally as checks complete."
          },
          "item_errors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BatchItemError"
            },
            "description": "Items skipped during processing (invalid or archived plot reference, invalid geometry or commodity), with the reason for each."
          },
          "error": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "BatchList": {
        "type": "object",
        "required": [
          "data",
          "next_cursor"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Batch"
            }
          },
          "next_cursor": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "ReportFormat": {
        "type": "string",
        "enum": [
          "pdf",
          "json",
          "csv",
          "dds"
        ]
      },
      "ReportInput": {
        "type": "object",
        "required": [
          "format"
        ],
        "description": "Provide exactly one of check_id or batch_id.",
        "properties": {
          "check_id": {
            "type": "string",
            "format": "uuid"
          },
          "batch_id": {
            "type": "string",
            "format": "uuid"
          },
          "format": {
            "$ref": "#/components/schemas/ReportFormat"
          }
        }
      },
      "SupportReportContext": {
        "type": "object",
        "description": "Machine-captured state at the time of the report, populated by the dashboard rather than typed by the user. Over-long values are truncated to 500 characters (marked with a trailing `...`) rather than rejected.",
        "properties": {
          "route": {
            "type": "string",
            "description": "Dashboard route the reporter was on."
          },
          "problem_detail": {
            "type": "string",
            "description": "`detail` of the RFC 7807 problem that prompted the report, or the message of an uncaught render error."
          },
          "problem_instance": {
            "type": "string",
            "description": "`instance` of that problem; the failing request's path."
          },
          "check_id": {
            "type": "string"
          },
          "plot_id": {
            "type": "string"
          }
        }
      },
      "SupportReportInput": {
        "type": "object",
        "required": [
          "category",
          "message"
        ],
        "properties": {
          "category": {
            "type": "string",
            "enum": [
              "bug",
              "data_quality",
              "question"
            ],
            "description": "`data_quality` is for \"this verdict looks wrong for my plot\"; a signal about the screening data, not a software defect."
          },
          "message": {
            "type": "string",
            "minLength": 1,
            "maxLength": 5000
          },
          "context": {
            "$ref": "#/components/schemas/SupportReportContext"
          },
          "reply_email": {
            "type": "string",
            "format": "email",
            "description": "Required for API-key callers; ignored for session callers, whose own address is always used."
          }
        }
      },
      "Report": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "check_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "batch_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "format": {
            "$ref": "#/components/schemas/ReportFormat"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "download_url": {
            "type": "string",
            "format": "uri",
            "nullable": true,
            "description": "Set once `status` is `complete`. A pre-signed download link, valid for one hour (until `expires_at`). Fetch it with a plain GET and no Authorization header; do not send your API key to this URL. The host and path are not part of the API contract. The link is not renewed: after `expires_at` this field still holds the expired URL, so request a new report to get a fresh link.",
            "example": "https://storage.example.com/reports/3fa85f64-5717-4562-b3fc-2c963f66afa6.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=..."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "download_url validity, once set."
          },
          "error": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "ReportList": {
        "type": "object",
        "required": [
          "data",
          "next_cursor"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Report"
            }
          },
          "next_cursor": {
            "type": "string",
            "nullable": true
          }
        }
      }
    }
  }
}