Skip to main content

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_EDIT enabled on your client
  • From each operator connecting to you: their Leon oprId (the first part of the address they sign in to, e.g. demo in demo.leon.aero)
  • Persistent storage on your side for one endorsementNid per 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

  1. Authenticate (OAuth 2.0 Code Grant) → get access_token
  2. Create the endorsement definition — once per operator, per certificate type → get and store endorNid
  3. Push a certificate to a crew member (create-or-update) → confirmation
  4. (Optional) Push a certificate with a file attached (multipart upload)
  5. Remove a certificate from a crew member
  6. (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:

PlaceholderWhere 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:

ScopeNeeded forRequired?
CREW_MEMBER_EXTERNAL_ENDORSEMENT_EDITSteps 2, 4, 5, 6 — creating definitions and writing certificatesYes
ENDORSEMENT_DEFINITION_SEEStep 6 — reading back your endorsement definitionsRecommended

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 the Retry-After header)
  • Refresh proactively when expiry − now < 5 minutes rather than waiting for a 401
  • A 401 on 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 endorNid you 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 endorNid values — 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:

VariableWhere to get itExample
$nameThe 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:

VariableWhere to get itExample
$endorsementNidThe endorNid you stored in Step 2 for this operator and certificate type12345
$emailCrew member's work e-mail, exactly as held in Leon"john.doe@operator.com"
$endorsementDataThe certificate record from your system — see the field table belowsee below

$endorsementData fields:

FieldRequiredFormatNotes
dateOfExpiryYes{ "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.
numberNoStringCertificate / licence number
dateOfIssueNo"YYYY-MM-DD" (ISO 8601)
initialIssueDateNo"YYYY-MM-DD" (ISO 8601)First-ever issue date, if you track revalidations
noteNoStringVisible to admins
crewNoteNoStringVisible to the crew member
fileListNoFile uploadSee Step 4. Omit the field entirely if you have no files — sending an empty array [] returns IS_NO_EMPTY.
fileInputListNoDo 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:

categoryMeaningWhat to do
IS_NOT_SOME_ERRORThe e-mail matched no active crew member, or the endorsementNid does not exist for this operatorCheck the address against the crew member's Leon profile; re-run Step 6 to confirm the definition still exists
ENDORSEMENT_NOT_FROM_OAUTHThe endorsementNid points to a definition the operator created manually in Leon, not one created via Step 2You can only write into definitions your own application created
VALUE_NOT_VALIDdateOfExpiry has both date and never, or neitherSend exactly one
IS_NO_EMPTYfileList or fileInputList was sent as an empty arrayOmit the field instead

⚠️ One error is not returned in errorList: if the endorsementNid belongs 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 stored endorNid is 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:

PartWhat it is
operationsThe GraphQL request. Every file position in fileList must be null here — the actual bytes come from the file parts.
mapMaps 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
}
}
}
}
}

📖 Full type reference →

Fill in:

VariableWhere to get itExample
$endorsementNidThe endorNid stored in Step 212345
$emailCrew 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 endorNid is 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. oauthClientName is 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.


There is no change-feed for endorsements, so this integration is driven entirely from your side:

  1. 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.
  2. 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.
  3. 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.
  4. 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

BehaviourConsequence
Certificates you push behave exactly like manually created onesThey 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" markerThe 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 definitionDeleting 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 operatorThe 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 valueIf you read data back through other Leon reports, expect a date rather than a null.

If something goes wrong

SymptomLikely causeFix
401 UnauthorizedAccess token expired or missingRefresh via Step 1d and retry with the new token
401 on the refresh call itselfRefresh token expired (30 days unused)Operator admin must repeat the authorization flow in Step 1b
429 Too Many RequestsMore than 500 active access tokens on one refresh token — usually caused by minting a token per requestCache one access token per operator for its full 30 minutes; honour Retry-After
Access denied / missing permission on the endorsement sectionScope not granted to your OAuth clientConfirm CREW_MEMBER_EXTERNAL_ENDORSEMENT_EDIT is in your authorize request and enabled on your client by Leon
IS_NOT_SOME_ERROR on every crew memberWrong endorsementNid, or you are calling the wrong operator's instanceVerify with Step 6 against that specific oprId
IS_NOT_SOME_ERROR on some crew membersThose crew are deleted, have no application access, or their work e-mail differs between the systemsHave the operator confirm the work e-mail on each affected Leon profile and align it with yours
ENDORSEMENT_NOT_FROM_OAUTHWriting to a definition the operator created manuallyUse only endorNid values from Step 2 / Step 6 with isFromExternalSource: true
VALUE_NOT_VALID on dateOfExpiryBoth date and never set, or neitherSend exactly one
IS_NO_EMPTYfileList: [] sentOmit the field
Generic GraphQL error, no violation listThe endorsementNid belongs to another OAuth application's definitionRe-check your stored IDs with Step 6
Fields you did not send were wiped in LeonStep 3 is a full overwrite, not a patchAlways send the complete record

❌ What this integration cannot do (yet)

You needStatusWhyWhat to do
Create a definition idempotently ("create or get")❌ Not availablecreateExternalDefinition always creates a new row; there is no uniqueness check on the nameStore endorNid yourself; use Step 6 for recovery
Configure the definition (validity period, required flag, applicable aircraft/positions/AOC, alert windows, group)❌ Not available via APIThe mutation accepts only nameAgree the configuration with the operator during onboarding; they set it in Settings → Crew Endorsements
Update or delete your own definition❌ Not available to external clientsOnly create, update, delete on regular definitions exist, and those are not open to the external-endorsement flowAsk the operator to change it in Leon, or raise it with Leon Support
Bulk-push many crew members in one call❌ Not availableputExternalForCrewMember handles one crew member per callBatch client-side; keep one access token per operator
Detect that an operator edited or deleted a record you pushed❌ Not availableIndividual certificate records store no source marker, and there is no change feed for endorsementsTreat your system as source of truth and re-push on a schedule
Attach a file using fileInputList❌ Not usable externallyIt requires internal Leon upload identifiers with no documented external routeUse fileList multipart upload (Step 4)
Match crew by anything other than work e-mail❌ Not availableThe API's only crew key for this flow is CrewMemberEmailKeep 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.