> ## Documentation Index
> Fetch the complete documentation index at: https://help.skillsdb.com/llms.txt
> Use this file to discover all available pages before exploring further.

# The Person Certification object

> A person's certification — what they hold, its approval workflow, evidence, expiry and history

<Info>
  **Quick Summary:** A person certification is one person's holding of a [certification](/api-docs/resources/certification): its workflow state, issued and expiry dates, identifier, evidence and history. Record it, move it through approval, renew it, and report coverage across a population. **Expiry is derived from the date, never from the status** — an approved certification stays `approved` in storage after its date passes, so every read carries a separate `expiry` block.
</Info>

## Attributes

| Field                                     | Type            | Description                                                                                                                              |
| ----------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `person`                                  | object          | `{ id, email, firstName, lastName }`                                                                                                     |
| `certification`                           | object          | `{ id, name, type }` — the library entry and its type name                                                                               |
| `status`                                  | string \| null  | `assigned`, `awaiting_approval`, `approved` or `denied`; `null` when the record has never been graded                                    |
| `certified`                               | boolean \| null | Pass/fail flag. Certifications are not graded on the company scale — there is no rung or benchmark                                       |
| `identifier`                              | string \| null  | Certificate number issued by the authority                                                                                               |
| `issuedAt` / `expiresAt`                  | string \| null  | Issued and expiry dates (ISO 8601 UTC)                                                                                                   |
| `expiry`                                  | object          | `{ state, daysUntil }` — `state` is `none`, `valid`, `expiring` or `expired`, derived at read time; `daysUntil` is negative once expired |
| `scoreDescription`                        | string \| null  | Free-text score description                                                                                                              |
| `issuingAuthority`                        | string \| null  | The authority recorded on this person's certification                                                                                    |
| `heldDirectly`                            | boolean         | Held as a direct assignment                                                                                                              |
| `careers`                                 | array of string | Careers the certification is derived through. A certification can be held both ways at once                                              |
| `attachmentCount`                         | integer         | Evidence on the live record                                                                                                              |
| `labels`                                  | array           | `{ id, name }` of the library certification's labels                                                                                     |
| `submittedAt` / `approvedAt` / `deniedAt` | string \| null  | Decision timestamps                                                                                                                      |

The detail read adds `attachments`: `{ id, type, label, url, createdAt }` where `type` is `file` or `link` and `url` is set for links only.

```json theme={null}
{
  "person": { "id": "1042", "email": "ada@example.com", "firstName": "Ada", "lastName": "Lovelace" },
  "certification": { "id": "87", "name": "Forklift Operator Licence", "type": "Regulatory" },
  "status": "approved",
  "certified": true,
  "identifier": "FL-2291-B",
  "issuedAt": "2025-06-01T00:00:00.000Z",
  "expiresAt": "2027-06-01T00:00:00.000Z",
  "expiry": { "state": "valid", "daysUntil": 268 },
  "scoreDescription": null,
  "issuingAuthority": "National Safety Council",
  "heldDirectly": true,
  "careers": ["Warehouse Operator"],
  "attachmentCount": 1,
  "labels": [{ "id": "12", "name": "Safety" }],
  "submittedAt": "2025-06-02T09:00:00.000Z",
  "approvedAt": "2025-06-03T10:00:00.000Z",
  "deniedAt": null
}
```

## Operations

Person path slots (`{personId}`) accept the person's **email address** or the opaque id from any response.

### A person's certifications

| Operation                                 | Method and path                                                          | Requires scope        |
| ----------------------------------------- | ------------------------------------------------------------------------ | --------------------- |
| List a person's certifications            | `GET /api/v1/people/{personId}/certifications`                           | `certifications:read` |
| Get one of a person's certifications      | `GET /api/v1/people/{personId}/certifications/{certificationId}`         | `certifications:read` |
| List certifications available to a person | `GET /api/v1/people/{personId}/available-certifications`                 | `certifications:read` |
| List a person's certification history     | `GET /api/v1/people/{personId}/certifications/{certificationId}/history` | `certifications:read` |

The list is filterable by `status` (`eq` / `in`) and `expiry` (`eq` / `in` over `none`, `valid`, `expiring`, `expired`); the page total is the count for the same filters. A certification held directly **and** through a career appears once, with `heldDirectly: true` and its `careers` listed.

The history is every superseded attempt — archived by renewal (`endedAs: "expired"`) or by resubmission after a denial (`endedAs: "denied"`) — each with its dates, identifier, who assigned, submitted, approved or denied it and when, and the evidence that stayed with that attempt. Read it with the live record for the full story.

### Recording and maintaining

| Operation                          | Method and path                                                           | Requires scope         |
| ---------------------------------- | ------------------------------------------------------------------------- | ---------------------- |
| Record (or assign) a certification | `POST /api/v1/people/{personId}/certifications`                           | `certifications:write` |
| Update a person's certification    | `PATCH /api/v1/people/{personId}/certifications/{certificationId}`        | `certifications:write` |
| Submit for approval                | `POST /api/v1/people/{personId}/certifications/{certificationId}/submit`  | `certifications:write` |
| Approve                            | `POST /api/v1/people/{personId}/certifications/{certificationId}/approve` | `certifications:write` |
| Deny                               | `POST /api/v1/people/{personId}/certifications/{certificationId}/deny`    | `certifications:write` |
| Renew                              | `POST /api/v1/people/{personId}/certifications/{certificationId}/renew`   | `certifications:write` |
| Unassign                           | `DELETE /api/v1/people/{personId}/certifications/{certificationId}`       | `certifications:write` |

**Recording.** `certificationId` is required. With nothing else the record is the light *assign* — it lands in `assigned` with no dates, identifier or evidence. Add `issuedAt`, `expiresAt`, `identifier`, `scoreDescription`, `certified`, `issuingAuthority`, one `link` and up to ten `files` to record it in full. Each file returns a pre-signed upload URL in `uploads`; upload the bytes to it. A person who already holds the certification is a [409](/api-docs/errors/conflict) — update or renew instead.

Two fields can be set here that no app screen can set: `scoreDescription`, and `certified: false`. The app reads both, so API-recorded certifications may carry values app-recorded ones never do.

**The state machine.** `assigned` → `awaiting_approval` (submit) → `approved` or `denied`; `denied` → `awaiting_approval` (submit again); `approved` is terminal except through renewal. Approve also accepts `assigned` directly, matching the app's manager path. A transition the current state does not allow is a [409](/api-docs/errors/conflict).

**Updating** is allowed while `assigned`, `awaiting_approval` or `denied`; an approved certification is closed to edits (409) and must be renewed. Omitted fields are untouched; an explicit `null` clears `issuedAt`, `expiresAt`, `identifier`, `scoreDescription` or `issuingAuthority`. `expiresAt` may not precede `issuedAt`.

**Renewing** archives the current attempt and its evidence to history as `expired`, resets the record to `assigned`, and records the new issue. The identifier and issuing authority carry forward unless replaced; the previous result does not.

**Unassigning** removes a direct assignment and the person's record of it. A certification held through a career cannot be unassigned per person (409, with the careers named) — change the career instead.

### Evidence

| Operation                    | Method and path                                                                                          | Requires scope         |
| ---------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------- |
| Update an attachment's label | `PATCH /api/v1/people/{personId}/certifications/{certificationId}/attachments/{attachmentId}`            | `certifications:write` |
| Remove an attachment         | `DELETE /api/v1/people/{personId}/certifications/{certificationId}/attachments/{attachmentId}`           | `certifications:write` |
| Get a download URL           | `GET /api/v1/people/{personId}/certifications/{certificationId}/attachments/{attachmentId}/download-url` | `certifications:read`  |

Attachments are supplied on the record, update and renew writes. The download URL is short-lived, and is issued only for a **file** attachment that belongs to this person and this certification; anything else is a 404.

### Coverage and reporting

| Operation                           | Method and path                                               | Requires scope        |
| ----------------------------------- | ------------------------------------------------------------- | --------------------- |
| List people holding a certification | `GET /api/v1/certifications/{id}/people`                      | `certifications:read` |
| Certification status breakdown      | `GET /api/v1/certifications/{id}/status-breakdown`            | `certifications:read` |
| Coverage across a scope             | `GET /api/v1/certification-coverage?scope=team\|org\|company` | `certifications:read` |
| Search people's certifications      | `GET /api/v1/people-certifications`                           | `certifications:read` |

All four return the object above, filter by `status` and `expiry`, and accept `expiringWithinDays` (default 30) to set what counts as `expiring`. Coverage is relative to the **acting person**: `team` is their direct reports, `org` their whole span of control, `company` the company (admin and full-access identities only). Search adds `certificationId`, `email` (`eq`, `like`) and `career` (`like`).

The status breakdown answers the compliance question directly:

```json theme={null}
{
  "certificationId": "87",
  "holders": 46,
  "byStatus": { "assigned": 3, "awaitingApproval": 2, "approved": 40, "denied": 1, "unknown": 0 },
  "expiringWithinDays": 30,
  "expiry": { "expired": 4, "expiringSoon": 6, "valid": 30, "none": 6 }
}
```

A status-only view would count the four lapsed holders as approved. Never collapse `expiry` into `status`.

### Batch and composite

| Operation                              | Method and path                                | Requires scope         |
| -------------------------------------- | ---------------------------------------------- | ---------------------- |
| Decide many certifications at once     | `POST /api/v1/people-certifications/decisions` | `certifications:write` |
| Record a certification by external key | `POST /api/v1/people-certifications/composite` | `certifications:write` |

**Decisions** take `{ decision: "approve" | "deny", items: [{ email, certificationId }] }` (up to 200). The call is **not atomic**: each item is decided on its own and the response lists every item's outcome (`applied` or `failed`, with a reason) in request order.

**Composite** is the integration-shaped write: `{ email, certificationTitle | certificationId, issuedAt, expiresAt?, scoreDescription?, renew? }`. It resolves the person by email, finds the certification by exact title (creating it in the library, name only, if absent) or by id, records it and approves it directly; `renew: true` routes through renewal of the current approved record. Restricted to **global-administrator** acting identities.

## Permissions

Reads resolve against the acting person's visibility of people: admin identities see the company, a manager their span of control, and any person their own record. A person outside that span is a [404](/api-docs/errors/not-found), indistinguishable from one that does not exist. Writes resolve against the acting person's permission to update certification records — span of control for a manager, company-wide for admin identities. Two app roles hold no certification-record permissions at all, and a key acting as one receives [insufficient-permissions](/api-docs/errors/insufficient-permissions) on every write here.

## Related errors

* [not-found](/api-docs/errors/not-found) — the person is not in your company or span, or does not hold this certification
* [conflict](/api-docs/errors/conflict) — already held on record; a transition the state does not allow; an approved record on update; unassigning a career-held certification
* [validation-error](/api-docs/errors/validation-error) — an unknown `certificationId`, an expiry before the issued date, a bad `scope` or `expiringWithinDays`
* [unprocessable](/api-docs/errors/unprocessable) — the composite write could not be applied in the record's current state
