Leon API Integration Guide: Crew Certificate & Endorsement Sync
Version: 2026-08-20 API: Leon GraphQL API (Beta) Prepared by: Leon Software Support
What this guide does
It shows you how to push crew certificates / endorsements (licences, medicals, training records, type ratings, company certificates) from your own system into Leon, so they appear on each crew member's Leon profile and feed Leon's expiry alerts and crew qualification checks.
This is a one-way, push-based integration: your system is the source of truth, Leon is the destination.
Before you start
Make sure you have:
- A registered Leon OAuth application (
client_id+client_secret) — see Step 1a - The OAuth scope
CREW_MEMBER_EXTERNAL_ENDORSEMENT_EDITenabled on your client - From each operator connecting to you: their Leon
oprId(the first part of the address they sign in to, e.g.demoindemo.leon.aero) - Persistent storage on your side for one
endorsementNidper operator, per certificate type — this is not optional, see Step 2 - The work e-mail of each crew member, recorded identically in your system and in Leon — this is the only matching key
All steps at a glance
- Authenticate (OAuth 2.0 Code Grant) → get
access_token - Create the endorsement definition — once per operator, per certificate type → get and store
endorNid - Push a certificate to a crew member (create-or-update) → confirmation
- (Optional) Push a certificate with a file attached (multipart upload)
- Remove a certificate from a crew member
- (Recovery) List your endorsement definitions if you lost an
endorNid
Step 1 — Authenticate
⚠️ OAuth is mandatory for 3rd party software providers. If your product will be used by more than one Leon operator, you must use this method. API Key is not an option — it is tied to a single operator and, more importantly, the external endorsement mutations in Step 2 and Step 3 explicitly require an OAuth-authenticated client. They will not work with an API Key.
Step 1a — Register your application (one-time)
Before writing any code, submit the Leon API registration form: https://leonsoftware.atlassian.net/servicedesk/customer/portal/4/group/8/create/40
You will receive a client_id and client_secret. Develop and test on the sandbox environment (https://{oprId}.sandbox.leon.aero/api/graphql/). Production access requires a demo meeting with Leon once development is complete.
When you register, ask explicitly for the scopes listed below — they are not granted by default.
Step 1b — Request user authorization
When an operator wants to connect your application, redirect their admin user to:
https://{oprId}.leon.aero/oauth2/code/authorize/?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&scope={SCOPE_LIST}&state={OPTIONAL_STATE}
Fill in:
| Placeholder | Where to get it |
|---|---|
{oprId} | The operator's Leon subdomain (ask any user: "What address do you use to sign in to Leon?") |
{CLIENT_ID} | Your client_id from Leon registration |
{REDIRECT_URI} | Your redirect URI — must exactly match what you submitted at registration |
{SCOPE_LIST} | Space-delimited scopes (see below) |
{OPTIONAL_STATE} | Optional CSRF token — recommended |
Required scopes for this integration:
| Scope | Needed for | Required? |
|---|---|---|
CREW_MEMBER_EXTERNAL_ENDORSEMENT_EDIT | Steps 2, 4, 5, 6 — creating definitions and writing certificates | Yes |
ENDORSEMENT_DEFINITION_SEE | Step 6 — reading back your endorsement definitions | Recommended |
Note the scope names: the endorsement scopes have no
GRAPHQL_prefix, while the crew member scope does. This is inconsistent but intentional — use the exact strings above.
After the user grants consent, Leon redirects to your {REDIRECT_URI} with ?code=... in the query string.
Save this → code from the redirect URL — valid for 10 minutes, single use.
Step 1c — Exchange the code for tokens
curl --location --request POST 'https://{oprId}.leon.aero/oauth2/code/token/' \
--form 'grant_type="authorization_code"' \
--form 'client_id="{CLIENT_ID}"' \
--form 'client_secret="{CLIENT_SECRET}"' \
--form 'redirect_uri="{REDIRECT_URI}"' \
--form 'code="{AUTHORIZATION_CODE}"'
⚠️ The trailing slash in the URL is required.
Response:
{
"token_type": "Bearer",
"access_token": "eyJhbGc...",
"refresh_token": "dGhpcyBp..."
}
Save this →
access_token— use in all API requests (valid 30 minutes)refresh_token— use to obtain a new access token (valid 30 days since last use)
Store both per oprId. Never share tokens between operators.
Step 1d — Refresh the access token
curl --location --request POST 'https://{oprId}.leon.aero/oauth2/code/token/' \
--form 'grant_type="refresh_token"' \
--form 'client_id="{CLIENT_ID}"' \
--form 'client_secret="{CLIENT_SECRET}"' \
--form 'refresh_token="{REFRESH_TOKEN}"'
⚠️ Token management rules:
- Reuse the access token for its full 30 minutes — do not create a new token per request
- Limit: 500 active access tokens per refresh token; exceeding it returns HTTP
429 Too Many Requests(check theRetry-Afterheader)- Refresh proactively when
expiry − now < 5 minutesrather than waiting for a401- A
401on the refresh call means the refresh token expired — the operator's admin must repeat Step 1b
Step 2 — Create the endorsement definition (once per operator, per certificate type)
Creates the certificate type in the operator's Leon (e.g. "Line Training Completion", "SEP Refresher") and returns the
endorNidyou will use in every later call.
⚠️ This call is not idempotent. Calling it twice with the same name creates two separate definitions with two different
endorNidvalues — it does not return the existing one. Call it once, store the result, and never call it again for that operator/certificate-type pair. If you lose the value, use Step 6 to recover it rather than creating a new definition.
Request:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Body:
mutation CreateExternalEndorsementDefinition($name: String!) {
endorsement {
createExternalDefinition(name: $name) {
... on NonNullExternalEndorsementValue {
definition: value {
endorNid
name
}
}
... on EndorsementMutationSectionCreateExternalDefinitionViolationList {
errorList: value {
message
path
category
}
}
}
}
}
📖 Full type reference → · ExternalEndorsement
Fill in:
| Variable | Where to get it | Example |
|---|---|---|
$name | The certificate type name as it should appear in Leon. Must not be blank. | "CRM Recurrent Training" |
Variables JSON:
{
"name": "CRM Recurrent Training"
}
Response (relevant fields only):
{
"data": {
"endorsement": {
"createExternalDefinition": {
"definition": { "endorNid": 12345, "name": "CRM Recurrent Training" }
}
}
}
}
Save this → endorNid — store it against this oprId and certificate type. Every call in Steps 4–6 needs it.
Errors: IS_BLANK_ERROR in errorList means $name was empty.
What the operator sees: the definition appears in the operator's Leon under Settings → Crew Endorsements, alongside their manually created ones, tagged with your application name. The operator can adjust its configuration there (validity period, applicable positions/aircraft, whether it is required, alert windows) — none of that is settable through the API, so agree it with them once during onboarding.
Step 3 — Push a certificate to a crew member
Creates the certificate on that crew member's profile, or updates it if it already exists. One call per crew member, per certificate type.
⚠️ This is a full overwrite, not a partial update. Any optional field you omit is written as empty — it is not left at its previous value. Always send the complete record every time.
How crew members are matched
Leon identifies the crew member solely by the work e-mail you pass in $email — there is no other key available in this flow. Before you start syncing an operator, agree with them how crew identity will be kept aligned between the two systems, and make sure the work e-mail stored on your side is byte-for-byte the address held on the crew member's Leon profile (matching is case-insensitive, but nothing else is normalised).
Three conditions must hold for a match to succeed:
- The address is the crew member's work e-mail in Leon — not their login, not a personal address
- The crew member is active in Leon: not deleted, and not marked as having no application access
- The address is unique across the operator's crew — if two profiles share a work e-mail, Leon silently picks one of them, and you cannot control which
Crew members with no work e-mail in Leon cannot receive certificates through this API at all. Treat any of these situations as an onboarding issue to raise with the operator, not something to work around: report the affected crew back to them and have the data corrected in Leon before the first sync run. Re-check the alignment whenever the operator onboards or offboards crew.
Request:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Body:
mutation PutExternalEndorsementForCrewMember(
$endorsementNid: EndorsementDefinitionNid!
$email: CrewMemberEmail!
$endorsementData: CrewMemberExternalEndorsementInputType!
) {
endorsement {
putExternalForCrewMember(
endorsementNid: $endorsementNid
email: $email
endorsementData: $endorsementData
) {
... on NonNullBooleanValue {
success: value
}
... on EndorsementMutationSectionPutExternalForCrewMemberViolationList {
errorList: value {
message
path
category
}
}
}
}
}
📖 Full type reference → · EndorsementMutationSection · DateOrNever
Fill in:
| Variable | Where to get it | Example |
|---|---|---|
$endorsementNid | The endorNid you stored in Step 2 for this operator and certificate type | 12345 |
$email | Crew member's work e-mail, exactly as held in Leon | "john.doe@operator.com" |
$endorsementData | The certificate record from your system — see the field table below | see below |
$endorsementData fields:
| Field | Required | Format | Notes |
|---|---|---|---|
dateOfExpiry | Yes | { "date": "YYYY-MM-DD" } or { "never": true } | Exactly one of the two. Sending both, or neither, returns VALUE_NOT_VALID. Leon does not calculate this from the definition's validity period — you must always supply it. |
number | No | String | Certificate / licence number |
dateOfIssue | No | "YYYY-MM-DD" (ISO 8601) | |
initialIssueDate | No | "YYYY-MM-DD" (ISO 8601) | First-ever issue date, if you track revalidations |
note | No | String | Visible to admins |
crewNote | No | String | Visible to the crew member |
fileList | No | File upload | See Step 4. Omit the field entirely if you have no files — sending an empty array [] returns IS_NO_EMPTY. |
fileInputList | No | — | Do not use. Internal to Leon's own front end; there is no external way to obtain valid values. |
Variables JSON:
{
"endorsementNid": 12345,
"email": "john.doe@operator.com",
"endorsementData": {
"number": "CRM-2026-0417",
"dateOfIssue": "2026-04-17",
"initialIssueDate": "2021-03-02",
"dateOfExpiry": { "date": "2027-04-30" },
"note": "Synced from TrainingSystem, session #88213"
}
}
For a certificate that never expires, replace dateOfExpiry with:
"dateOfExpiry": { "never": true }
Response (relevant fields only):
{
"data": {
"endorsement": {
"putExternalForCrewMember": { "success": true }
}
}
}
Save this → nothing new. Record the sync result against the crew member on your side so you can retry failures.
Errors returned in errorList:
category | Meaning | What to do |
|---|---|---|
IS_NOT_SOME_ERROR | The e-mail matched no active crew member, or the endorsementNid does not exist for this operator | Check the address against the crew member's Leon profile; re-run Step 6 to confirm the definition still exists |
ENDORSEMENT_NOT_FROM_OAUTH | The endorsementNid points to a definition the operator created manually in Leon, not one created via Step 2 | You can only write into definitions your own application created |
VALUE_NOT_VALID | dateOfExpiry has both date and never, or neither | Send exactly one |
IS_NO_EMPTY | fileList or fileInputList was sent as an empty array | Omit the field instead |
⚠️ One error is not returned in
errorList: if theendorsementNidbelongs to a different OAuth application's definition, Leon returns a generic GraphQL error rather than a structured violation. Treat unexplained generic errors on this mutation as a sign that your storedendorNidis wrong.
Step 4 — Push a certificate with a file attached (optional)
Same operation as Step 3, sent as a multipart request so a scanned certificate is stored on the record.
File uploads follow the graphql-multipart-request-spec. Use the fileList field — it is the only file mechanism available to external integrators.
Request:
curl 'https://{oprId}.leon.aero/api/graphql/' \
-H 'Authorization: Bearer {ACCESS_TOKEN}' \
-F operations='{
"query": "mutation PutExternalEndorsementForCrewMember($endorsementNid: EndorsementDefinitionNid!, $email: CrewMemberEmail!, $endorsementData: CrewMemberExternalEndorsementInputType!) { endorsement { putExternalForCrewMember(endorsementNid: $endorsementNid, email: $email, endorsementData: $endorsementData) { ... on NonNullBooleanValue { success: value } ... on EndorsementMutationSectionPutExternalForCrewMemberViolationList { errorList: value { message path category } } } } }",
"variables": {
"endorsementNid": 12345,
"email": "john.doe@operator.com",
"endorsementData": {
"number": "CRM-2026-0417",
"dateOfExpiry": { "date": "2027-04-30" },
"fileList": [null]
}
}
}' \
-F map='{"0":["variables.endorsementData.fileList.0"]}' \
-F 0=@/path/to/certificate.pdf
Fill in:
| Part | What it is |
|---|---|
operations | The GraphQL request. Every file position in fileList must be null here — the actual bytes come from the file parts. |
map | Maps each file part name to its position in the variables. For two files: {"0":["variables.endorsementData.fileList.0"],"1":["variables.endorsementData.fileList.1"]} |
0, 1, … | The file parts themselves |
Save this → nothing new. Response format is identical to Step 3.
Step 5 — Remove a certificate from a crew member
Deletes the certificate record from that crew member's profile. The definition itself stays in place.
Request:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Body:
mutation DeleteExternalEndorsementForCrewMember(
$endorsementNid: EndorsementDefinitionNid!
$email: CrewMemberEmail!
) {
endorsement {
deleteExternalForCrewMember(endorsementNid: $endorsementNid, email: $email) {
... on NonNullBooleanValue {
success: value
}
... on EndorsementMutationSectionDeleteExternalForCrewMemberViolationList {
errorList: value {
message
path
category
}
}
}
}
}
Fill in:
| Variable | Where to get it | Example |
|---|---|---|
$endorsementNid | The endorNid stored in Step 2 | 12345 |
$email | Crew member's work e-mail | "john.doe@operator.com" |
Variables JSON:
{
"endorsementNid": 12345,
"email": "john.doe@operator.com"
}
Response (relevant fields only):
{
"data": {
"endorsement": {
"deleteExternalForCrewMember": { "success": true }
}
}
}
Errors: IS_NOT_SOME_ERROR (crew member or definition not found) and ENDORSEMENT_NOT_FROM_OAUTH (the definition was not created by your application) — same meanings as in Step 3.
Step 6 — Recover a lost endorsement definition ID
Use this when your stored
endorNidis missing, or to verify that a definition still exists before a sync run. Do not solve a lost ID by calling Step 2 again — that creates a duplicate.
Requires the ENDORSEMENT_DEFINITION_SEE scope.
Request:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Body:
query ListEndorsementDefinitions {
endorsement {
definitionList {
endorNid
name
isFromExternalSource
oauthClientName
}
}
}
📖 Full type reference → · EndorsementQuerySection
No variables required. Not paginated.
Response (relevant fields only):
{
"data": {
"endorsement": {
"definitionList": [
{ "endorNid": 12345, "name": "CRM Recurrent Training", "isFromExternalSource": true, "oauthClientName": "TrainingSystem" },
{ "endorNid": 999, "name": "Medical Class 1", "isFromExternalSource": false, "oauthClientName": null }
]
}
}
}
Save this → the endorNid of entries where isFromExternalSource is true and oauthClientName matches your registered application name.
⚠️ This query returns all of the operator's definitions — their manual ones and other integrations' external ones included.
oauthClientNameis the only field that identifies yours, and definition names are not unique, so if you see two entries with your client name and the same name, a duplicate was created at some point. Ask the operator which one holds the live data before writing to it.
Recommended sync design
There is no change-feed for endorsements, so this integration is driven entirely from your side:
- On connect (per operator): run Step 2 once per certificate type, store every
endorNid. Separately, reconcile crew identities with the operator — confirm every crew member's work e-mail matches between the two systems, and report missing or duplicated addresses back to them. - On change (per certificate): when a certificate is issued, revalidated, or corrected in your system, call Step 3 immediately. When it is revoked, call Step 5.
- Periodic reconciliation (daily is usually enough): re-run Step 6 to confirm your definitions still exist, re-check crew e-mail alignment to pick up joiners and leavers, and re-push the full current certificate set. Because Step 3 is a create-or-update, re-pushing is safe.
- Rate: stay well inside the token limits by holding one access token per operator for its full 30 minutes. There is no bulk mutation, so a full re-push costs one request per crew member per certificate type — size your reconciliation window accordingly.
Behaviour you need to design around
| Behaviour | Consequence |
|---|---|
| Certificates you push behave exactly like manually created ones | They appear in Crew Endorsements, count towards expiry alerts and crew qualification checks. Pushing wrong expiry dates has real operational effect. |
| Individual certificate records carry no "owned by integration" marker | The operator can edit or delete a record you pushed, from Leon's UI. Your next sync will silently restore it. |
| The operator can delete your definition | Deleting a definition cascades and removes every crew certificate under it. Your stored endorNid then becomes invalid — handle IS_NOT_SOME_ERROR by re-checking Step 6 and alerting the operator, not by creating a new definition automatically. |
| The definition is per operator | The same certificate type across ten operators means ten different endorNid values. Key your storage on (oprId, certificateType). |
| "Never expires" is stored as a far-future date, not as an empty value | If you read data back through other Leon reports, expect a date rather than a null. |
If something goes wrong
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized | Access token expired or missing | Refresh via Step 1d and retry with the new token |
401 on the refresh call itself | Refresh token expired (30 days unused) | Operator admin must repeat the authorization flow in Step 1b |
429 Too Many Requests | More than 500 active access tokens on one refresh token — usually caused by minting a token per request | Cache one access token per operator for its full 30 minutes; honour Retry-After |
Access denied / missing permission on the endorsement section | Scope not granted to your OAuth client | Confirm CREW_MEMBER_EXTERNAL_ENDORSEMENT_EDIT is in your authorize request and enabled on your client by Leon |
IS_NOT_SOME_ERROR on every crew member | Wrong endorsementNid, or you are calling the wrong operator's instance | Verify with Step 6 against that specific oprId |
IS_NOT_SOME_ERROR on some crew members | Those crew are deleted, have no application access, or their work e-mail differs between the systems | Have the operator confirm the work e-mail on each affected Leon profile and align it with yours |
ENDORSEMENT_NOT_FROM_OAUTH | Writing to a definition the operator created manually | Use only endorNid values from Step 2 / Step 6 with isFromExternalSource: true |
VALUE_NOT_VALID on dateOfExpiry | Both date and never set, or neither | Send exactly one |
IS_NO_EMPTY | fileList: [] sent | Omit the field |
| Generic GraphQL error, no violation list | The endorsementNid belongs to another OAuth application's definition | Re-check your stored IDs with Step 6 |
| Fields you did not send were wiped in Leon | Step 3 is a full overwrite, not a patch | Always send the complete record |
❌ What this integration cannot do (yet)
| You need | Status | Why | What to do |
|---|---|---|---|
| Create a definition idempotently ("create or get") | ❌ Not available | createExternalDefinition always creates a new row; there is no uniqueness check on the name | Store endorNid yourself; use Step 6 for recovery |
| Configure the definition (validity period, required flag, applicable aircraft/positions/AOC, alert windows, group) | ❌ Not available via API | The mutation accepts only name | Agree the configuration with the operator during onboarding; they set it in Settings → Crew Endorsements |
| Update or delete your own definition | ❌ Not available to external clients | Only create, update, delete on regular definitions exist, and those are not open to the external-endorsement flow | Ask the operator to change it in Leon, or raise it with Leon Support |
| Bulk-push many crew members in one call | ❌ Not available | putExternalForCrewMember handles one crew member per call | Batch client-side; keep one access token per operator |
| Detect that an operator edited or deleted a record you pushed | ❌ Not available | Individual certificate records store no source marker, and there is no change feed for endorsements | Treat your system as source of truth and re-push on a schedule |
Attach a file using fileInputList | ❌ Not usable externally | It requires internal Leon upload identifiers with no documented external route | Use fileList multipart upload (Step 4) |
| Match crew by anything other than work e-mail | ❌ Not available | The API's only crew key for this flow is CrewMemberEmail | Keep work e-mails aligned between the systems; escalate missing/duplicate addresses to the operator |
For anything on this list that blocks your use case, contact Leon Support at customer.leon.aero with a description of the operation you need.