ACARS V2 API — Device Code Authentication
The V2 ACARS API introduces a device code authentication flow, replacing the traditional username/password login. This allows ACARS desktop clients to authenticate securely without handling user credentials.
Authentication Flow
sequenceDiagram
participant Client as ACARS Client
participant API as Airspace API
participant Pilot as Pilot (Browser)
Client->>API: POST /api/v2/acars/auth/request
API-->>Client: { user_code, authorization_token, expires_in, poll_interval }
Client->>Pilot: Display "Enter code at /acars/authorize"
loop Every poll_interval seconds
Client->>API: POST /api/v2/acars/auth/token
API-->>Client: 202 Pending
end
Pilot->>API: Enters code on /acars/authorize
Note over API: Code approved
Client->>API: POST /api/v2/acars/auth/token
API-->>Client: 200 { access_token, token_type }
Client->>API: GET /api/v2/acars/va (Bearer token)
API-->>Client: VA data
Step 1: Request a Device Code
POST /api/v2/acars/auth/request
No authentication required. No request body.
Response (201 Created):
{
"user_code": "482910",
"authorization_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"expires_in": 300,
"poll_interval": 5
}
| Field | Type | Description |
|---|---|---|
user_code | string | 6-digit code to display to the pilot |
authorization_token | string | 64-character hex token (keep secret, used for polling) |
expires_in | integer | Seconds until the code expires (300 = 5 minutes) |
poll_interval | integer | Minimum seconds between poll requests |
Step 2: User Approves the Code
The pilot navigates to /acars/authorize in their browser (must be logged in to the VA), enters the 6-digit code, and clicks "Authorize Device".
The code can also be pre-filled via query parameter: /acars/authorize?code=482910.
Step 3: Poll for Access Token
POST /api/v2/acars/auth/token
Content-Type: application/json
{
"authorization_token": "a1b2c3d4e5f6..."
}
| Parameter | Type | Required | Description |
|---|---|---|---|
authorization_token | string | Yes | The 64-character token from step 1 |
Response Codes:
200 OK — Approved
{
"access_token": "1|abc123def456...",
"token_type": "Bearer"
}
The device has been approved. Use the access_token as a Bearer token for all authenticated endpoints.
202 Accepted — Pending
{
"status": "pending"
}
The user hasn't approved the code yet. Continue polling every poll_interval seconds.
410 Gone — Expired
{
"status": "expired"
}
The code has expired or doesn't exist. Start a new device code flow from step 1.
429 Too Many Requests — Slow Down
{
"status": "slow_down"
}
You're polling too frequently. Wait at least poll_interval seconds between requests.
Authenticated Endpoints
Once you have an access_token, include it in the Authorization header:
Authorization: Bearer 1|abc123def456...
All V2 endpoints below require this header unless marked as public.
Pilot Profile & History
GET /api/v2/acars/pilot
Returns the authenticated pilot's profile information and 6-month flight history in a single call. This is the recommended endpoint for building pilot dashboards.
Response (200):
{
"profile": {
"id": 42,
"name": "John Doe",
"profile_picture_url": "https://...",
"member_since": "2025-01-15T00:00:00+00:00",
"rank": { "name": "Captain", "level": 5, "color": "#FFD700", "icon": "captain.svg" },
"current_airport": { "icao": "KJFK", "iata": "JFK", "name": "John F. Kennedy Intl", "city": "New York" },
"base_airport": { "icao": "KLAX", "iata": "LAX", "name": "Los Angeles Intl", "city": "Los Angeles" },
"vatsim_id": "1234567",
"ivao_id": null,
"totals": {
"flights": 150,
"flight_hours": 320.5,
"points": 4500,
"avg_landing_rate": -185.3,
"last_flight_at": "2026-03-01T14:30:00+00:00"
}
},
"history": {
"months": ["2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03"],
"flight_hours": [12.5, 8.3, 15.0, 6.2, 10.1, 3.5],
"flights": [5, 3, 7, 2, 4, 1],
"points": [500, 300, 700, 200, 400, 100],
"avg_landing_rate": [-180, -200, -150, -220, -170, -190]
}
}
| Field | Type | Description |
|---|---|---|
profile.rank | object | null | Pilot's current rank (null if unranked) |
profile.current_airport | object | null | Airport where the pilot is currently located |
profile.base_airport | object | null | Pilot's assigned home base |
profile.totals | object | Lifetime statistics |
history | object | 6-month monthly breakdown (same format as /history) |
The response also carries the pilot's id and lifetime aggregates at the top level, which the desktop client's My Flights tab shows next to the paginated logbook (see Pilot Flights). They cover accepted flights only, like profile.totals:
{
"id": 42,
"name": "John Doe",
"flights_count": 150,
"flight_hours": 320.5,
"landing_rate_avg": -185.3,
"total_distance": 45210,
"points": 4500,
"rank": "Captain",
"profile": { "..." },
"history": { "..." }
}
| Field | Type | Description |
|---|---|---|
id | integer | Pilot's user id |
flights_count | integer | Accepted flights |
flight_hours | float | Accepted flight hours |
landing_rate_avg | float | null | Average landing rate (fpm) over accepted flights |
total_distance | integer | Nautical miles flown on accepted flights |
points | integer | Lifetime points |
rank | string | null | Current rank name |
Also available at the V1 path: GET /api/acars/pilot
VA Information
GET /api/v2/acars/va
Returns the virtual airline's name, logos, primary color, and domains.
Response (200):
{
"name": "Example Airways",
"logo_url": "https://...",
"banner_url": "https://...",
"primary_color": "#1a73e8",
"domains": ["example.airspace.app"]
}
Active Booking
GET /api/v2/acars/booking
Returns the authenticated pilot's active booking: airline, aircraft, airports and stands, the pilot's estimated off-block time (EOBT), and summaries of the generated payload and the SimBrief OFP. The full payload manifest and the complete OFP have endpoints of their own: Booking Payload and Booking OFP.
Response (200):
{
"id": 42,
"flight_number": "EX101",
"callsign": "EXA101",
"type": "regular",
"is_charter": false,
"is_alternating": false,
"alternate_reason": null,
"expires_at": "2026-02-24T15:30:00+00:00",
"eobt": "2026-02-24T14:35:00+00:00",
"eobt_locked": false,
"custom_route": "DCT MERIT J60 PSB",
"airline": { "name": "Example Airways", "icao": "EXA", "iata": "EX" },
"aircraft": { "registration": "N12345", "name": "Spirit of Adventure", "type": "B738", "subfleet": "738", "fleet": "Boeing 737" },
"departure_airport": { "icao": "KJFK", "iata": "JFK", "name": "John F. Kennedy Intl", "city": "New York", "latitude": 40.6398, "longitude": -73.7789, "elevation": 13, "stand": { "stand": "B22", "apron": "Terminal 4" } },
"arrival_airport": { "icao": "KLAX", "iata": "LAX", "name": "Los Angeles Intl", "city": "Los Angeles", "latitude": 33.9425, "longitude": -118.4081, "elevation": 128, "stand": null },
"alternate_airport": null,
"payload": {
"weight_unit": "kg",
"total_pax": 162,
"total_pax_weight": 13608.0,
"total_baggage_weight": 2916.5,
"total_cargo_weight": 1850.0,
"total_payload_weight": 18374.5,
"cabin_classes": [
{ "cabin_class": "J", "pax": 12 },
{ "cabin_class": "Y", "pax": 150 }
],
"holds": [
{ "name": "FWD", "baggage_weight": 1402.5, "cargo_weight": 1100.0, "total_weight": 2502.5 },
{ "name": "AFT", "baggage_weight": 1338.0, "cargo_weight": 750.0, "total_weight": 2088.0 }
],
"reduced_for_landing_weight": false
},
"ofp": {
"id": "1771943400_A1B2C3D4E5",
"generated_at": "2026-02-24T13:10:00+00:00",
"airac": "2602",
"weight_unit": "kg",
"callsign": "EXA101",
"route": "DCT MERIT J60 PSB ... DCT",
"cruise_altitude": 37000,
"cost_index": "30",
"distance": 2215,
"sid": "KENNEDY5",
"star": "ANJLL4",
"departure_runway": "31L",
"arrival_runway": "24R",
"alternates": ["KONT"],
"times": {
"scheduled_out": "2026-02-24T14:35:00+00:00",
"scheduled_off": "2026-02-24T14:55:00+00:00",
"scheduled_on": "2026-02-24T20:40:00+00:00",
"scheduled_in": "2026-02-24T20:48:00+00:00",
"enroute_minutes": 345,
"block_minutes": 373
},
"fuel": { "block": 18400, "taxi": 300, "trip": 14400, "contingency": 720, "alternate": 1240, "reserve": 1150, "extra": 590, "min_takeoff": 17510, "planned_takeoff": 18100, "planned_landing": 3700 },
"weights": { "oew": 41413, "pax_count": 162, "cargo": 4766, "payload": 18374, "zfw": 59787, "max_zfw": 62732, "tow": 77887, "max_tow": 79015, "ldw": 63487, "max_ldw": 66360 },
"pdf_url": "https://www.simbrief.com/ofp/flightplans/EXAKJFK_PDF_1771943400.pdf"
}
}
| Field | Type | Description |
|---|---|---|
type | string | regular, ferry, charter or cargo |
alternate_reason | string | null | Why operations sent the flight to its alternate, when is_alternating |
eobt | string | null | Estimated off-block time (the pilot's target pushback), ISO-8601 UTC. Set on the briefing page, or filled from the OFP's scheduled out time when SimBrief returns the plan. Scores the flight's On-Time Performance |
eobt_locked | boolean | true once the flight has started; the EOBT can no longer change |
custom_route | string | null | Route the pilot saved on the briefing page for SimBrief. The route actually planned is ofp.route |
departure_airport.stand, arrival_airport.stand | object | null | Assigned stand and its apron |
payload | object | null | Summary of the generated load; null until it is generated. Weights are kg |
payload.cabin_classes | array | Passengers per cabin class |
payload.holds | array | Bags and freight per cargo hold, in the load profile's order. Empty holds are listed with zero weights; carry-on bags stay in the cabin and are not counted |
payload.reduced_for_landing_weight | boolean | Part of the load was offloaded to keep the estimated landing weight under the MLW |
ofp | object | null | Headline figures of the SimBrief OFP; null until the OFP has been retrieved (see Booking OFP) |
ofp.weight_unit | string | null | Unit of ofp.fuel and ofp.weights: kg or lb, as the pilot's SimBrief account plans |
ofp.cruise_altitude | integer | null | Initial cruise altitude in feet |
ofp.distance | integer | null | Route distance in nautical miles |
ofp.times | object | SimBrief schedule, ISO-8601 UTC, with en-route and block time in minutes |
ofp.pdf_url | string | null | SimBrief's PDF of the OFP |
Any OFP figure SimBrief did not include is null.
Booking Payload
GET /api/v2/acars/booking/payload
The full manifest for the active booking's load: the same summary as payload on Active Booking, plus every passenger (in seat order), bag and freight item. Weights are kg. Answers 404 with No payload available until the load has been generated.
Response (200), abridged:
{
"weight_unit": "kg",
"total_pax": 162,
"total_pax_weight": 13608.0,
"total_baggage_weight": 2916.5,
"total_cargo_weight": 1850.0,
"total_payload_weight": 18374.5,
"cabin_classes": [{ "cabin_class": "J", "pax": 12 }, { "cabin_class": "Y", "pax": 150 }],
"holds": [{ "name": "FWD", "baggage_weight": 1402.5, "cargo_weight": 1100.0, "total_weight": 2502.5 }],
"reduced_for_landing_weight": false,
"passengers": [
{ "id": 17, "name": "Ana Souza", "gender": "F", "weight": 64.5, "seat": "1A", "cabin_class": "J" }
],
"baggage": [
{ "passenger_id": 17, "type": "checked_bag", "number": "0957-000001", "weight": 23.5, "hold": "FWD" },
{ "passenger_id": 17, "type": "carry_on", "number": null, "weight": 8.0, "hold": null }
],
"cargo": [
{ "id": 1, "hold": "FWD", "weight": 1100.0 }
]
}
| Field | Type | Description |
|---|---|---|
passengers[].gender | string | null | M or F |
passengers[].seat | string | null | Seat, or CREW for crew on a ferry flight |
baggage[].type | string | carry_on, gate_check, checked_bag, extra_bag or special_item |
baggage[].number | string | null | Bag tag; carry-on bags have none |
baggage[].hold | string | null | Cargo hold; null for carry-on bags, which stay in the cabin |
Booking OFP
GET /api/v2/acars/booking/ofp
The complete SimBrief OFP for the active booking, exactly as SimBrief returned it — navlog, weather, ATC flight plan, text.plan_html and the rest of SimBrief's XML, decoded to JSON (every value is a string, times are Unix seconds, durations are seconds). Use it for anything the ofp summary on Active Booking leaves out.
When the pilot has dispatched but the OFP has not been retrieved yet — the briefing page polls SimBrief only while it is open, so a pilot who generates the plan and goes straight to the sim leaves it behind — this endpoint fetches it from SimBrief first; afterwards ofp on Active Booking carries it too. Answers 404 with No OFP available when the pilot has not dispatched or SimBrief has no plan for the booking.
The response is large (often 0.5–1.5 MB); fetch it when the pilot opens the OFP, not on a poll.
Last Position
GET /api/v2/acars/position/last
Returns the latest position report for the active booking.
Response (200):
{
"sent_at": "2026-02-24T14:30:00.000+00:00",
"latitude": 40.6398,
"longitude": -73.7789,
"altitude": 35000,
"altitude_agl": 34500,
"heading": 270,
"ground_speed": 450,
"indicated_airspeed": 280,
"true_airspeed": 460,
"vertical_speed": 0,
"phase": "cruise",
"on_ground": false
}
Start Flight
POST /api/v2/acars/start
Marks the active booking as started and clears prior position reports.
Request body (optional):
{
"timestamp": 1741435200123,
"speech": "client"
}
speech: "client" says the client plays the cabin announcements itself from GET /speech/pack; the server then stops queuing audio for GET /sound. Omit it to keep the server-timed announcements.
timestamp is the client's clock at the moment of the start, in any format a position report accepts. When it is sent, every position report stamped before it is discarded for this booking: a start begins a fresh recording, so reports from an earlier session of the same booking (for example, ones replayed from a client outbox after the pilot gave up on a previous attempt) are dropped instead of being merged into the new flight. Such reports still answer 202, so the client can clear them from its outbox. Without a timestamp, no report is discarded.
Response (200):
{
"message": "Flight started",
"trackingID": 42
}
Submit Position Report
POST /api/v2/acars/position
Content-Type: application/json
Accepts a single position object or an array of position objects. Returns 202 immediately; processing is asynchronous.
Every position is stored durably before the 202 is returned — even if the background enrichment worker crashes, the data is not lost. The server also de-duplicates on (booking, sent_at), so a client that retries the same payload after a flaky network will never produce duplicate rows. Clients can therefore retry any 5xx response without coordination.
Single position:
{
"timestamp": "2026-03-08T12:00:00.123Z",
"position": { "latitude": { "value": 51.4775 }, "longitude": { "value": -0.4614 } },
"..."
}
Batch (array of positions):
[
{ "timestamp": "2026-03-08T12:00:00.000Z", "position": { "latitude": { "value": 51.0 }, "longitude": { "value": -0.5 } }, "..." },
{ "timestamp": "2026-03-08T12:00:00.250Z", "position": { "latitude": { "value": 51.5 }, "longitude": { "value": -0.6 } }, "..." }
]
timestamp is stored with millisecond precision (timestamp(3) in MySQL/Postgres). All of the following formats are accepted, so existing clients that report at whole-second resolution continue to work unchanged:
- ISO-8601 string with fractional seconds —
"2026-03-08T12:00:00.123Z" - ISO-8601 string with whole seconds —
"2026-03-08T12:00:00Z" - Unix epoch seconds (integer or numeric string) —
1741435200 - Unix epoch milliseconds (13-digit integer or numeric string) —
1741435200123
The server parses the format automatically: any numeric value ≥ 1e12 is treated as milliseconds since epoch, anything below that as seconds. Response timestamps are always returned in ISO-8601 with the .vvv fractional component.
Key fields (per position):
| Field | Type | Required | Description |
|---|---|---|---|
latitude | float | Yes | Current latitude |
longitude | float | Yes | Current longitude |
altitude | int | No | Altitude MSL (feet) |
altitudeAgl | int | No | Altitude AGL (feet) |
heading | int | No | True heading (degrees) |
groundSpeed | int | No | Ground speed (knots) |
indicatedAirspeed | int | No | IAS (knots) |
trueAirspeed | int | No | TAS (knots) |
verticalSpeed | int | No | VS (fpm) |
flightPhase | string | No | Current phase |
onGround | bool | No | On ground flag |
landingRate | float | No | Landing VS (fpm) |
aircraftType | string | No | Aircraft ICAO type code (e.g. B738) |
acarsVersion | string | No | Top-level field. Reporting client version (semver or short SHA, max 32 chars). Persisted on every position row for triage and rollout monitoring. |
A numeric reading too large for the column that stores it (for example a throttle of 1047 %, which some aircraft report during engine start) is stored as null. The rest of that position, and every other position in the batch, is stored as reported and the request still returns 202. latitude and longitude are not checked: they are required, and any real coordinate fits.
Engine fields:
Each engine (1–4) supports the following nested fields:
| Field | Type | Description |
|---|---|---|
engines[].n1 | float | N1 percentage |
engines[].n2 | float | N2 percentage |
engines[].throttle | float | Throttle position |
engines[].mixture | float | Mixture lever position |
engines[].propeller | float | Propeller lever position |
engines[].firing | bool | Engine running |
engines[].exists | bool | Engine physically present on aircraft |
Autopilot fields:
| Field | Type | Description |
|---|---|---|
autopilot.master | bool | Autopilot master switch |
autopilot.heading | float | Selected heading |
autopilot.altitude | float | Selected altitude |
autopilot.vs | float | Selected vertical speed |
autopilot.speed | float | Selected speed |
autopilot.approach | bool | Approach mode active |
autopilot.nav | bool | NAV mode active |
Radio & navigation fields:
| Field | Type | Description |
|---|---|---|
radios.com1 | float | COM1 frequency |
radios.com2 | float | COM2 frequency |
radios.nav1 | float | NAV1 frequency |
radios.nav2 | float | NAV2 frequency |
radios.nav1Obs | float | NAV1 OBS setting |
radios.nav2Obs | float | NAV2 OBS setting |
radios.transponder | int | Transponder code |
radios.transponderState | int | Transponder state (0=off, 1=standby, 2=on, 3=test) |
Lights fields:
| Field | Type | Description |
|---|---|---|
lights.beacon | bool | Beacon light |
lights.strobe | bool | Strobe lights |
lights.landing | bool | Landing lights |
Controls fields:
| Field | Type | Description |
|---|---|---|
controls.elevator | float | Elevator deflection |
controls.aileron | float | Aileron deflection |
controls.rudder | float | Rudder deflection |
controls.spoilers | float | Spoilers position |
APU fields:
| Field | Type | Description |
|---|---|---|
apu.switchOn | bool | APU master switch |
apu.rpm | float | APU RPM percentage |
apu.genSwitch | bool | APU generator switch |
apu.genActive | bool | APU generator active |
Doors fields:
| Field | Type | Description |
|---|---|---|
doors[].open | float | Door open position (0.0 = closed, 1.0 = fully open) |
Up to 5 doors are supported (doors[0] through doors[4]).
Runway & stand fields:
null unless the aircraft is on the ground on a runway or a stand. A client that omits these keys entirely (for example, a report already sitting in an outbox from before the client supported them) is treated identically to sending null.
| Field | Type | Description |
|---|---|---|
runway.ends[].designator | string | Runway end identifier, e.g. 09L |
runway.ends[].latitude | float | Threshold latitude |
runway.ends[].longitude | float | Threshold longitude |
runway.ends[].altitude | float | Threshold elevation (ft). Both ends report the same value — neither simulator source gives a separate elevation per end. |
stand.name | string | Parking stand name, e.g. A12 |
stand.latitude | float | Stand latitude |
stand.longitude | float | Stand longitude |
The server resolves which of the two reported ends the aircraft is aligned with (from aircraft heading, preferring the airline's own runway record when one is seeded) and stores that alongside the reported pair. The flight's departure/arrival runway and stand are derived from this on every processing pass, and the landing performance report (touchdown distance/offset, runway used) is derived from it when a seeded airport_runways row exists for the resolved designator — see the Flights page's landing performance report section.
Environment & state fields:
| Field | Type | Description |
|---|---|---|
wind.direction | float | Wind direction (degrees true) |
wind.speed | float | Wind speed (knots) |
sensors.pressureQNH | float | QNH pressure setting |
sensors.gForce | float | Current G-force |
sensors.stallWarning | bool | Stall warning active |
sensors.overspeedWarning | bool | Overspeed warning active |
sensors.paused | bool | Simulation paused |
sensors.slew | bool | Slew mode active |
sensors.crashed | bool | Aircraft crashed |
simulationRate | float | Simulation rate multiplier |
totalWeight | float | Aircraft total weight (lbs) |
fuelWeight | float | Fuel weight (lbs) |
See the Scramble API docs at https://<your-domain>/docs/acars for the complete field schema and validation rules.
Response (202):
{
"message": "Position received"
}
or for batch:
{
"message": "3 positions received"
}
Cancel Flight
POST /api/v2/acars/stop
Stops the active flight and clears all position reports.
Response (200):
{
"message": "Flight canceled"
}
Finish Flight
POST /api/v2/acars/finish
Completes the flight, creates a PIREP, and deletes the booking.
Response (200):
{
"message": "Flight finished",
"pirepID": 123
}
Speech Instructions (server-timed)
GET /api/v2/acars/sound
Returns pending TTS speech instructions for the active booking. This is how ACARS clients that do not time the announcements themselves get them: the server detects the flight phase from position reports and queues each trigger's audio here. Once a client sends "speech": "client" with POST /start, or reads any /speech/* endpoint below, the server stops queuing audio for that booking.
Response (200):
{
"instructions": [
{ "type": "play", "url": "/api/v2/acars/sound/fragment/42" },
{ "type": "pause", "duration_ms": 500 },
{ "type": "play", "url": "/api/v2/acars/sound/combined/abc123.mp3" }
]
}
Speech Pack (client-timed)
GET /api/v2/acars/speech/pack[?arrival=ICAO]
The active booking's speech pack for a client that plays the announcements itself: every trigger in flight order with its lines rendered for this booking, the pack timing, and the audio to download. arrival resolves the pack as if the flight were going to another airport (a diversion). Reading it starts generating every playable line's audio in the background.
The audio is prepared when the booking is made, so the pack is usually ready on the first request. While it is not, the planned arrival's pack answers 202 with a Retry-After header: read it again after that many seconds. It answers 200 once every playable line that is not dynamic has its audio_url, or two minutes after the first request, whichever comes first; lines still without audio then come with their segments, or are fetched with GET /speech/lines/{id}. A pack for another arrival (a diversion) never waits.
Response (202):
{
"message": "The speech pack is being prepared",
"data": { "status": "preparing", "lines_ready": 12, "lines_total": 28, "retry_after": 10 }
}
Response (200):
{
"data": {
"version": 1,
"revision": "9f2c…",
"booking_id": 123,
"flight": {
"departure": "LPPT", "arrival": "SBGR", "is_alternate": false, "original_arrival": null,
"planned_block_minutes": 590, "time_of_day": "night",
"meal_services": [
{ "n": 1, "meal": "dinner", "timing": "after_cruise", "minutes": 30 },
{ "n": 2, "meal": "breakfast", "timing": "before_descent", "minutes": 90 }
]
},
"settings": {
"seatbelt": { "on_cooldown_minutes": 2, "off_cooldown_minutes": 2 },
"landing": { "smooth_max_fpm": 200, "smooth_max_g": 1.2, "hard_min_fpm": 400, "hard_min_g": 1.5 },
"night": { "from": "22:00", "to": "06:00" }
},
"audio": { "ready": true, "lines_ready": 28, "lines_total": 28 },
"phases": [
{
"phase": "gate_departure",
"label": "Boarding",
"lines": [
{
"id": 42, "phase": "gate_departure", "sort_order": 0, "type": "text",
"language": "en", "voice": "Lily",
"conditions": { "time_of_day": "any", "landing": "any", "cruise_at": "cruise", "meal": "any", "repeat": true, "repeat_every_minutes": 5 },
"service": null,
"text": "Good evening, ladies and gentlemen, and welcome aboard…",
"playable": true, "skip_reason": null, "dynamic": true,
"audio_url": null, "segments": [],
"fetch_url": "https://your-va.example/api/v2/acars/speech/lines/42",
"duration_ms": null
}
]
}
]
}
}
skip_reasonisdisabled,time_of_day,meal_not_served,no_voiceoremptywhen a line will not play.dynamiclines use a variable that depends on when they play (local time, delays, weather, greeting): fetch them again just before playing.audio_urlis one file for the line; when it is null butsegmentsis not empty, play the segments in order. A multi-sentence line's file is built in the background; a pack served before it is ready gives the segments instead.- Meal service lines appear once per service they play at, with
serviceset.
GET /api/v2/acars/speech/lines/{id}[?arrival=ICAO&service=N]
One line, rendered now, with its audio generated before replying: {"data": LINE}. 422 when it cannot be voiced (no voice, empty text); 404 when the line is not in the booking's pack.
GET /api/v2/acars/speech/phases/{phase}[?arrival=&service=&landing=&cruise_at=]
The playable lines of one trigger, each voiced: {"data": {"phase": "after_landing", "lines": [LINE…]}}. landing is smooth, normal or hard (a normal landing plays only lines set to any landing); service picks a meal service; cruise_at is cruise, 25, 50 or 75.
Audio Streaming (Public)
These endpoints do not require authentication:
GET /api/v2/acars/sound/fragment/{id}
GET /api/v2/acars/sound/combined/{filename}
GET /api/v2/acars/sound/upload/{filename}
Returns audio/mpeg streams.
Send Message
POST /api/v2/acars/message
Content-Type: application/json
{
"message": "Requesting pushback clearance."
}
For compatibility with ACARS / Hoppie / Baudot-era teleprinters, every persisted message is normalized to a reduced character set: A–Z, 0–9, space, comma (,), period (.), slash (/), hyphen (-). Lowercase letters are upcased and accented letters are converted to their unaccented equivalent (À → A, ç → C); anything else outside the set (non-Latin scripts, !, ?, :, ;, parentheses, em-dashes, emojis, newlines) is stripped silently. Clients should expect the response message field to differ from the request when input contained disallowed characters.
Response (201):
{
"id": 1,
"user_id": 5,
"type": "chat",
"sender_id": 5,
"sender_name": "John Doe",
"sender_role": "user",
"message": "REQUESTING PUSHBACK CLEARANCE",
"sent_at": "2026-02-24T14:30:00.000000Z",
"received_at": "2026-02-24T14:30:00.000000Z",
"read_at": null,
"created_at": "2026-02-24T14:30:00.000000Z",
"updated_at": "2026-02-24T14:30:00.000000Z"
}
Confirm Message
PUT /api/v2/acars/message/confirm
Content-Type: application/json
{
"message_id": 1
}
Marks a message as read. Returns the updated message object.
List Messages
GET /api/v2/acars/messages?page=1
Returns paginated messages (50 per page), newest first.
Pilot Resources
These endpoints back the desktop client's My Flights, NOTAMs and Documents tabs. They share the rules below.
Scoping. Every response is limited to the pilot behind the Bearer token and to their airline. The client sends filter[user_id] on the flights route; the server ignores it and never returns another pilot's data.
Status codes. 200 ok · 403 the record exists but is not available to this pilot · 404 no such route or record · 401 only for a missing, invalid or expired token. The client signs the pilot out on any 401 from /api/v2/acars/*, so nothing else answers 401.
Pagination. Laravel style: { "data": [...], "meta": { "current_page", "last_page", "per_page", "total" } }. page and per_page (capped at 100) select the page; sort and sort_dir (asc | desc) order it. An unknown sort falls back to the endpoint's default order.
Private API probe. The client tries /api/v1/<resource> before falling back to /api/v2/acars/<resource> and stops on 401/403. The private API v1 only accepts API keys, so a pilot's ACARS token on /api/v1/* answers 404 (not 401) and the client falls through to the routes below.
Pilot Flights
GET /api/v2/acars/pilot/flights
GET /api/v2/acars/flights (alias)
The pilot's filed PIREPs, newest first by default.
| Parameter | Default | Description |
|---|---|---|
page, per_page | 1, 50 | Pagination (per_page capped at 100) |
sort | created_at | One of created_at, blocks_off, blocks_on, landing_rate, distance, flight_time |
sort_dir | desc | asc or desc |
filter[user_id] | — | Ignored; the token decides whose logbook this is |
Response (200):
{
"data": [
{
"id": 12345,
"callsign": "GLO1234",
"flight_number": "G31234",
"status": "acceptedStaff",
"departure_airport": { "icao": "SBGR", "iata": "GRU", "name": "Guarulhos", "city": "Sao Paulo", "latitude": -23.435556, "longitude": -46.473056 },
"arrival_airport": { "icao": "SBRJ", "iata": "SDU", "name": "Santos Dumont", "city": "Rio de Janeiro", "latitude": -22.910461, "longitude": -43.163133 },
"aircraft": { "registration": "PR-GXZ", "name": "Boeing 737-800", "icao_code": "B738" },
"flight_time": 55,
"block_time": 60,
"distance": 195,
"landing_rate": -180,
"fuel_used": 1450,
"score": 98,
"composite_score": 9.8,
"pax": 120,
"cargo": 800,
"departure_time": "2026-09-26T14:30:00Z",
"arrival_time": "2026-09-26T15:25:00Z",
"created_at": "2026-09-26T15:30:00Z"
}
],
"meta": { "current_page": 1, "last_page": 3, "per_page": 50, "total": 120 }
}
| Field | Type | Description |
|---|---|---|
status | string | PIREP status: pendingStaff, pendingUser, pendingSystem, acceptedSystem, acceptedStaff, rejectedSystem, rejectedStaff, warningSystem, warningStaff |
flight_time, block_time | integer | Minutes |
distance | integer | Nautical miles |
landing_rate | integer | fpm (negative) |
fuel_used | integer | null | kg burned, once the flight has been processed |
score | integer | null | Flight score on a 0–100 scale (composite_score × 10), once rated |
composite_score | float | null | The same score on the platform's 0–10 scale |
departure_time, arrival_time | string | null | Blocks off / blocks on, ISO-8601 UTC |
Flight Detail
GET /api/v2/acars/flights/{id}
One of the pilot's PIREPs with the data behind the flight page: its runways and stands, and its Landing, Takeoff and Score tabs. Another pilot's flight answers 404, the same as one that does not exist. The response carries every field of Pilot Flights plus the fields below. A section with nothing recorded is null, so the client can hide its tab instead of showing placeholder values. Score formulas and input labels are in the pilot's language.
Response (200), abridged:
{
"data": {
"id": 12345,
"callsign": "GLO1234",
"route": "UZ25 PORTO",
"departure_runway": "10L",
"arrival_runway": "20L",
"departure_stand": "213",
"arrival_stand": "5A",
"assigned_departure_stand": { "stand": "212", "apron": "Terminal 2" },
"assigned_arrival_stand": { "stand": "5A", "apron": "Main Apron" },
"approach_type": "ils",
"landing_g_force": 1.18,
"landing": {
"landing_rate_band": "good",
"g_force_band": "normal",
"touchdown_distance_m": 420,
"touchdown_offset_m": -1.5,
"touchdown_ground_speed": 132,
"touchdown_indicated_airspeed": 138,
"runway_used_m": 1150,
"runway_used_percent": 87.5,
"runway": { "designator": "20L", "magnetic_heading": 200, "length_m": 1260.0, "width_m": 42.0 },
"wind": { "direction_degrees": 200, "speed_knots": 10.0, "headwind_knots": 10.0, "crosswind_knots": 0.0 },
"stabilized_approach": { "gate_height_ft": 1000, "passed": false, "criteria": null, "failures": ["Approach unstabilized at 1000 ft above field: ..."] }
},
"takeoff": {
"liftoff_distance_m": 1900,
"takeoff_roll_m": 1750,
"liftoff_offset_m": 0.8,
"liftoff_ground_speed": 151,
"liftoff_indicated_airspeed": 155,
"runway_used_percent": 51.3,
"runway": null,
"wind": null
},
"score_breakdown": {
"score": 8.6,
"contributing": ["otp", "eco", "comfort", "safety"],
"categories": [
{ "key": "safety", "score": 8.0, "weight": 1.0, "available": true, "unavailable_reason": null, "formula": "10 minus 0.5 per FDM warning ...", "inputs": [{ "label": "FDM warnings", "value": "0" }] }
]
},
"events": [
{ "id": 77, "type": "violation", "event": "Approach unstabilized ...", "happened_at": "2026-09-26T15:18:00Z", "duration_seconds": null }
],
"points": {
"total": -30,
"entries": [
{ "id": 9, "source": "fdm", "reason": "Overspeed", "points": -50, "remarks": null, "created_at": "2026-09-26T15:31:00Z" }
]
}
}
}
| Field | Type | Description |
|---|---|---|
route | string | null | Filed route |
departure_runway, arrival_runway | string | null | Runways the aircraft actually used |
departure_stand, arrival_stand | string | null | Stands the aircraft actually left from and parked on, as the ACARS client reported them; null when it reported none |
assigned_departure_stand, assigned_arrival_stand | object | null | Stands the booking assigned, with their apron, as the flight page shows them; null when none was assigned |
approach_type | string | null | ils, rnp, visual or raw_data |
landing | object | null | Touchdown analysis; null when no touchdown measurement, wind sample or stabilized-approach rule exists |
landing.landing_rate_band | string | null | butter (≤100 fpm), good (≤200), firm (≤400), hard |
landing.touchdown_distance_m | integer | null | Metres past the threshold; negative is short of it |
*_offset_m | float | null | Metres from the centreline, positive right of it |
*_ground_speed, *_indicated_airspeed | integer | null | Knots |
landing.runway, takeoff.runway | object | null | The runway from the airport database; length_m is the declared landing or takeoff length, else the full length |
landing.wind, takeoff.wind | object | null | Wind on the touchdown / liftoff sample against the runway; headwind_knots negative is a tailwind, crosswind_knots positive is from the right |
landing.stabilized_approach | object | null | Only when the aircraft's FDM profile evaluates stabilized approaches. criteria holds the flaps/gear/speed snapshot on flights evaluated since it was introduced, else null |
takeoff | object | null | Liftoff analysis; null when nothing was measured |
score_breakdown | object | The live per-category score the flight page shows, on the 0–10 scale. score is null until a category can be scored |
events | array | FDM events in time order; type is information, warning or violation |
points | object | Points awarded for this flight: fdm (from its events) and staff (from review) |
Flight Track
GET /api/v2/acars/flights/{id}/track
The flight's recorded positions in time order, for the map and telemetry chart. Another pilot's flight answers 404.
| Parameter | Default | Description |
|---|---|---|
max_points | 5000 | Most samples to return, 2–5000. Longer tracks are downsampled evenly, keeping the first and last sample |
Response (200):
{
"data": [
{ "sent_at": "2026-09-26T14:40:00.250Z", "phase": "Climb", "lat": -23.4, "lng": -46.4, "altitude": 2500, "altitude_agl": 1700, "indicated_airspeed": 200, "ground_speed": 210, "vertical_speed": 2000, "heading": 80 }
]
}
lngis unwrapped from the departure airport, so a track that crosses the antimeridian stays continuous and can run past ±180.- Samples where the simulator glitched to zero altitude and speed mid-flight are dropped.
- Altitudes are feet, speeds knots,
vertical_speedfeet per minute. - A flight without positions returns
"data": [].
Company NOTAMs
GET /api/v2/acars/notams
GET /api/v2/acars/notams/{id}
The company NOTAMs currently in effect for the airline — active, started and not yet ended — exactly as the pilot NOTAMs page shows them. NOTAMs that are inactive, scheduled for later or already ended answer 404 on the detail route.
| Parameter | Default | Description |
|---|---|---|
page, per_page | 1, 25 | Pagination (per_page capped at 100) |
sort | created_at | One of created_at, starts_at, ends_at, title |
sort_dir | desc | asc or desc |
filter[is_active] | — | Accepted for compatibility; only NOTAMs in effect are ever returned |
Response (200):
{
"data": [
{
"id": 7,
"title": "Runway closure",
"content": "<p>Runway 09R closed for works.</p>",
"body": "<p>Runway 09R closed for works.</p>",
"starts_at": "2026-09-20T00:00:00Z",
"ends_at": null,
"requires_acknowledgement": true,
"is_active": true,
"created_at": "2026-09-19T18:04:11Z",
"updated_at": "2026-09-19T18:04:11Z"
}
],
"meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
content and body carry the same sanitised HTML. The detail route wraps the object in data.
Documents
GET /api/v2/acars/documents
GET /api/v2/acars/documents/{id}
GET /api/v2/acars/documents/{id}/pdf
The document library as the pilot sees it: standalone documents whose visibility (public, minimum rank, licence, staff) opens them to the pilot behind the token. Documents attached to another record, such as MEL definitions, are not part of the library and answer 404; a library document closed to the pilot answers 403.
| Parameter | Default | Description |
|---|---|---|
page, per_page | 1, 100 | Pagination (per_page capped at 100) |
sort | sort_order | One of sort_order, title, created_at, updated_at |
sort_dir | asc | asc or desc |
filter[parent_id] | — | Documents inside this folder; an empty value selects the library roots |
filter[title] | — | Documents whose title contains this text |
Response (200):
{
"data": [
{
"id": 3,
"title": "FCOM",
"slug": "fcom",
"type": "pdf",
"parent_id": 1,
"visibility": "public",
"is_auto_generated": false,
"source": null,
"source_type": null,
"content": null,
"file_url": "https://example.airspace.app/api/v2/acars/documents/3/pdf",
"sort_order": 2,
"created_at": "2026-08-02T09:12:00Z",
"updated_at": "2026-08-02T09:12:00Z"
}
],
"meta": { "current_page": 1, "last_page": 1, "per_page": 100, "total": 1 }
}
| Field | Type | Description |
|---|---|---|
type | string | html, pdf or folder; auto-generated documents are html with is_auto_generated: true |
source | string | null | For auto-generated documents, a readable label of what they were generated from: Airport briefing, Load profile or FDM profile |
source_type | string | null | The same as a stable key: airport, load_profile or fdm_profile |
content | string | null | Sanitised HTML for html documents |
file_url | string | null | Absolute URL of the PDF download below for pdf documents; null otherwise |
visibility | string | public, staff, rank or license |
PDF download
GET /api/v2/acars/documents/{id}/pdf
Authorization: Bearer <token>
Returns application/pdf with Content-Disposition: attachment, authenticated with the ACARS token rather than a web session, so the client can fetch the bytes directly and open the file locally. pdf documents return the uploaded file; html (and auto-generated) documents return a rendered PDF, pre-generated when one is stored and rendered on the spot otherwise. Folders have no PDF and answer 404; a document closed to the pilot answers 403.
Interactive API Documentation
Full interactive API documentation with request/response schemas is available at:
https://<your-domain>/docs/acars
This is auto-generated from the live routes and covers the V2 API — every endpoint shows its authentication requirements, and requests can be tried directly from the page using a Bearer access token obtained through the device code flow.
Pilot History (Legacy)
Returns 6 months of the authenticated pilot's flight statistics — hours, flights, points, and average landing rate. Prefer /pilot which includes profile data alongside history.
GET /api/v2/acars/history
Authorization: Bearer <token>
Response:
{
"months": ["2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03"],
"flight_hours": [12.5, 8.3, 15.0, 6.2, 10.1, 3.5],
"flights": [5, 3, 7, 2, 4, 1],
"points": [500, 300, 700, 200, 400, 100],
"avg_landing_rate": [-180, -200, -150, -220, -170, -190]
}
| Field | Type | Description |
|---|---|---|
months | string[] | 6 month labels in YYYY-MM format (oldest first) |
flight_hours | float[] | Total accepted flight hours per month |
flights | int[] | Total accepted flight count per month |
points | int[] | Total points earned per month |
avg_landing_rate | float[] | Average landing rate (fpm) per month |
Also available at the V1 path: GET /api/acars/history
VA Information — favicon_url
The VA information endpoint now includes a favicon_url field:
GET /api/v2/acars/va
The favicon_url field returns the tenant's configured favicon as a fully resolved URL, or null if not set.
Error Handling
All endpoints return JSON error responses:
{
"message": "No active booking"
}
Common error codes:
| Code | Meaning |
|---|---|
| 401 | Missing or invalid Bearer token — the client signs the pilot out |
| 403 | The record exists but is not available to this pilot |
| 404 | Resource not found (no active booking, no such route or record, etc.) |
| 410 | Device code expired |
| 422 | Validation error |
| 429 | Rate limited (polling too fast) |