# Verifying

There are two ways to verify an MC4P certificate. Call the verify endpoint and
read the verdict, or fetch the public keys once and check the signature
yourself.

## Call the verify endpoint

Post the page URL you loaded, and the MC4P document you found on it.

```bash
curl -X POST https://api.mintall.ai/api/v1/mc4p/products/$PRODUCT_ID/verify \
  -H "Content-Type: application/json" \
  -d '{
    "productUrl": "https://merchant.example/products/pendant",
    "document": {
      "productID": "...",
      "url": "https://merchant.example/products/pendant",
      "mintall:issuedFor": { "domain": "merchant.example" },
      "proof": { "jwt": "eyJ..." }
    }
  }'
```

`productUrl` is required. `document` is optional: send it and the signature
checks run, omit it and only the domain checks run.

Send the page's MC4P `Product` node as a nested object, not the whole JSON-LD
graph. Unknown keys are ignored, so passing the node through untouched is fine.
A stringified node is parsed for you and verifies normally. A payload that isn't
a single node, such as a graph array, isn't rejected with a 422. The document is
dropped and the verdict is `unverified`, with a warning saying why.

```json
{
  "status": "verified",
  "productId": "...",
  "issuedFor": "merchant.example",
  "checks": {
    "domainKnown": true,
    "domainVerified": true,
    "boundToDomain": true,
    "boundToUrl": true,
    "productIdMatches": true,
    "signatureValid": true,
    "certificateIntegrity": true,
    "timestampValid": true,
    "trustDataCurrent": true
  },
  "warnings": [],
  "errors": []
}
```

### The three verdicts

| `status`     | Meaning                                                              |
| ------------ | -------------------------------------------------------------------- |
| `verified`   | A signed document, and every load-bearing check passed.              |
| `failed`     | A signed document, and at least one load-bearing check did not pass. |
| `unverified` | No signed claim was submitted.                                       |

`unverified` is the verdict for a
[certificate with no `proof`](/api/certificates#when-a-certificate-has-no-proof)
or a request with no `document` at all.

### Reading the checks

Six checks are load bearing. All six must be `true` for `verified`:

- `signatureValid` — the RS256 signature matches a Mintall public key
- `certificateIntegrity` — the JWT decodes and its claims are intact
- `timestampValid` — `iat` and `exp` are in range
- `boundToDomain` — the signed `domain` claim matches the page's domain
- `productIdMatches` — the signed `productId` matches the product being verified
- `boundToUrl` — the signed `url` matches the page the document was found on

`boundToUrl` is the anti-copy check. A certificate lifted onto another
listing still carries the URL it was issued for, so it fails here without
anyone needing to trust the page.

`domainKnown`, `domainVerified`, and `trustDataCurrent` are advisory and do not
decide the verdict. `trustDataCurrent` compares the coverage counts signed into
the certificate against the counts now: `false` means the merchant's catalog
moved since issuance, not that the certificate is invalid.

**null is not false:** Every check is nullable. `null` means the check did not run for this request —
  the signature checks, for example, are skipped when no document is submitted.
  `false` means the check ran and did not pass. Do not treat a skipped check as
  a failure.

## Verify offline with JWKS

Fetch and cache the RFC 7517 key set to verify certificates locally. Use a
remote JWKS client to refresh the keys automatically when the cache expires or
a certificate references a new key.

```bash
curl https://api.mintall.ai/api/v1/.well-known/jwks.json
```

```js
import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(
  new URL("https://api.mintall.ai/api/v1/.well-known/jwks.json"),
);

const { payload } = await jwtVerify(certificate.proof.jwt, jwks);

if (payload.domain !== new URL(pageUrl).hostname) {
  throw new Error("Certificate is not authorized for this domain");
}
if (payload.url && payload.url !== pageUrl) {
  throw new Error("Certificate was issued for a different page");
}
```

  ```python
import jwt
from jwt import PyJWKClient

jwks = PyJWKClient("https://api.mintall.ai/api/v1/.well-known/jwks.json")
key = jwks.get_signing_key_from_jwt(certificate["proof"]["jwt"])

payload = jwt.decode(
    certificate["proof"]["jwt"],
    key.key,
    algorithms=["RS256"],
    issuer="https://api.mintall.ai",
)

if payload["domain"] != urlparse(page_url).hostname:
    raise ValueError("Certificate is not authorized for this domain")
```

  ### Signed claims

Verifying the signature gets you these claims. Everything here is signed, so
none of it depends on trusting the page.

| Claim                    | Meaning                                      |
| ------------------------ | -------------------------------------------- |
| `iss`                    | Issuing API base URL                         |
| `iat` / `exp`            | Issued at, and 7 days later                  |
| `domain`                 | The domain the certificate is authorized for |
| `url`                    | The product page URL it was issued for       |
| `productId`              | Mintall's product ID                         |
| `externalProductId`      | The merchant platform's own product ID       |
| `productName`            | Product name at issuance                     |
| `storeName` / `storeUrl` | Merchant identity at issuance                |
| `certifiedAssets`        | Images with a C2PA certification             |
| `totalAssets`            | Images on the product                        |
| `coveragePercent`        | `certifiedAssets` over `totalAssets`         |

Doing it yourself skips `domainKnown`, `domainVerified`, and
`trustDataCurrent`: those compare against live Mintall state and need the
verify endpoint.