Leon API Integration Guide: RFQ & Quote Management via Webhook
Version: 20 August 2026 API: Leon GraphQL API (Beta) Prepared by: Leon Software Support
What this guide does
Lets your system push charter Requests For Quote (RFQ) into Leon, read back the operator's priced quotes, and receive the operator's accept / reject decision — including the final price and the Flight Quotation PDF — as an HTTP callback to your own endpoint.
Before you start
Make sure you have:
- A Leon account with API access enabled — contact Leon Support if you don't have it yet
- An API Key created in Leon (this produces a
RefreshToken) — see Step 1a - The API Key granted these resources:
GRAPHQL_SALES_QUOTE_EDIT,GRAPHQL_SALES_QUOTE_REQUEST_SEE,GRAPHQL_ACFT,GRAPHQL_AIRPORT,GRAPHQL_CONTACT - The Sales module active on the operator's Leon account
- Two publicly reachable HTTPS endpoints for the accept and reject callbacks — see Step 2, they must already be live before you create your first RFQ
- The operator's
oprId(their Leon subdomain — ask any user: "What address do you use to sign in to Leon?")
⚠️ This guide uses the API Key authentication method, which is only valid for a single operator's internal use. If you are building software that will be used by more than one Leon operator (a broker platform, marketplace, or any distributed SaaS product), you must use OAuth 2.0 Code Grant instead. API Key is not an option in that case, and integrations using API Key are not visible in the Leon Addons panel. The rest of this guide is unchanged apart from Step 1 — contact Leon Support for the OAuth registration form.
API endpoints:
| Environment | URL |
|---|---|
| Sandbox (develop and test here) | https://{oprId}.sandbox.leon.aero/api/graphql/ |
| Production | https://{oprId}.leon.aero/api/graphql/ |
All steps at a glance
- Authenticate → get
access_token - Stand up your webhook endpoints → get two live HTTPS URLs
- List quotable aircraft → get aircraft
registrationvalues - Create the RFQ → get
quoteRequestNidand yourX-prefixed external ID - Read the RFQ and its quotes → get
quoteRealizationNid+ prices - Poll for new and changed quotes → get a fresh
timestampfor the next run - (Optional) Download the Flight Quotation PDF → get base64 file content
- Accept a quote → Leon POSTs the accept payload to your endpoint
- Reject or cancel a quote → Leon POSTs the reject payload to your endpoint
- Handle the incoming webhook payload → your system is updated
Step 1 — Authenticate
⚠️ This method is only for integrations built for a single operator's internal use. If you plan to distribute this integration to other Leon operators, use OAuth instead.
Step 1a — Create an API Key in Leon
The operator creates an API Key in Leon (Settings → API Keys). Full instructions: https://wiki.leonsoftware.com/leon/api-keys
Grant the key the resources listed in Before you start. This produces a RefreshToken.
Step 1b — Exchange the Refresh Token for an Access Token
curl -X POST -d 'refresh_token={RefreshToken}' https://{oprId}.leon.aero/access_token/refresh/
Fill in:
| Placeholder | Where to get it |
|---|---|
{oprId} | The operator's Leon subdomain |
{RefreshToken} | From the API Key created in Step 1a |
Response: the access token, returned as plain text.
Save this → the returned string — this is your access_token. Valid for 30 minutes.
⚠️ Token rules: reuse the access token for its full 30-minute life — do not mint a new token per request. Refresh proactively when fewer than 5 minutes remain rather than waiting for a
401.
Every request from here on uses:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Step 2 — Stand up your webhook endpoints
Leon will call these URLs later with the operator's decision. They must exist before you create your first RFQ.
Build two HTTPS endpoints, for example:
https://your-system.example.com/leon/quote-acceptedhttps://your-system.example.com/leon/quote-rejected
Each endpoint must satisfy all of the following:
| Requirement | Detail |
|---|---|
Answers GET with a status code in 200–301 | Leon performs a live GET health check on the URL at the moment you create the RFQ (5s connect + 5s total timeout). If the check fails, the whole createRequestWithWebhook mutation is rejected with Invalid url response - should return code between 200 and 301. |
Accepts POST with a JSON body | This is how the actual payload arrives |
Parses the body as JSON regardless of the Content-Type header | Leon sends the JSON string without setting Content-Type, so cURL defaults to application/x-www-form-urlencoded. Do not gate your parser on the header. |
| Treats the URL itself as the secret | Leon sends no signature, HMAC, JWT, or auth header. Use a long unguessable path or query token, and restrict by source IP if you can. |
| Responds quickly with a status in 200–301 | Any other status makes the operator's Accept/Reject action fail with a MARKETPLACE_INTEGRATION_ERROR. There is no automatic retry — the operator has to repeat the action manually. |
A valid TLS certificate is required; self-signed certificates will fail the health check.
Save this → your two URLs — you'll pass them as acceptWebhook and rejectWebhook in Step 4.
Step 3 — List quotable aircraft
The RFQ names the aircraft you want quoted by tail registration, so fetch the operator's active fleet first. Cache this — it changes rarely.
Request:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Body:
query ListQuotableAircraft {
aircraftList(onlyActive: true) {
aircraftNid
registration
acftTypeName
paxCapacity
isActive
}
}
📖 Full type reference → · Query aircraftList
Fill in: no variables.
Response (relevant fields only):
{
"data": {
"aircraftList": [
{ "aircraftNid": 123, "registration": "SP-ABC", "acftTypeName": "Citation XLS", "paxCapacity": 8, "isActive": true }
]
}
}
Save this → the registration string of each aircraft you want quoted — you'll pass it in aircraftList in Step 4.
Step 4 — Create the RFQ
Creates a quote request in the operator's Sales panel and registers your two callback URLs against it.
Request:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Body:
mutation CreateRfqWithWebhook($quoteRequest: QuoteRequestWithWebhook!) {
sales {
createRequestWithWebhook(quoteRequest: $quoteRequest) {
nid
id
createdDate
status { name }
acceptWebhook
rejectWebhook
legs {
nid
adep { icao }
ades { icao }
std
sta
paxNo
isTBA
}
aircraftList { nid tail type maxPax }
buyer { name email }
}
}
}
📖 Mutation reference → · Input type QuoteRequestWithWebhook · Return type QuoteRequest
Fill in — top level of $quoteRequest:
| Field | Required | Where to get it | Example |
|---|---|---|---|
marketplace | Yes | Enum naming the source of the request. Use Internal for your own system unless Leon told you otherwise. See MarketplaceEnum | "Internal" |
aircraftList | Yes | Array of registration strings from Step 3 | ["SP-ABC"] |
itinerary | Yes | Array of legs — see the leg table below | see JSON |
requestedBy | Yes | The client asking for the quote — see the requester table below | see JSON |
representative | No | Contact person acting for the client — displayName is required if you send this object | see JSON |
assignee | No | Leon login NID of the sales person to assign | 4571 |
message | No | Free-text note shown to the operator | "Pet on board" |
acceptWebhook | No, but required for this integration | Your accept URL from Step 2 | "https://…/quote-accepted" |
rejectWebhook | No, but required for this integration | Your reject URL from Step 2 | "https://…/quote-rejected" |
quoteRequestId | No, but strongly recommended | Your own identifier for this RFQ | "CRM-2026-00412" |
Fill in — each entry in itinerary (RfqItineraryInput):
| Field | Required | Notes | Example |
|---|---|---|---|
date | Yes | YYYY-MM-DD | "2026-09-01" |
time | No | HH:MM. Omit together with isTBA: true | "10:00" |
departureOrArrival | Yes | Whether date/time describe departure or arrival. See DepartureOrArrivalEnum | "DEPARTURE" |
adep | Yes | Departure airport code (ICAO/IATA) | "EPWA" |
ades | Yes | Arrival airport code | "EGLL" |
paxNumber | Yes | Passenger count on this leg | 4 |
isTBA | No | true when times are not fixed yet | false |
cargo | No | Cargo weight — see CargoInput | — |
Fill in — requestedBy (QuoteRequestWithWebhookRequester):
| Field | Required | Notes |
|---|---|---|
displayName | Yes | Company or person name; Leon matches or creates a client record from this |
id | No | Your own client identifier |
emails, phone, mobilePhone | No | Contact details |
postCode, city, street, countryCode | No | Address; countryCode is ISO 3166-1 alpha-2, e.g. "PL" |
Variables JSON:
{
"quoteRequest": {
"marketplace": "Internal",
"quoteRequestId": "CRM-2026-00412",
"aircraftList": ["SP-ABC"],
"acceptWebhook": "https://your-system.example.com/leon/quote-accepted",
"rejectWebhook": "https://your-system.example.com/leon/quote-rejected",
"message": "Client requests catering and ground transport quote separately.",
"requestedBy": {
"id": "CLIENT-8891",
"displayName": "Northwind Charter Ltd",
"emails": ["ops@northwind.example.com"],
"phone": "+48221234567",
"city": "Warsaw",
"countryCode": "PL"
},
"representative": {
"id": "CONTACT-551",
"displayName": "Anna Kowalska",
"firstName": "Anna",
"lastName": "Kowalska",
"emails": ["anna.kowalska@northwind.example.com"]
},
"itinerary": [
{
"date": "2026-09-01",
"time": "10:00",
"departureOrArrival": "DEPARTURE",
"adep": "EPWA",
"ades": "EGLL",
"paxNumber": 4,
"isTBA": false
},
{
"date": "2026-09-04",
"time": "16:30",
"departureOrArrival": "DEPARTURE",
"adep": "EGLL",
"ades": "EPWA",
"paxNumber": 4,
"isTBA": false
}
]
}
}
Response (relevant fields only):
{
"data": {
"sales": {
"createRequestWithWebhook": {
"nid": "77412",
"id": "X-CRM-2026-00412",
"status": { "name": "New Request" },
"acceptWebhook": "https://your-system.example.com/leon/quote-accepted",
"rejectWebhook": "https://your-system.example.com/leon/quote-rejected",
"legs": [
{ "nid": "20551", "adep": { "icao": "EPWA" }, "ades": { "icao": "EGLL" }, "std": 1788254400, "sta": 1788262200, "paxNo": 4, "isTBA": false }
],
"aircraftList": [{ "nid": 123, "tail": "SP-ABC", "type": "Citation XLS", "maxPax": 8 }],
"buyer": { "name": "Northwind Charter Ltd", "email": "ops@northwind.example.com" }
}
}
}
}
⚠️ Leon prefixes your
quoteRequestIdwithX-. You sentCRM-2026-00412; the stored identifier returned inidisX-CRM-2026-00412. Always use the full prefixed value when looking the request up later (Step 5).
Save this →
nid— thequoteRequestNid, used in Steps 5, 7 and 9id— theX-prefixed external ID, used for lookups by your own referencestd/staare UTC Unix timestamps in seconds
Step 5 — Read the RFQ and its quotes
Returns the operator's quotes (Leon calls them realizations) with prices, aircraft, and per-leg schedule. Call this after the operator has had time to quote, or after Step 6 tells you something changed.
Request:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Body:
query GetQuoteRequestWithQuotes($quoteRequestNid: QuoteRequestNid!) {
sales {
getQuoteRequest(quoteRequestNid: $quoteRequestNid) {
nid
id
status { name }
lastExternalUpdate
legs { nid adep { icao } ades { icao } std sta paxNo }
realizations {
nid
acft { aircraftNid registration acftTypeName }
price
currency
quoted
quotedDateTime
booked
isUsed
status
isOutdated
pricing {
currency
totalPrice
quoteRoundedPrice
VATRate
VATAmount
}
legs {
nid
adep { icao }
ades { icao }
stdUTC
staUTC
blockTime
isFuelStop
}
notes { customerNote cancellationPolicy legalNote }
}
}
}
}
📖 Query getQuoteRequest · Types QuoteRequest, QuoteRealization, Pricing
Fill in:
| Variable | Where to get it | Example |
|---|---|---|
$quoteRequestNid | nid from the Step 4 response | "77412" |
Variables JSON:
{ "quoteRequestNid": "77412" }
Response (relevant fields only):
{
"data": {
"sales": {
"getQuoteRequest": {
"nid": "77412",
"id": "X-CRM-2026-00412",
"status": { "name": "Quoted" },
"realizations": [
{
"nid": "31998",
"acft": { "aircraftNid": 123, "registration": "SP-ABC", "acftTypeName": "Citation XLS" },
"price": 24800.0,
"currency": "EUR",
"quoted": true,
"quotedDateTime": "2026-08-20T09:41:00+00:00",
"booked": false,
"isUsed": false,
"status": "Unanswered",
"isOutdated": false,
"pricing": { "currency": "EUR", "totalPrice": 24800.0, "quoteRoundedPrice": 24800.0, "VATRate": 0, "VATAmount": 0 },
"notes": { "customerNote": "Price includes catering.", "cancellationPolicy": "50% within 72h.", "legalNote": null }
}
]
}
}
}
}
How to read the key fields:
| Field | Meaning |
|---|---|
status.name on the request | Workflow stage: New Request, Quoted, Rejected, Option, Contract Sent, Booked, Brief Sent, Done, Invoice Sent, Owner Approval, Canceled, Opportunity, Ready to book. More values may be added — treat unknown values gracefully. |
quoted | true once the operator has priced this quote. Poll until this flips. |
status on the realization | Whether you have answered this quote: Unanswered, Accepted, Rejected. More values may be added. |
isOutdated | The itinerary or price changed after the quote was sent — re-read before acting on it |
price vs pricing.totalPrice | price is the headline figure shown in Sales; pricing is the detailed breakdown including VAT |
Alternative — look up by your own reference instead of nid:
query GetRequestByExternalId($quoteRequestIdList: [QuoteRequestId!]!) {
sales {
getQuoteRequestListByProviderFriendlyId(quoteRequestIdList: $quoteRequestIdList) {
nid
id
status { name }
realizations { nid price currency quoted status }
}
}
}
📖 Query getQuoteRequestListByProviderFriendlyId
{ "quoteRequestIdList": ["X-CRM-2026-00412"] }
⚠️ Pass the
X-prefixed value."CRM-2026-00412"will not match.
Save this → the nid of the realization you want to act on — this is the quoteRealizationNid used in Steps 7, 8 and 9.
Step 6 — Poll for new and changed quotes
Instead of re-reading every open RFQ, ask Leon what changed since your last run. This is the recommended sync strategy — no webhook infrastructure needed for it, and you catch up automatically after downtime.
Request:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Body:
query PollModifiedQuotes($dateTime: DateTime!) {
sales {
getModifiedQuotesList(dateTime: $dateTime) {
timestamp
deleted
created {
nid
price
currency
quoted
status
quoteRequest { nid id status { name } }
}
changed {
nid
price
currency
quoted
status
isOutdated
quoteRequest { nid id status { name } }
}
}
}
}
📖 Query getModifiedQuotesList · Return type QuotesChanges
Fill in:
| Variable | Where to get it | Example |
|---|---|---|
$dateTime | The timestamp saved from your previous run. On the very first run, use a recent point in time. | "2026-08-20T08:00:00+00:00" |
Variables JSON:
{ "dateTime": "2026-08-20T08:00:00+00:00" }
Response (relevant fields only):
{
"data": {
"sales": {
"getModifiedQuotesList": {
"timestamp": 1787209260,
"deleted": [31877],
"created": [],
"changed": [
{ "nid": "31998", "price": 24800.0, "currency": "EUR", "quoted": true, "status": "Unanswered", "isOutdated": false,
"quoteRequest": { "nid": "77412", "id": "X-CRM-2026-00412", "status": { "name": "Quoted" } } }
]
}
}
}
}
Loop design:
- Store
lastSyncTimestampafter every successful run - Call this query with
dateTime = lastSyncTimestamp - Process
created,changed, anddeleted(an array of realization NIDs that no longer exist) - Set
lastSyncTimestamp= the returnedtimestamp— use Leon's value, not your own clock, to avoid gaps from clock drift
⚠️ The lookback window is capped at 7 days. Poll at least once a day; a longer gap means changes are lost and you must re-read the affected requests with Step 5. Results are ordered by change time ascending. This query returns quotes with a modified price or itinerary — not every field change.
Save this → timestamp for the next run, and each nid you need to re-read in detail.
Step 7 — Download the Flight Quotation PDF (optional)
Generates the operator's Flight Quotation document for one or more quotes. Skip this if you only need the numbers — the accept webhook in Step 10 already delivers the PDF as an attachment.
Request:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Body:
query GetFlightQuotationPdf(
$quoteRequestNid: QuoteRequestNid!
$quoteRealizationNidList: [QuoteRealizationNid!]
) {
sales {
getFlightQuotationDocument(
quoteRequestNid: $quoteRequestNid
quoteRealizationNidList: $quoteRealizationNidList
) {
... on DocumentFileDataValue {
value { name fileName content }
}
... on ErrorList {
errorList { message category path }
}
}
}
}
📖 Query getFlightQuotationDocument · Types DocumentFileData, ErrorList
Fill in:
| Variable | Where to get it | Example |
|---|---|---|
$quoteRequestNid | nid from Step 4 or 5 | "77412" |
$quoteRealizationNidList | Realization nid values from Step 5. Omit to let Leon use the selected realization, falling back to the first one. | ["31998"] |
Variables JSON:
{ "quoteRequestNid": "77412", "quoteRealizationNidList": ["31998"] }
Response (relevant fields only):
{
"data": {
"sales": {
"getFlightQuotationDocument": {
"value": { "name": "Flight Quotation", "fileName": "quotation-77412.pdf", "content": "JVBERi0xLjQK…" }
}
}
}
}
This is a union type: a successful call returns a
valueobject, a failure returnserrorList. Branch on which key is present. The document template is chosen automatically if you don't specify one.
Save this → content — base64-encoded file bytes. Decode and store or forward to the client.
Step 8 — Accept a quote
Marks the quote as accepted in Leon and triggers a POST to your
acceptWebhookURL with the final price and documents.
Request:
POST https://{oprId}.leon.aero/api/graphql/
Content-Type: application/json
Authorization: Bearer <access_token from Step 1>
Body:
mutation AcceptQuote($messagesList: [ExternalServiceMessageInput!]!) {
sales {
changeRequestStatus {
setWebhookAccept(messagesList: $messagesList) {
... on NonNullQuoteRequestValue {
value {
nid
id
status { name }
usedRealization { nid price currency booked status }
}
}
... on ErrorList {
errorList { message category path }
}
}
}
}
}
📖 Mutation setWebhookAccept · Input type ExternalServiceMessageInput
Fill in — each entry in messagesList:
| Field | Required | Where to get it | Example |
|---|---|---|---|
quoteRealizationNid | Yes | Realization nid from Step 5 | "31998" |
message | Yes | Text sent along with the acceptance | "Accepted — please issue the contract." |
attachments | Yes (may be an empty array) | Documents to attach — see ExternalServiceMessageAttachmentInput: name plus one of documentTemplateNid, url, or resourceNid | [] |
Variables JSON:
{
"messagesList": [
{
"quoteRealizationNid": "31998",
"message": "Accepted — please issue the contract.",
"attachments": []
}
]
}
Response (relevant fields only):
{
"data": {
"sales": {
"changeRequestStatus": {
"setWebhookAccept": {
"value": {
"nid": "77412",
"id": "X-CRM-2026-00412",
"status": { "name": "Booked" },
"usedRealization": { "nid": "31998", "price": 24800.0, "currency": "EUR", "booked": true, "status": "Accepted" }
}
}
}
}
}
}
⚠️ This mutation only works on requests created through
createRequestWithWebhook. Calling it on a request that came from Avinode, a marketplace, or manual entry returns a provider-type mismatch error.⚠️ The webhook call is part of this mutation, not a background job. If your endpoint returns anything outside 200–301, the whole mutation comes back with an
errorListentry categorised asMARKETPLACE_INTEGRATION_ERRORand nothing is retried. Retry the mutation yourself once your endpoint is healthy.Note: when the operator clicks Accept on this RFQ inside Leon's Sales panel, Leon fires exactly the same mutation — so your endpoint receives the same payload whether the trigger was your API call or a human in the UI.
Save this → status.name and usedRealization — confirm they match what your webhook handler recorded in Step 10.
Step 9 — Reject or cancel a quote
Same shape as Step 8, but hits your
rejectWebhookURL.
Body:
mutation RejectQuote(
$messagesList: [ExternalServiceMessageInput!]!
$quoteCancellationReason: QuoteCancellationReasonInput
$shouldDeleteTrip: Boolean!
) {
sales {
changeRequestStatus {
setWebhookReject(
messagesList: $messagesList
quoteCancellationReason: $quoteCancellationReason
shouldDeleteTrip: $shouldDeleteTrip
) {
... on NonNullQuoteRequestValue {
value { nid id status { name } }
}
... on ErrorList {
errorList { message category path }
}
}
}
}
}
📖 Mutation setWebhookReject · Input type QuoteCancellationReasonInput
Fill in:
| Variable | Required | Where to get it | Example |
|---|---|---|---|
$messagesList | Yes | Same structure as Step 8 | see JSON |
$quoteCancellationReason.canceledBy | Yes if the object is sent | BUYER or SELLER — see QuoteRequestRejectCanceledByEnum | "BUYER" |
$quoteCancellationReason.cancellationReason | Yes if the object is sent | Free text | "Client changed dates." |
$shouldDeleteTrip | Yes | Whether to remove a trip already created from this quote | false |
Variables JSON:
{
"messagesList": [
{ "quoteRealizationNid": "31998", "message": "Client withdrew the request.", "attachments": [] }
],
"quoteCancellationReason": { "canceledBy": "BUYER", "cancellationReason": "Client changed dates." },
"shouldDeleteTrip": false
}
Cancel instead of reject: use the identically-shaped setWebhookCancel mutation. Use reject before the quote is confirmed, cancel after. Both send the same payload to your rejectWebhook URL — only Leon's internal status differs (Rejected vs Canceled), so read rejected_data if you need to tell them apart, or key off the mutation you called.
Save this → status.name — confirm the request moved to Rejected or Canceled.
Step 10 — Handle the incoming webhook payload
What your endpoints from Step 2 actually receive.
Transport: POST, JSON body, no Content-Type: application/json header (Leon does not set one, so cURL defaults to application/x-www-form-urlencoded), no authentication header of any kind. Parse the raw body as JSON.
Payload delivered to acceptWebhook
{
"quote": {
"acft": {
"aircraftRegistation": "SP-ABC",
"aircraftNid": 123,
"registrationWithoutSpecialChars": "SPABC"
},
"lift": null,
"opr_id": "northwind",
"opr_nid": 42,
"provider_friendly_id": "X-CRM-2026-00412",
"quote_request_id": "CRM-2026-00412",
"quote_realization_nid": 31998,
"currency_code": "EUR",
"total_price": 24800.00,
"segments": [
{
"start_airport": { "icao": "EPWA", "iata": "WAW" },
"end_airport": { "icao": "EGLL", "iata": "LHR" },
"date_time": { "date": "2026-09-01", "time": "10:00", "departure": true, "local": false },
"block_time_minutes": 120,
"pax_count": 4,
"pax_segment": true,
"show_to_buyer": true
}
],
"attachments": [
{ "mimeType": "application/pdf", "data": "JVBERi0xLjQK…", "name": "quote.pdf", "type": "Flight Quotation" }
]
},
"message": "Accepted — please issue the contract."
}
⚠️
aircraftRegistationis spelled that way in Leon's payload — a typo in the source, not in this guide. Match on the literal key.Field naming is mixed
camelCaseandsnake_case; do not normalise assumptions across keys.
Payload delivered to rejectWebhook
Same envelope, but without segments, currency_code, total_price, and attachments, and with rejected_data added:
{
"quote": {
"acft": { "aircraftRegistation": "SP-ABC", "aircraftNid": 123, "registrationWithoutSpecialChars": "SPABC" },
"opr_id": "northwind",
"opr_nid": 42,
"provider_friendly_id": "X-CRM-2026-00412",
"quote_request_id": "CRM-2026-00412",
"quote_realization_nid": 31998,
"rejected_data": { "cancelledBy": "seller", "cancellationReason": "Aircraft unavailable." }
},
"message": "Aircraft went AOG."
}
How to match it to your own records:
| Payload field | Use it for |
|---|---|
provider_friendly_id | Your X- prefixed external ID — the primary key to look up your RFQ |
quote_realization_nid | The specific quote, matches nid from Step 5 |
opr_id | Which operator sent it — essential if you connect more than one |
Handler requirements:
- Respond with 200–301 as fast as possible. A slow or failing response breaks the operator's action in Leon, and there is no retry.
- Acknowledge first, process asynchronously. Leon sets no send timeout, so a hanging endpoint holds the operator's request open.
- Make handling idempotent — the operator may repeat a failed action, producing a duplicate delivery for the same
quote_realization_nid. - Treat the callback as unauthenticated input: validate
opr_idagainst operators you actually integrate with, and never trusttotal_pricewithout cross-checking via Step 5.
Save this → nothing further — the workflow is complete. Reconcile against Step 5 if you want a second confirmation of final state.
If something goes wrong
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized | Access token expired (30-minute life) or missing | Re-run Step 1b and use the new access_token |
Invalid url response - should return code between 200 and 301. on Step 4 | Your webhook URL didn't answer a GET health check within 5s, or answered with a code outside 200–301 | Make both URLs answer GET with 200. Check TLS certificate validity, firewall rules, and that the path exists for GET, not just POST |
errorList with category MARKETPLACE_INTEGRATION_ERROR on Step 8 or 9 | Your webhook endpoint returned a code outside 200–301, or was unreachable | Fix the endpoint, then re-run the mutation. Nothing is retried automatically |
| Provider type mismatch error on Step 8 or 9 | The quote request wasn't created via createRequestWithWebhook | Only use the setWebhook* mutations on requests you created through Step 4 |
| Lookup by external ID returns nothing | You passed the unprefixed ID | Prefix with X-, e.g. X-CRM-2026-00412 |
400 Bad Request on Step 4 | Missing required field or wrong type in $quoteRequest | Check the Fill-in tables — marketplace, aircraftList, itinerary, and requestedBy.displayName are all mandatory, as are date, departureOrArrival, adep, ades, paxNumber on every leg |
Empty realizations array in Step 5 | The operator hasn't created any quotes yet | Keep polling with Step 6; the request sits at New Request until the operator quotes it |
| Step 6 misses changes | Gap longer than the 7-day lookback window, or you reused your own clock instead of Leon's timestamp | Poll at least daily and always carry forward the timestamp Leon returns |
| Webhook body arrives empty in your framework | Your framework parsed the body as form data because of the missing Content-Type header | Read the raw request body and JSON.parse it explicitly |
❌ What this integration cannot do (yet)
| You asked for | Status | Why | What to do |
|---|---|---|---|
| Delivery guarantee on webhook callbacks | ❌ Not available | Leon sends a single POST with no retry queue and no send timeout; a failed delivery surfaces as a GraphQL error to whoever triggered it | Treat Step 6 polling as your source of truth and use the webhook as a low-latency hint, not the only channel |
| Signed / authenticated webhook payloads | ❌ Not available | No HMAC, JWT, or shared-secret header is sent — the URL itself is the only secret | Use an unguessable URL path, IP allow-listing if possible, and verify every payload against Step 5 before acting on money |
| Push notification when a quote is priced (as opposed to accepted/rejected) | ❌ Not available | The accept and reject webhooks only fire on the final decision mutations | Use Step 6 (getModifiedQuotesList) polling to detect newly priced quotes |
| Creating the priced quote itself from outside Leon | ❌ Not available via this flow | Pricing is the operator's action in the Sales panel; createRequestWithWebhook creates only the request | If you need to write quotes and prices programmatically, raise it with Leon Support — a different set of mutations governs operator-side quote creation and is outside the scope of this webhook pattern |
Validation note
Every query and mutation in this guide was validated against the live Leon GraphQL schema (schema-beta) before publication. Webhook payload structures, the URL health check behaviour, and the X- prefix rule were confirmed against the Leon backend source.