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