# Certificates and legal terms

This guide covers two areas. For certificates, you manage a product's
certificate template and the certificates issued to users. For legal terms,
you manage the platform and product legal terms and read the acceptances
users have already recorded. None of these operations creates an acceptance:
an acceptance is always recorded by the user in the student area.

For the full schemas, see the [Certificates reference](https://cademi.dev/api/reference/certificates.md)
and the [Legal terms reference](https://cademi.dev/api/reference/legalterms.md).

## Product certificate template

`GET /products/{product_id}/certificate-template` (`certificates.read`)
returns the product's certificate template:

```json
{
  "object": "certificate_template",
  "product_id": "prd_42",
  "enabled": true,
  "single_issue": false,
  "criteria": { "kind": "progress_percent", "exam_id": null, "percent": 85 },
  "layout": {
    "background": null,
    "back_side_enabled": false,
    "show_dates": true,
    "show_product_name": true,
    "fields": {
      "name": { "enabled": true, "font": "opensans-bold-italic", "color": "#000000" },
      "document": { "enabled": true, "font": "opensans-italic", "color": "#000000" },
      "date": { "enabled": true, "font": "opensans-italic", "color": "#000000" },
      "sequence": { "enabled": false, "font": "opensans-italic", "color": "#000000" }
    },
    "qr": { "enabled": true, "position": "bottom-right", "color": "#000000", "background_color": "#FFFFFF" }
  },
  "texts": { "content": "...", "instructor": "", "workload": "" },
  "fields": [{ "key": "cfd_9", "label": "Profession", "source": "custom_field" }],
  "defaults": { "font": "opensans-italic", "color": "#000000" },
  "revision": "..."
}
```

`criteria.kind` accepts a fixed set of values: `completion` (available as
soon as the user has access), `one_week_after_start`, `two_weeks_after_start`,
`three_weeks_after_start`, `one_month_after_start`, `two_months_after_start`
(delays counted from the start of access), `progress_percent` (the threshold
goes in `percent` and must be one of `75`, `80`, `85`, `90`, or `95`), and
`exam` (the exam goes in `exam_id`).

`layout.fields` contains an `{enabled, font, color}` object for each of the
four printed fields (`name`, `document`, `date`, `sequence`). The user's name
is always printed, so `name.enabled` does not change the result. `layout.qr`
controls the QR code: whether it is shown, its position (`bottom-right`,
`bottom-left`, `top-right`, `top-left`), and its colors. `texts` contains only
`content` (the certificate body text), `instructor`, and `workload`; there is
no `title` or `signature`.

`PUT /products/{product_id}/certificate-template` (`certificates.update`)
replaces the whole template. Any field you omit from the body is reset to its
default, so a partial body never merges with the previous template. Send the
current revision in the `If-Match` header. The template's revision is the
product's revision, so other changes to the product also produce a new
`ETag`. A criterion, QR position, font, or percentage outside the supported
values returns `422 validation_failed`. Templates of replicated products are
read-only and return `403 replica_readonly`.

### Template preview

`POST /products/{product_id}/certificate-template/previews`
(`certificates.preview`) renders a preview with placeholder data, without a
user and without issuing a certificate. The `Idempotency-Key` header is
required:

```json
{ "object": "certificate_preview", "id": "cpv_931", "pdf_url": "https://...", "note": "..." }
```

The preview has no `file_id`: use `pdf_url` to open the rendered PDF. A
preview is not an issued certificate. It does not appear in certificate
listings or reports, and public certificate validation does not recognize
it. Templates of replicated products return `403 replica_readonly`.

## Issued certificates

`GET /users/{user_id}/certificates` (`certificates.read`, cursor-paginated)
lists the certificates issued to the user. You can filter by `product_id`,
`status` (`valid`, `revoked`), `issued_after`, and `issued_before`. Revoked
certificates stay in the list with `status: revoked`; deleted certificates
do not appear.

```json
{
  "object": "certificate",
  "id": "cer_501",
  "user_id": "usr_42",
  "product_id": "prd_42",
  "code": "ABCD-1234",
  "number": { "instance": 118, "product": 7 },
  "status": "valid",
  "reissue": false,
  "supersedes_certificate_id": null,
  "superseded_by_certificate_id": null,
  "pdf_url": "https://...",
  "validation_url": "https://.../cert/ABCD-1234",
  "issued_at": "2026-09-20T12:00:00Z",
  "revoked": null,
  "deleted": false,
  "revision": "..."
}
```

The document, address, and custom field values captured at issuance are
returned in `fields{}` only when the credentials have the
`users.read_personal` permission. Without it, the field is omitted, never
`null`.

`POST /users/{user_id}/certificates` (`certificates.create`) issues a
certificate to the user. Send `{product_id}` in the body. If the user is not
eligible, the API returns `409 state_conflict` with the reason in
`details[].reason`: `no_access` (the user has no access to the product),
`template_disabled` (the product does not issue certificates),
`exam_not_passed` (the release rule is an exam and the user has not passed
it), or `not_completed` (any other release rule not yet met). If the user
already holds a valid certificate for the product, the API returns `200` with
that certificate instead of issuing a new one. This applies in addition to
the `Idempotency-Key` header. A newly issued certificate returns `201`.

Sending `supersedes_certificate_id` performs a **reissue**. Reissuing
requires the dedicated `certificates.reissue` permission in addition to
`certificates.create`. The certificate you reference is revoked with the
reason `reissued` in the same request, so a user always has at most one valid
certificate per product.

`GET /users/{user_id}/certificates/{certificate_id}` (`certificates.read`)
returns a single certificate. Requesting another user's certificate through
the URL returns `404`, not `403`.

### Revoke

`PATCH /users/{user_id}/certificates/{certificate_id}` (`certificates.revoke`)
supports a single change: revoking the certificate. Send the current revision
in the `If-Match` header.

```json
{ "status": "revoked", "reason": "Certificate issued by mistake." }
```

Any other field in the body returns `422 unknown_field`: an issued
certificate cannot be edited. The response includes `status: revoked` and
the `revoked` object:

```json
{ "at": "2026-09-23T10:00:00Z", "reason": "Certificate issued by mistake.", "by": { "kind": "admin", "id": "adm_7" } }
```

Revoking **keeps the certificate on record**: it stays retrievable and still
counts as a previous certificate for the `reissue` flag. Public certificate
validation reports it as revoked. The user's access, progress, and points are
not affected. Revoking a certificate that is already revoked returns
`409 state_conflict`.

### Delete

`DELETE /users/{user_id}/certificates/{certificate_id}` (`certificates.delete`)
is different from revoking: it moves the certificate to the trash and removes
it from the user's history. A reason is required in the body (`{reason}`).
After deletion, public validation
no longer finds the certificate code, and the product's release rule applies
to the user again as usual. The API returns `204`.

## Permissions

`certificates.read`, `certificates.create`, `certificates.update` (the
template), `certificates.delete`, `certificates.reissue`, `certificates.revoke`,
and `certificates.preview`. Reissuing and revoking have their own
permissions, separate from creating: a credential can issue certificates
without being able to reissue or revoke them.

## Legal terms

Each scope has a single text, without versions: at most one legal term for
the platform and one per product. The legal term ID is derived from its
scope: `trm_platform`, or `trm_product_<n>`, where `<n>` is the numeric part
of the product's public ID (for example, `trm_product_42` for `prd_42`).

`GET /legal-terms` (`legal_terms.read`) lists the configured legal terms
without their body. All matching legal terms are returned in a single
response, without a cursor. You can filter by `scope` and `product_id`:

```json
{ "object": "legal_term", "id": "trm_product_42", "scope": "product", "product_id": "prd_42", "title": "...", "acceptances_count": 318, "updated_at": "...", "revision": "..." }
```

`GET /legal-terms/{legal_term_id}` (`legal_terms.read`) returns the same
resource with `body{blocks[]}` (an Editor.js document). A scope without a
configured legal term returns `404`: the legal term does not exist, rather
than existing with an empty body.

`POST /legal-terms` (`legal_terms.create`) configures the legal term for a
scope that does not have one yet: `{scope, product_id?, body}`. The `product`
scope requires `product_id`. If the scope already has a legal term, the API
returns `409 already_exists`; use `PATCH` to change the text.

`PATCH /legal-terms/{legal_term_id}` (`legal_terms.update`) replaces the
text: `{body}`. Send the current revision in the `If-Match` header. Setting
`body: null` removes the legal term. Changing the text does **not** reopen
acceptances already recorded: an acceptance is a dated entry in the user's
history, with proof of origin, not a signature of a specific version. Legal
terms of replicated accounts and replicated products are read-only and
return `403 replica_readonly`.

## Legal term acceptances (read-only)

This API never creates an acceptance: acceptances are always recorded when
the user accepts the legal term in the student area. The operations below
are read-only.

`GET /legal-terms/{legal_term_id}/acceptances` (`legal_terms.read`,
cursor-paginated, filters `user_id`, `accepted_after`, `accepted_before`) and
`GET /legal-terms/{legal_term_id}/acceptances/{term_acceptance_id}` are the
canonical location of an acceptance:

```json
{ "object": "term_acceptance", "id": "tac_9021", "legal_term_id": "trm_product_42", "user_id": "usr_42", "accepted_at": "2026-09-10T08:00:00Z" }
```

`ip` and `user_agent` are the proof of origin of the acceptance and are
personal data of the user. They are returned in `proof{ip, user_agent}` only
when the credentials have the `legal_terms.read_proof` permission; without
it, the object is omitted.

`GET /users/{user_id}/term-acceptances` (`legal_terms.read`) lists a user's
acceptances across both scopes (the platform and each product). Each item
includes `links.self`, which points to the canonical location under
`/legal-terms`, not to this endpoint.

## Events

`certificate.issued`, `certificate.revoked`, `certificate.deleted`,
`certificate_template.updated`, `legal_term.updated`, `term_acceptance.created`.
Event payloads do not include the user's personal data.

## Errors

| Code | Status | When |
|---|---|---|
| `not_found` | 404 | The user, product, certificate, legal term, or acceptance does not exist or is not accessible with the current credentials |
| `permission_denied` | 403 | The credentials lack the permission for the operation (including `certificates.reissue`, `certificates.revoke`, and `certificates.preview`). Missing `legal_terms.read_proof` does not cause an error: `proof` is omitted |
| `replica_readonly` | 403 | The certificate template or legal term belongs to a replicated product or account |
| `state_conflict` | 409 | The user is not eligible for a certificate (reason in `details[].reason`), or the certificate is already revoked |
| `already_exists` | 409 | The scope already has a legal term |
| `revision_mismatch` | 412 | The `If-Match` revision does not match the current revision |
| `validation_failed` | 422 | A required field is missing, a criterion, QR position, font, or percentage is outside the supported values, or the reason is empty |
| `unknown_field` | 422 | The body contains an unsupported field when revoking a certificate or updating a legal term |
