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 and the Legal terms reference.

Product certificate template

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

{
  "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:

{ "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.

{
  "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.

{ "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:

{ "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.

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:

{ "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.

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:

{ "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

CodeStatusWhen
not_found404The user, product, certificate, legal term, or acceptance does not exist or is not accessible with the current credentials
permission_denied403The 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_readonly403The certificate template or legal term belongs to a replicated product or account
state_conflict409The user is not eligible for a certificate (reason in details[].reason), or the certificate is already revoked
already_exists409The scope already has a legal term
revision_mismatch412The If-Match revision does not match the current revision
validation_failed422A required field is missing, a criterion, QR position, font, or percentage is outside the supported values, or the reason is empty
unknown_field422The body contains an unsupported field when revoking a certificate or updating a legal term

On this page