Everything a virtual airline's bot or website needs is here. An owner or admin sets it up on the airline's Edit page, under Connect your own tools. There are three ways in and they share one data shape, so you can mix them.
| What it is | Needs a server | Best for | |
|---|---|---|---|
| Discord messages | Takeoff and completed flight posts in your Discord channel, with labelled fields | No | A bot that already watches a channel |
| Signed webhooks | We post JSON to your address the moment something happens | Yes (a public https address) | Reliable, instant, with retries |
| Airline API | Your bot or app asks us, and can write back, with a key | No | Catching up after downtime, jobs, plans, saving your app's data on aircraft and pilots |
What counts as a flight
A flight is tracked by the Hangar Telemetry app. It departs at takeoff, arrives when the aircraft touches down, and is completed when it has landed and the engines are shut down. A flight that never leaves the ground, covers under 2 nm or is airborne under 90 seconds is dropped quietly. If the app stops sending (the sim closes), the flight is closed 10 minutes later, and counts as completed only if it had already landed.
Departure and arrival airports are the nearest airport in our list within 6 nm. A strip that is not in the list gives a blank code, with the flight still reported.
Signed webhooks
Add a webhook, pick the events, and copy the signing secret (shown once). Every post is an HTTPS POST with a JSON body and these headers:
| Header | Meaning |
|---|---|
X-Hangar-Event | The event name, like flight.completed |
X-Hangar-Delivery | A unique id for this delivery |
X-Hangar-Timestamp | Unix seconds when it was signed |
X-Hangar-Signature | v1= and the hex HMAC-SHA256 of timestamp + "." + body, with your secret |
Answer with any 2xx status within 10 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up (you can send it again from the delivery log). After 15 failed posts in a row the webhook is switched off so a dead address costs nothing, and the Edit page says why. Your address must be public https: addresses inside private networks are refused.
Check the signature (Node)
import crypto from "node:crypto";
export function verify(secret, headers, rawBody) {
const timestamp = headers["x-hangar-timestamp"];
const signature = headers["x-hangar-signature"] ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false;
}
const expected = "v1=" + crypto.createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Check the signature (Python)
import hmac, hashlib, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
timestamp = headers["X-Hangar-Timestamp"]
signature = headers.get("X-Hangar-Signature", "")
if abs(time.time() - int(timestamp)) > 300:
return False
expected = "v1=" + hmac.new(secret.encode(), (timestamp + ".").encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
Always verify against the raw body, before any JSON parsing.
Events
| Event | When |
|---|---|
flight.departed | A pilot took off |
flight.arrived | A pilot landed (a second one follows a touch and go) |
flight.completed | The flight finished: landed and shut down |
job.posted | A job was added to the board |
job.claimed | A pilot claimed a job |
job.started | A pilot took off on a claimed job |
job.completed | A flight completed a job |
job.released | A job went back on the board, expired or was cancelled |
ping | The test from the Edit page |
Every body has the same envelope: id, type, sentAt, airline and data.
A completed flight
{
"id": "evt_9f3c1d0a6b7e4c0d8f5a2b1c3d4e5f60",
"type": "flight.completed",
"sentAt": "2026-10-29T21:14:03+00:00",
"airline": { "id": "…", "name": "North Coast Transport", "slug": "north-coast-transport", "url": "https://hangaraddons.com/virtual-airlines/north-coast-transport-fae7fc5a" },
"data": {
"id": "6b1f0a52-6a63-4d3e-9d1c-0c2c4b7d9a10",
"url": "https://hangaraddons.com/virtual-airlines/north-coast-transport-fae7fc5a/tracking?flight=6b1f0a52-6a63-4d3e-9d1c-0c2c4b7d9a10",
"status": "completed",
"airline": { "id": "…", "name": "North Coast Transport", "slug": "north-coast-transport", "url": "…" },
"pilot": { "id": "…", "name": "Av8trFord", "discordUserId": "123456789012345678", "profileUrl": "https://hangaraddons.com/members/av8trford-1a2b3c4d" },
"aircraft": { "title": "Wilga 2000", "registration": "N773" },
"departure": { "icao": "PAMX", "name": "Mc Carthy Airport", "atUtc": "2026-10-29T20:31:00+00:00" },
"arrival": { "icao": "PANC", "name": "Ted Stevens Anchorage International Airport", "atUtc": "2026-10-29T21:09:00+00:00" },
"startedAtUtc": "2026-10-29T20:24:10+00:00",
"completedAtUtc": "2026-10-29T21:14:00+00:00",
"distanceNm": 187.4,
"durationMinutes": 38,
"maxAltitudeFt": 8500,
"maxGroundSpeedKt": 124,
"landingRateFpm": 142,
"landingRateExact": true,
"fuelStartLb": 210,
"fuelEndLb": 96,
"fuelBurntLb": 114,
"payloadLb": 480
}
}
Fuel, payload and an exact landing rate need Telemetry app 1.5.0 or newer; older apps leave those as null and landingRateExact is false with an estimate from the last airborne reading. pilot.discordUserId is there only if the pilot linked their Discord account on their profile.
Discord messages
Turn on A pilot takes off and A pilot completes a flight under Discord notifications. Each message is an embed with one labelled field per fact (Pilot, Discord, Aircraft, Tail, From, To, Distance, Time, Landing, Fuel burnt, Payload) and a footer reading flight:<id> departed or flight:<id> completed. A bot that reads the embed can fetch everything else with the bot API below. Your bot needs the Message Content intent to read embeds.
Airline API
Make a key under Bot keys and tick what it may do. Every key can read. The other permissions are:
| Permission | Lets the key |
|---|---|
| Work the job board | Post, claim, release and cancel jobs |
| Plan and cancel scheduled flights | Add flights to the airline's schedule and cancel them |
| Add tracked flights | Add a flight another app tracked to the airline's history |
| Save aircraft and pilot data | Write and delete its own documents on aircraft and pilots |
Send the key on every request, as Authorization: Bearer hak_… or as X-Api-Key:
Authorization: Bearer hak_…
A key belongs to one airline and sees only that airline, so requests never name it. A request that needs a permission the key lacks gets 403. Each key can make 720 requests a minute; past that you get 429 with Retry-After. Errors are JSON: { "status": 403, "error": "…" }.
| Request | Does |
|---|---|
GET /api/bot/v1/airline | The airline and what your key may do |
GET /api/bot/v1/pilots | Members, with their linked discordUserId |
GET /api/bot/v1/flights | Flights, newest first. status is completed (default), abandoned or inprogress; pilot, discordUserId, limit (up to 100) filter |
GET /api/bot/v1/flights/{id} | One flight |
GET /api/bot/v1/airports | The airline's airports: position, the aircraft types allowed there and the note for pilots |
GET /api/bot/v1/fleet | Aircraft types (seats, cargo capacity) and aircraft, each with its home base and where it last landed |
GET /api/bot/v1/routes | The approved route list, with distance when known |
GET /api/bot/v1/distance?from=PAMX&to=N773 | Straight line distance in nautical miles between two identifiers, custom strips included |
GET /api/bot/v1/jobs | Jobs (status 0 to 5, default the active ones) |
POST /api/bot/v1/jobs | Post a job (jobs permission) |
POST /api/bot/v1/jobs/{id}/claim | Claim for a pilot, with { "discordUserId": "…" } or { "pilotUserId": "…" } |
POST /api/bot/v1/jobs/{id}/release | Put a claimed job back |
DELETE /api/bot/v1/jobs/{id} | Cancel a job |
Catching up after downtime
Walk forward from the last flight you handled. Pass its completedAtUtc time (or the next from the previous call) as since; you get the oldest flights after it first, plus next to pass on the following call:
GET /api/bot/v1/flights?since=2026-10-29T21:14:00Z&limit=50
{ "items": [ … ], "next": "2026-10-29T22:02:11+00:00" }
Saving your app's data on aircraft and pilots
An app can keep one JSON document per aircraft, and one per pilot, in the airline's own records. Wear and tear, hours, maintenance, a pilot's saved controls: the airline owns it, every app the airline uses can read it, and nothing is lost if your own server is.
A document belongs to a namespace, 2 to 32 lowercase letters, numbers or hyphens, normally your app's name (ga-realistic). Apps never overwrite each other, because each writes its own namespace, but any key can read any namespace. An aircraft is named by its id or its registration (N208HA), a pilot by their user id or the Discord id they linked, and has to be a member.
| Request | Does |
|---|---|
GET /api/bot/v1/aircraft/state?namespace=ga-realistic | Your document for every aircraft in the fleet, in one call |
GET /api/bot/v1/aircraft/{aircraft}/state | Every document on one aircraft |
GET /api/bot/v1/aircraft/{aircraft}/state/{namespace} | One document, with its ETag |
PUT /api/bot/v1/aircraft/{aircraft}/state/{namespace} | Save the document (app data permission). The body is the JSON object itself |
DELETE /api/bot/v1/aircraft/{aircraft}/state/{namespace} | Remove it |
GET /api/bot/v1/pilots/state?namespace=ga-realistic-controls | Your document for every pilot who has one |
GET /api/bot/v1/pilots/{pilot}/state | Every document on one pilot |
GET /api/bot/v1/pilots/{pilot}/state/{namespace} | One document, with its ETag |
PUT /api/bot/v1/pilots/{pilot}/state/{namespace} | Save the document (app data permission) |
DELETE /api/bot/v1/pilots/{pilot}/state/{namespace} | Remove it |
PUT /api/bot/v1/aircraft/N208HA/state/ga-realistic
Authorization: Bearer hak_…
If-Match: "3"
{ "wearPercent": 12.5, "engineHours": 340.2, "lastInspection": "2026-10-30" }
200 OK
ETag: "4"
{ "namespace": "ga-realistic", "version": 4, "updatedAtUtc": "…", "updatedBy": "GA Realistic", "state": { "wearPercent": 12.5, … } }
Versions. Each save raises version by one. Send If-Match with the version you last read (0 for a document that does not exist yet) and, if someone saved in between, nothing is written and you get 409 with currentVersion: read again, merge, and retry. Leave If-Match out to simply overwrite.
Limits. A document is a JSON object up to 64 KB and 10 levels deep. An aircraft holds 10 documents and so does a pilot. Documents are stored as sent and the site does not read them, so the shape is yours to choose and change.
Adding a flight your app tracked
POST /api/bot/v1/flights (flight import permission) adds a finished flight to the airline's history as a flight of one of its members. It shows in the airline's flights and stats, completes a matching job or scheduled flight, and raises the same Discord posts and signed webhooks as a flight tracked by the Telemetry app. Imported flights carry no rating, because that needs data only the Telemetry app records.
{
"externalId": "gar-2026-11-06-0042",
"discordUserId": "123456789012345678",
"aircraftTitle": "Cessna 208B Grand Caravan",
"registration": "N208HA",
"startedAtUtc": "2026-11-06T14:02:00Z",
"endedAtUtc": "2026-11-06T15:31:00Z",
"departureIcao": "KSEA",
"arrivalIcao": "KPDX",
"distanceNm": 128.4,
"landingRateFpm": 140,
"fuelStartLb": 1100,
"fuelEndLb": 720,
"payloadLb": 1800,
"maxAltitudeFt": 9000,
"maxGroundSpeedKt": 160,
"track": [ { "latitude": 47.45, "longitude": -122.31, "altitudeFt": 100 } ]
}
- Name the pilot with
pilotUserIdordiscordUserId. The pilot,externalIdand the two times are required; the rest is optional. externalIdis your own id for the flight, up to 100 characters. Sending it again returns the first flight with"duplicate": true(status200) and adds nothing, so retries are safe. A new flight returns201withflightId.- A flight has to last between 2 minutes and 24 hours. A track is optional, up to 3000 points.
Scheduled flights
| Request | Does |
|---|---|
GET /api/bot/v1/scheduled-flights?scope=active | What is planned or under way. scope=past gives finished and cancelled ones; limit up to 200 |
POST /api/bot/v1/scheduled-flights | Plan a flight (schedule permission) |
DELETE /api/bot/v1/scheduled-flights/{id} | Cancel one that has not started (409 if it is under way or finished) |
{
"flightNumber": "HA104",
"departureIcao": "KSEA",
"arrivalIcao": "KPDX",
"aircraftTitle": "Cessna 208B Grand Caravan",
"registration": "N208HA",
"scheduledForUtc": "2026-11-07T18:00:00Z",
"notes": "Mail run",
"discordUserId": "123456789012345678"
}
Name a pilot with pilotUserId or discordUserId, or send "open": true to leave it for any member to fly. jobId ties it to a job. The Telemetry app on the pilot's machine offers it, and it is matched to the tracked flight when they take off (from 12 hours before to 48 hours after the planned time, on the tail number). An airline holds up to 500 active scheduled flights.
The job board
The board lives on the airline's Jobs tab. An admin turns it on, posts jobs by hand or lets it fill itself. Automatic jobs are made between the airline's airports and along its approved routes, and each job is given a fleet aircraft whose type the destination allows (more under Airports and custom strips). Pay, loads, distances and how long a job stays up are settings on the board. A pilot claims a job; when they take off from its departure airport it goes in flight (job.started), and the flight they finish from that airport to its destination completes it (job.completed), in the right aircraft if the job names a tail number. A flight that ends without finishing puts it back to claimed. A claim nobody flies goes back on the board after the hours the airline set.
Job status is a number: 0 open, 1 claimed, 2 in flight, 3 completed, 4 expired, 5 cancelled. A job completes on a completed flight, so the pilot must shut down at the destination.
With Show the board in Discord on, one message in your channel is kept up to date as jobs are claimed, flown and finished. It needs the airline's Discord webhook, and it cannot have buttons: members claim on the site, or your own bot can claim for them through the API.
Airports and custom strips
The Airports button on the Jobs tab is where an airline lists the airports and strips it flies. Each has an identifier, an optional note for pilots (shown on the job) and the aircraft types that may be assigned to jobs ending there; leave the types empty to allow any. Pilots flying their own aircraft are never held to it, it only decides which fleet aircraft a job is given. Lists can be exported and imported as CSV.
A strip that is not in our airport database, like N773, is added with its own latitude and longitude. From then on a flight that starts or ends within 6 nm of it carries N773 as its departure or arrival (in the webhook, the Discord message, the bot API and for job matching), instead of a blank or the nearest real airport. A flight that ends away from any listed airport has no arrival code, but arrival.latitude and arrival.longitude still say where the aircraft came to rest, and the Discord message shows it as Landed at. In the Airports list the airline sees these landings grouped by place and can add each as a strip in one click; adding or changing airports also fills in the departure and arrival of the last 90 days of flights that had none.
Mark an airport as a hub and the automatic jobs run out from a hub and back to it instead of between outlying strips (several hubs are fine; the board setting Keep jobs on your hubs turns this off). GET /api/bot/v1/airports returns isHub for each. Clear board on the Jobs tab cancels every open job at once, and optionally the claimed ones, so the board can be filled again from new settings.
Limits and tips
- 5 webhooks and 10 keys per airline, 720 requests a minute per key.
- Events are delivered at least once, so make your handler safe to run twice (the delivery id and the flight id are stable).
- A flight is matched to an airport within 6 nm. Airports that are neither in our database nor on the airline's own list come through blank, so add custom strips under Airports.
- The payload is versioned by the
v1in the signature and the API path; new fields may be added, so ignore ones you do not know.