Certificates
The Certificates API lets you manage user certificates and qualifications (e.g. safety courses, builder cards, crane licenses) from an external competence or HR system, so site managers in Ditio can see at a glance who is qualified for what — and when a certificate expires. Certificates are matched to users by employee number.
Base URL: api/v4/integration/certificates · Scope: ditioapiv3
Examples use the environment variables from Environments and default to the test environment.
Create or update certificates
Section titled “Create or update certificates”POST /api/v4/integration/certificatesSend an array of certificates. The operation is an upsert: if a certificate already exists for the employee (matched by employee number + certificate type), it is updated; otherwise a new one is created. This makes the endpoint safe to call repeatedly from a scheduled sync.
Entries are processed one at a time, in order. The employee is looked up by employee number across your company structure.
curl -X POST "$DITIO_API_BASE/api/v4/integration/certificates" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '[ { "employeeNumber": "1042", "certificateType": "Byggekort", "certificateNumber": "BK-2026-12345", "issuedDateTime": "2026-01-15T00:00:00Z", "validUntilDateTime": "2028-01-15T00:00:00Z", "notes": "Utstedt av opplæringskontoret" }, { "employeeNumber": "1042", "certificateType": "Kranførerbevis", "issuedDateTime": "2025-06-01T00:00:00Z", "validUntilDateTime": "2027-06-01T00:00:00Z" } ]'Fields
Section titled “Fields”| Field | Type | Required | Description |
|---|---|---|---|
employeeNumber | string | Yes | Must match an existing employee in your company structure |
certificateType | string | Yes | Type of certificate (e.g. “Byggekort”, “Kranførerbevis”). See Certificate types |
certificateNumber | string | No | The number printed on the certificate. Stored on the certificate. On an update, leaving it out keeps the stored number |
issuedDateTime | datetime | Yes | Date the certificate was issued |
validUntilDateTime | datetime | Yes | Expiration date |
notes | string | No | Additional notes |
certificateNumber is stored on the certificate. When you update an existing
certificate, a certificateNumber you send replaces the stored one, and
leaving it out keeps the number that is already stored.
What is and isn’t checked
Section titled “What is and isn’t checked”- An entry with a missing
employeeNumber, or one that matches no employee, fails. - Dates are not validated. Nothing checks that
issuedDateTimeis beforevalidUntilDateTime, and an omitted date is stored as0001-01-01. Always send both dates. - If a current certificate with the same
employeeNumber+certificateTypealready exists, it is updated rather than duplicated. OnlycertificateNumber,issuedDateTime,validUntilDateTimeandnotesare updated, and only when one of them differs from what is stored.
Certificate types
Section titled “Certificate types”certificateType is matched against the certificate types configured for your
company, ignoring case and spaces: "Kranførerbevis", "kranførerbevis"
and "Kran førerbevis" all match the same type. The same rule finds the
existing certificate to update.
If no configured type matches, Ditio creates a new certificate type with
that name on your parent company. A typo therefore creates a new type rather
than failing, so keep your certificateType values consistent with the types
set up in Ditio Web.
Response
Section titled “Response”200 with an array of the certificates that were created or changed.
Entries that matched an existing certificate with identical values are not
included, so an empty array means nothing changed. Items use Ditio’s stored
field names rather than the request names, including id, userId (the
employee’s identity id), typeId, issued, validTo and notes.
Partial failure
Section titled “Partial failure”If any entry fails, the whole response is 400, with one item in errors per
failed entry. Entries processed successfully in the same request are
still saved, but they are not listed in the 400 response. Because the
endpoint is an upsert, fixing the failed entries and resending the whole array
is safe. See Errors for the error body.
Upload attachments to a certificate
Section titled “Upload attachments to a certificate”POST /api/v4/integration/certificates/attachments?employeeNumber={employeeNumber}&certificateType={certificateType}Attach images or PDF scans of the physical card (front and back, for
example) to a certificate. You identify the certificate with the same key as
the upsert, employeeNumber + certificateType, so you never need a Ditio
id. The files are added to the employee’s current certificate of that
type, which means the certificate must exist first: create it with
Create or update certificates, then upload
the files.
Send one or more files as multipart/form-data, each in a part named file.
| Param | Required | Effect |
|---|---|---|
employeeNumber | Yes | The employee’s number, as in the upsert |
certificateType | Yes | The certificate type, as in the upsert. Matching ignores case and spaces, see Certificate types |
replaceExistingFilesWithSameName | No | Default false. When true, a file your company uploaded earlier to this certificate with the same original file name is replaced instead of added again. Use it when you re-sync the same scans |
replaceExistingFilesWithSameName only replaces files your company
uploaded. Attachments added by anyone else, for example the employee in the
mobile app, are never replaced, even when the file name is the same. If one
request sends the same file name twice, the last file is kept.
curl -X POST "$DITIO_API_BASE/api/v4/integration/certificates/attachments?employeeNumber=1042&certificateType=Byggekort&replaceExistingFilesWithSameName=true" \ -H "Authorization: Bearer $TOKEN" \ -F "file=@/path/to/byggekort-front.jpg" \ -F "file=@/path/to/byggekort-back.jpg"Response (200):
{ "successful": true, "message": "Files uploaded and attached ok", "certificateId": "66c1a2b3c4d5e6f7a8b9c0d4", "files": [ { "fileReference": { "id": "66b1a2c3d4e5f6a7b8c9d0e2", "fileNameOrig": "byggekort-front.jpg" } } ], "attachments": ["66b1a2c3d4e5f6a7b8c9d0e1", "66b1a2c3d4e5f6a7b8c9d0e2"]}certificateId is Ditio’s id of the certificate the files were attached to.
files has one entry per file attached by this request; fileReference.id is
the stored file’s id and fileReference.fileNameOrig the file name you sent.
Each entry carries more file metadata than shown here. attachments lists the
ids of all files on the certificate after the upload, including earlier
ones, without any that were replaced.
| Status | When |
|---|---|
200 | The files were uploaded and attached |
400 | employeeNumber or certificateType is blank, no employee has that employeeNumber, or the body has no file part |
404 | The employee has no current certificate of that type. Create it first with the upsert above |
415 | The body is not multipart/form-data |
See Errors for the error body.
Python example — upload scans after a sync
Section titled “Python example — upload scans after a sync”import osimport requests# Paste get_token (/getting-started/authentication/#getting-a-token-in-your-scripts) above this.
API_BASE = os.environ.get("DITIO_API_BASE", "https://core-api.ditio.dev/core")headers = {"Authorization": f"Bearer {get_token('ditioapiv3')}"}
# 1. The certificate must exist: create or update it first.requests.post( f"{API_BASE}/api/v4/integration/certificates", headers=headers, json=[{ "employeeNumber": "1042", "certificateType": "Byggekort", "certificateNumber": "BK-2026-12345", "issuedDateTime": "2026-01-15T00:00:00Z", "validUntilDateTime": "2028-01-15T00:00:00Z", }],).raise_for_status()
# 2. Attach the scans to that certificate.with open("byggekort-front.jpg", "rb") as front, open("byggekort-back.jpg", "rb") as back: response = requests.post( f"{API_BASE}/api/v4/integration/certificates/attachments", headers=headers, params={ "employeeNumber": "1042", "certificateType": "Byggekort", "replaceExistingFilesWithSameName": "true", }, files=[ ("file", ("byggekort-front.jpg", front, "image/jpeg")), ("file", ("byggekort-back.jpg", back, "image/jpeg")), ], )response.raise_for_status()print(response.json()["attachments"])Python example — sync from a competence system
Section titled “Python example — sync from a competence system”import osimport requests# Paste get_token (/getting-started/authentication/#getting-a-token-in-your-scripts) above this.
API_BASE = os.environ.get("DITIO_API_BASE", "https://core-api.ditio.dev/core")
certificates = [ { "employeeNumber": "1042", "certificateType": "Byggekort", "issuedDateTime": "2026-01-15T00:00:00Z", "validUntilDateTime": "2028-01-15T00:00:00Z", },]
response = requests.post( f"{API_BASE}/api/v4/integration/certificates", headers={"Authorization": f"Bearer {get_token('ditioapiv3')}"}, json=certificates,)response.raise_for_status()Common issues
Section titled “Common issues”| Issue | Cause | Fix |
|---|---|---|
400 for one entry | employeeNumber is missing or doesn’t match a Ditio employee | Create the user first via Users or Employees v5, then resend |
Dates show as 0001-01-01 | A date field was omitted | Send both issuedDateTime and validUntilDateTime on every entry |
404 when uploading attachments | The employee has no current certificate of that type yet | Create the certificate with the upsert first, then upload the files |
| Uploaded scans appear twice | The same file was uploaded again without replaceExistingFilesWithSameName=true | Send replaceExistingFilesWithSameName=true on re-syncs |
| Unexpected new certificate types, or duplicates | Certificate type spelled differently between syncs | Keep certificateType values stable. Matching ignores case and spaces, but any other difference creates a new type and a separate certificate |
Related
Section titled “Related”- Users (v4) — the accounts certificates attach to
- Employees (v5) — modern employee management, including tags