# FAA Airmen Certificate Change Monitor (`titan_coder/faa-airmen-certificate-change-monitor`) Actor

Private change monitor for named FAA airmen by UNIQUE ID. Bills only on real change in the monthly FAA Releasable Airmen file: certificate level upgrade, rating change, medical class or date change, plus a distinct event when a record is absent from two different FAA releases. No names or addresses.

- **URL**: https://apify.com/titan\_coder/faa-airmen-certificate-change-monitor.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 airman record changeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## FAA Airmen Certificate Change Monitor

Watches specific airmen in the official FAA Civil Aviation Registry **Airman Directory Releasable File**
(`registry.faa.gov/database/CS<MM><YYYY>.zip`) and delivers — and bills — only on a genuine change since
your last check. Never a re-delivery of an unchanged record.

**Positioning, stated plainly.** Delta monitoring of this file is *not* unique — at least one other Apify
actor already reads the same monthly FAA archive, already accepts a list of FAA unique IDs, already keeps
a durable "emit only what is new or changed since the last run" store, and already bills only for
delivered results. This actor is not sold as "the only delta monitor". It is built around two things that
a "new-or-changed" feed structurally cannot give you:

1. **Absence as a first-class event.** A feed that emits records which are new or changed can only tell
   you about rows it *sees*. To notice that a specific airman is **no longer in the file at all** you have
   to keep a durable index of the IDs you are watching and diff the snapshot against it. That is what this
   actor does, and the event it raises is narrow and literal: **"this record was absent from two
   consecutive, genuinely different FAA releases."** Read the next section for exactly what that does and
   does not mean.
2. **A privacy-narrowed output for credentialing and compliance** — no names, no street addresses, no ZIP
   codes, ever (see Privacy below). That is a deliberate narrowing of the product, not a feature the
   lead-generation scrapers are missing.

If what you need is a bulk export with names and addresses for lead generation, the other FAA actors on
Apify Store are the right tool and are cheaper for that job.

**Not a certification of flight privileges and not legal advice.** This is an informational monitor of a
public government file's *publication state*. It is not proof that anyone currently holds valid flight
privileges, not a verification of a medical certificate on any given date, and not a substitute for a
direct request to the FAA Airmen Certification Branch before any decision that matters (hiring, crew
assignment, insurance underwriting, Part 135/145 compliance).

### What it watches

One watch = one FAA **UNIQUE ID** (the letter `A` or `C` followed by exactly 7 digits, e.g. `A0000014`) —
the stable identifier FAA itself uses as the key across all four files in the archive. Per run the actor
checks each watch against the current monthly snapshot and reports:

- **`new`** — the first time this airman was found (the very first sighting of a watch is a free,
  unbilled baseline; `new` fires only for a later first appearance).
- **`record_changed`** — something in the tracked professional record changed: a certificate level
  upgrade (Student → Private → Commercial → ATP), a certificate type gained or lost, ratings or aircraft
  type ratings added or removed, or the medical class / medical date / medical expiry / BasicMed dates
  changed. The row's `changedFields` says which.
- **`removed_from_releasable_file`** — the record, previously found, is now absent from the releasable
  file **entirely**, on **two consecutive, genuinely different monthly releases** (see "Two-strike"
  below). This is a statement about the FAA publication only — **never** a report that a certificate was
  revoked, suspended or lost. See "What the absence event does and does not mean".
- **`restored`** — an airman previously *confirmed* removed is found again.
- **`candidate_removed`** — first absence on a single release. Informational only: delivered with
  `billed: false`, never charged. If the airman reappears before a second, different release confirms
  the absence, the candidate is silently cancelled and nothing is billed either way.

Every genuine transition (`new`, `record_changed`, `restored`, and a *confirmed*
`removed_from_releasable_file`) is billed once under a single event. The same unchanged record is never
billed twice, and `candidate_removed` is never billed.

### Privacy: no names, no home addresses — on purpose

This is a register of **private individuals**. The FAA file itself contains each airman's full name and
home mailing address, and Public Law 106-181 does require FAA to release them. This actor still does
**not** read those fields into its model and does **not** emit them anywhere — not in the dataset, not in
the webhook, not in the digest.

What you get: the `airmanId` you supplied, the full certificate structure (type, level, ratings, aircraft
type ratings, expiry), medical class and dates, and coarse geography — **city, state, country and FAA
region, with no street address and no ZIP code**.

Address fields are also deliberately excluded from the change hash: a pilot moving house is not a change
in professional standing, so a relocation never produces a row and never costs you anything. If you need
a name-and-address list for lead generation, this is the wrong actor — that is what the bulk FAA scrapers
on Apify Store are for. This one is built for *credentialing and compliance*, where the address is not
just unnecessary but a liability.

### Monthly cadence — read this before scheduling

**FAA republishes this file once a month, on the 1st.** That is the ceiling on freshness of the source
itself, not a limitation of this implementation. In the normal case, running the actor more often than
monthly surfaces nothing new — the exception is a mid-month reissue of the same month's archive, which
this actor *does* detect whenever the reissued file differs in size, `Last-Modified` or `ETag` from the
one already processed (see the known limitation below for the case where it does not). Either way, this
actor is built so that you do not pay for trying:

- Before downloading anything, the actor makes one cheap ranged request (200 bytes) and compares the
  response headers (month, exact size, `Last-Modified`, `ETag`) with those of the last release it fully
  processed. If they match **and** every watch already has a baseline, the run ends immediately as
  `no_new_release`: no 57 MB download, no history write, **zero charges**. (`HEAD` cannot be used here —
  `registry.faa.gov` answers `HEAD` with HTTP 503; a ranged `GET` answers `206 Partial Content`.) Those
  headers are only ever a reason to download or not download; nothing is decided by them. Once the
  archive is in hand, its SHA-256 is what identifies the release.
  **Known limitation:** this cheap check is a heuristic, not a guarantee. If FAA reissues a corrected
  archive for the same month and the new file happens to keep the same size, `Last-Modified` and `ETag`
  as the one already processed, this actor will not notice — the probe matches, the download is skipped,
  and the correction is only picked up once the *following* month's release changes at least one of those
  headers (or, at the latest, once the following month's file is published under a new name/size
  entirely). We deliberately do not trade this away for a full monthly re-download-and-hash on every run:
  the archive is 57 MB, and "just download it every time to be sure" turns a near-free monthly check into
  a 57 MB transfer on every single run, most of which would find nothing new. If your use case cannot
  tolerate a same-headers reissue going unnoticed for up to a month, do not rely on this actor's
  freshness check for that — that risk window is a property of the FAA source's own publishing habits,
  not something this actor can close without materially changing its cost profile.
- If you add a *new* watch mid-month, that run does download the archive so the new watch gets its
  baseline immediately rather than waiting for the next release.
- **A release is only marked as processed once every watch in the run has finished successfully.** If a
  run is cut short — the run's wall-clock deadline, a lost monitor lease, your own spend cap, or one watch
  whose snapshot could not be trusted — that release is *not* recorded as done, and the next run
  downloads and re-processes it instead of skipping it. Rows already delivered are not re-delivered or
  re-charged: the right to write a row and bill for it is granted by an atomic gate (a single
  `RequestQueue.addRequest` per item, in a dedicated durable journal that is never cleared) before the
  first irreversible step, keyed on the airman, the change sequence and the content hash — the per-watch
  index is only advanced after a row reaches a terminal state.
  **This is at-most-once delivery, not exactly-once**: a crash right after the right to deliver is granted
  can lose that one event permanently (it is recorded internally as unresolved and never retried), but you
  are never billed twice for the same event. That guarantee holds as long as the claim journal itself is
  not deleted or recreated (an account-level action outside the actor's control), and it applies from the
  build that introduced this atomic gate onward. Every run reports this in `coverage` as `releaseAccepted`
  (plus `releaseNotAcceptedReason`) and `claimJournalSize` (a best-effort, eventually-consistent size of
  that journal, for operational monitoring only).
- A download that fails on a transient network error (timeout, 5xx, interrupted stream) is retried
  exactly once inside the run's fixed byte budget. Nothing else in the actor retries.

**Recommended schedule: once a month, on the 2nd–3rd** (a small delay past the 1st absorbs publication
lag). Anything more frequent is harmless and near-free; its only payoff is catching a mid-month reissue
sooner, as described above. If the current month's file is not up yet, the
actor automatically falls back to the previous month's file rather than failing.

### Two-strike removal confirmation, tied to the release

A single snapshot passing the integrity gate (below) only proves the file is not catastrophically
truncated; a file that is genuinely valid but a few percent short still passes. To avoid billing a
disappearance that is really a data artefact, this actor requires the absence to repeat:

1. **First absence** of a previously-found airman → recorded internally as a pending candidate and
   delivered as `candidate_removed`, `billed: false`. The last known record is left untouched. This row
   is free, but a Dataset write is still irreversible, so it goes through its own at-most-once gate (keyed
   without the run id, on a durable "disappearance cycle" counter) — a crash right after delivery can lose
   this one informational row, but it is never delivered twice for the same cycle.
2. **Reappears before confirmation** → the candidate flag is cleared. Nothing billed, nothing reported as
   removed or restored.
3. **Still absent on a *different* monthly release** → *now* confirmed and billed as
   `removed_from_releasable_file`.

The words "*different* release" are load-bearing. The source only changes monthly, so "two consecutive
runs" would be meaningless — you could run the actor three times in one afternoon against the identical
file. Confirmation is keyed to **release identity, which is the SHA-256 of the archive the run actually
downloaded** — not to a header. Repeated runs against the same monthly file therefore never advance the
two-strike counter, and neither does a re-download of byte-identical content served with a different
`Last-Modified` or `ETag`. Conversely, if FAA reissues a corrected file for the same month *and the run
actually downloads it* (see the known limitation above — a same-headers reissue can go undownloaded until
the next month), the hash differs and it correctly counts as a new release even if its size is unchanged.
**Practical consequence: a confirmed absence reaches you one month after the first one** (longer still if
a same-headers reissue delays detection further, per the limitation above). That is the honest cost of
not billing you for a false alarm.

### What the absence event does and does not mean

**The event is about the FAA file, not about anybody's licence.** `removed_from_releasable_file` states
exactly one fact and nothing more:

> This `UNIQUE ID` was present in the FAA Airman Directory Releasable File, and is now absent from it in
> **two consecutive, genuinely different releases** of that file.

It is **not** a report that a certificate was revoked, suspended, cancelled, surrendered or lost, and it
must never be read, forwarded or resold as one. This actor cannot know any of that, because the file does
not say it.

Why it cannot: FAA's own documentation, shipped inside the archive as `HelpComm.pdf` ("Airman Directory
Releasable File, Comma Delimited", Airmen Certification Branch AFS-760), states two things at once:

> "Only active Certificates are processed; At least 1 Certificate must be active for Airman to be in
> file."

> "This file contains the names, addresses, and certificate information of those airmen who did not
> respond to indicate that they wished to withhold their address information."

So a record leaves the file when **either** the airman no longer has an active certificate in it **or**
the airman exercised their statutory right (Public Law 106-181 §715) to withhold address release — a
purely administrative act with no bearing on their credentials at all. **The file carries no field that
distinguishes these two, and neither does this actor.** The honest use of the event is as a *prompt to
check*: something about this airman's presence in the public FAA publication changed, so ask the FAA
Airmen Certification Branch directly before any decision that matters.

There is also a third possibility the actor guards against rather than reports: a partial or inconsistent
snapshot. If a watched ID turns up in the certificate files but is missing from **both** basic files, the
snapshot is not trustworthy for that airman, so the run reports `source_access_limited` for that watch —
no candidate row, no charge, no advance of the two-strike counter — and the same FAA release is downloaded
and re-processed on the next run rather than being marked as done.

### Honest limits

- **The durable dataset is a delivery-attempt log, not a guaranteed mirror of the default dataset.** Each
  row is written to the named durable dataset first, then mirrored to the run's default dataset, and only
  then does billing proceed for that row. If the durable write succeeds but the default-dataset mirror
  fails (a transient Apify storage error, say), the item is marked `dataset_unknown`, billing for it is
  permanently blocked (fail-closed — we never charge for a row we cannot confirm was delivered), and that
  exact row is not recreated. The durable dataset can therefore hold a small number of orphan rows that
  were never mirrored and never billed. The **default dataset is the canonical log of rows successfully
  written to this run's output** (see its `run_summary` row) — but a default-dataset row does not by
  itself prove payment: the row is written *before* `Actor.charge()` runs, so if charging then fails or
  returns `charge_unknown`, the row exists but is not confirmably paid. **`run_summary.eventsBilled` and
  Apify's own billing ledger are the source of truth for confirmed payment**, not the presence of a row
  in either dataset.

- **Nothing is classified until the whole snapshot passes a fail-closed integrity gate.** Before a single
  watch is judged new/changed/removed, every run validates the entire download: the ZIP passes
  `testzip()` (no CRC corruption); it contains **exactly** the six expected members (`PILOT_BASIC.csv`,
  `PILOT_CERT.csv`, `NONPILOT_BASIC.csv`, `NONPILOT_CERT.csv`, `HelpComm.pdf`, `index_c.txt`) — not one
  more, not one fewer; each member's decompressed size and their total are under sane caps; every CSV
  decodes as **strict UTF-8 with no silent replacement of invalid bytes**; every header matches its
  expected column list exactly (a vanished or renamed column is caught here, distinctly from an empty
  value in one row); `UNIQUE ID` matches FAA's documented `^[AC][0-9]{7}$` and is **unique across the
  whole file** in each Basic file, with `(UNIQUE ID, TYPE)` unique across each Certificate file;
  required fields are non-empty; the medical field group is all-present-or-all-absent; ratings have no
  gaps; and each file's row count is above an absolute floor **and** not a material drop (more than 5%)
  from the last snapshot that itself passed this gate. Any failure ends the run as `source_access_limited`
  for **every** watch: no baseline is read or written, no removal or change event fires, and nobody is
  charged. This exists specifically so a syntactically valid but truncated archive (a cut-off download, a
  proxy returning a partial 200) cannot turn every tracked airman into a billable disappearance.

- **What was verified live and what was not.** On 2026-09-13 the September archive was downloaded and
  fully parsed: 592,700 pilot Basic rows, 682,411 pilot Certificate rows, 380,007 and 393,413 non-pilot
  rows; zero duplicate `UNIQUE ID`s; all four files strictly sorted by `UNIQUE ID`; Basic and Certificate
  ID sets identical within each family; 63,214 airmen present in both families with byte-identical Basic
  rows; every field's nullability counted individually; and the format claims above cross-checked against
  FAA's own `HelpComm.pdf` from inside the archive. The change signal itself was verified by the earlier
  diff of the August and September archives (3,617 new IDs, 2,769 gone, 708 `LEVEL` changes, 1,428 rating
  changes, 16,295 medical-date changes). What was **not** observed is a single named airman actually
  leaving the file between two runs of *this actor* — the source updates monthly and the build window was
  hours. The "disappearance = no active certificate or address opt-out" conclusion rests on FAA's own
  documented file contract, not on a watched live transition.

- **Non-US airmen have no `state`.** FAA leaves `STATE` blank for foreign addresses (16,979 of 592,700
  pilot rows live). `country` and `city` are always present.

- **Medical fields only exist for pilots.** `MED CLASS` / `MED DATE` / `MED EXP DATE` are populated only
  where the airman holds an active Certificate Type "P" (256,374 of 592,700 pilot rows live). An A\&P
  mechanic legitimately has none, and that absence is not a change.

- **This actor does not search by name.** You supply the `UNIQUE ID`. If you only have a name, look the
  airman up first — this actor deliberately provides no name-based discovery over a register of private
  individuals.

### Coverage and billing record

Every run writes a `coverage` record with per-watch `status`/`reason`/`matched`, the FAA release identity
actually used (`releaseId`, `releaseKey` — month plus the SHA-256 of the downloaded archive —
`noNewRelease`, `releaseAccepted`, `releaseNotAcceptedReason`), per-file snapshot row counts,
`removedFromReleasableFileCount` (confirmed, billed second-strike removals only),
`pendingRemovalCandidateCount` (unbilled first-strike candidates), and `recordsDelivered` /
`recordsBilled` — enough to reconcile every charge against every row. A watch that never matched (a typo,
or an ID FAA has never issued) is reported as `matched: false`, `status: "ok"` — not an error.

### What it does not do

- Does not emit names, street addresses or ZIP codes — ever (see Privacy above).
- Does not scrape any FAA web page; it uses FAA's own official monthly bulk export.
- Does not re-deliver a record that has not changed.
- Does not report an address change (deliberately excluded from the change hash).
- Does not cover certificate actions, enforcement or accident history — the releasable file contains none
  of that.
- Does not report revocations, suspensions or surrenders, and does not infer them from an absence.
- Does not claim to be the only change monitor for this file. See "Positioning, stated plainly" at the
  top.

# Actor input Schema

## `monitorId` (type: `string`):

Name of this monitor's durable history (a-z, 0-9, dash; up to 40 chars). Keep it stable — the change history and the once-only billing record live under it.

## `watches` (type: `array`):

1-200 objects: {"watchId": "captain-smith", "airmanId": "A0000014"}. airmanId is the FAA UNIQUE ID exactly as published in the Releasable Airmen file: the letter A or C followed by exactly 7 digits. One watch = one airman. New airmen can be added later under the same monitorId; each gets a free unbilled baseline on its first run.

## `notifyOn` (type: `string`):

new\_alerts — post the webhook only when paid changes were delivered; always — post it every run; never — do not call webhookUrl at all.

## `webhookUrl` (type: `string`):

Optional. Receives a digest of delivered (paid) changes as JSON. HTTPS only. Never contains names or home addresses.

## `probeOnly` (type: `boolean`):

When true, verifies network connectivity and HTTP availability of the official FAA Airmen registry via a lightweight range-request without downloading or parsing the 57MB archive. Emits a health-check receipt to the dataset and exits 0 in seconds.

## Actor input object example

```json
{
  "monitorId": "my-airmen-watch",
  "watches": [
    {
      "watchId": "example-airman",
      "airmanId": "A0000014"
    }
  ],
  "notifyOn": "new_alerts",
  "probeOnly": true
}
```

# Actor output Schema

## `results` (type: `string`):

Every row this run produced. Key fields: watchId, airmanId, changeType (new|record\_changed|removed\_from\_releasable\_file|restored|candidate\_removed), changedFields, certificates, certSummary, previousCertSummary, medClass, medDate, medExpDate, city, state, country, region, releaseId. changeType=removed\_from\_releasable\_file means the airman disappeared from the monthly FAA releasable file entirely, confirmed on two different monthly releases — per FAA's own documentation that means either no active certificate remains OR the airman elected to withhold address release; the file does not distinguish the two. Names, street addresses and ZIP codes are deliberately never emitted. Informational only — not a confirmation of current flight privileges.

## `coverage` (type: `string`):

What this run actually covered and what it charged for: per-watch status/reason/matched, records delivered and billed, removedFromReleasableFileCount, pendingRemovalCandidateCount, the FAA release identity used (releaseId/releaseKey), whether the run was skipped as noNewRelease, and per-file snapshot row counts. Enough to reconcile every charge against every row.

## `digest` (type: `string`):

A short human-readable summary of what this run found, written every run.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "monitorId": "my-airmen-watch",
    "watches": [
        {
            "watchId": "example-airman",
            "airmanId": "A0000014"
        }
    ],
    "notifyOn": "new_alerts",
    "probeOnly": true
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/faa-airmen-certificate-change-monitor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "monitorId": "my-airmen-watch",
    "watches": [{
            "watchId": "example-airman",
            "airmanId": "A0000014",
        }],
    "notifyOn": "new_alerts",
    "probeOnly": True,
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/faa-airmen-certificate-change-monitor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "monitorId": "my-airmen-watch",
  "watches": [
    {
      "watchId": "example-airman",
      "airmanId": "A0000014"
    }
  ],
  "notifyOn": "new_alerts",
  "probeOnly": true
}' |
apify call titan_coder/faa-airmen-certificate-change-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/faa-airmen-certificate-change-monitor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/T3rCIHnoofb2gzfxu/builds/EmXWaNpy3ZSLPiowN/openapi.json
