letsfg 2026.5.70 → 2026.5.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,10 +9,10 @@
9
9
 
10
10
  | | **CLI / SDK** (this package) | **Developer API** |
11
11
  |---|---|---|
12
- | **Search cost** | Free (Bearer token via `letsfg auth` — zero-amount card setup) | Prepaid credits |
13
- | **Booking** | `POST /api/agent-book` — confirmed order or a booking link, no LetsFG fee | Direct airline URL (unlock required first) |
14
- | **Speed** | 60–90 s | 2–5 s (discover) · 60–90 s (full) |
15
- | **Setup** | `npm install letsfg` then `letsfg auth` | [letsfg.co/developers](https://letsfg.co/developers) |
12
+ | **Search cost** | Free (card-backed token from [letsfg.co/connect](https://letsfg.co/connect), nothing charged) | Prepaid credits |
13
+ | **Booking** | `POST /api/agent-book` — fare held on your card, a LetsFG agent buys the ticket, captured only on a real PNR. Every offer. | Direct airline URL (unlock required first) |
14
+ | **Speed** | 8–10 s to first results; longer on a split | 2–5 s (discover) · 8–10 s to first results (full) |
15
+ | **Setup** | `npm install letsfg`, then connect at [letsfg.co/developers/api/mcp](https://letsfg.co/developers/api/mcp) | [letsfg.co/developers](https://letsfg.co/developers) |
16
16
 
17
17
  > **Want direct airline URLs without any per-booking fee?** Use the [Developer API](https://letsfg.co/developers) — prepaid credits, results in seconds, no checkout step.
18
18
 
@@ -22,13 +22,31 @@
22
22
  npm install letsfg
23
23
  ```
24
24
 
25
+ ## Getting a token
26
+
27
+ Connect LetsFG as an MCP server at `https://letsfg.co/developers/api/mcp` and
28
+ approve the connection — in Claude, ChatGPT, Cursor, Windsurf, or Claude Code
29
+ (`claude mcp add --transport http letsfg https://letsfg.co/developers/api/mcp`).
30
+ The consent step opens [letsfg.co/connect](https://letsfg.co/connect), where you
31
+ add a card (any card, or Revolut Pay / Google Pay) in a 0.00 Revolut setup.
32
+ Nothing is charged, no Revolut account is needed, and the card details go to
33
+ Revolut, never to LetsFG. The token you get back is card-backed: it searches
34
+ and it books. One card = one account; quotas are per card (10 searches per
35
+ 10 min, 30 per hour, 100 per day — polling never counts).
36
+
37
+ Pass it as `bearerToken`, or set `LETSFG_BEARER_TOKEN` for the CLI.
38
+
39
+ > `letsfg auth` still runs the Stripe card setup that was retired on
40
+ > 2026-09-02 and cannot get a token today; every token issued that way was
41
+ > revoked (401 `TOKEN_REVOKED`). A connect-flow login for the CLI and SDKs is
42
+ > coming — until then, connect through the MCP.
43
+
25
44
  ## Quick Start (SDK)
26
45
 
27
46
  ```typescript
28
47
  import { LetsFG, cheapestOffer, offerSummary } from 'letsfg';
29
48
 
30
- // PFS — free. Get a Bearer token once with `letsfg auth` (zero-amount card
31
- // setup, nothing charged), then pass it here.
49
+ // PFS — free. The card-backed token from the connect flow (see above).
32
50
  const bt = new LetsFG({ bearerToken: 'eyJ...' });
33
51
 
34
52
  // Search — FREE
@@ -36,22 +54,62 @@ const flights = await bt.search('GDN', 'BER', '2026-03-03');
36
54
  const best = cheapestOffer(flights);
37
55
  console.log(offerSummary(best));
38
56
 
39
- // Book — free, ticket price only, no LetsFG fee. No unlock step.
57
+ // Book — ticket price only, no LetsFG fee, no unlock step. Starts the booking:
58
+ // the fare is HELD on your card and a LetsFG agent buys the ticket (4-11 min).
40
59
  const result = await bt.book(
41
60
  best.id,
42
- [{ given_name: 'John', family_name: 'Doe', born_on: '1990-01-15', gender: 'm' }],
61
+ [{
62
+ given_name: 'John', family_name: 'Doe', born_on: '1990-01-15', gender: 'm',
63
+ nationality: 'GB', phone_number: '+447700900123', phone_country: 'GB',
64
+ address_line1: '1 Analytical Way', address_city: 'London',
65
+ address_postal: 'N1 9GU', address_country: 'GB',
66
+ }],
43
67
  'john@example.com',
44
68
  '',
45
69
  '',
46
70
  flights.search_id,
47
71
  );
48
- if (result.booked) {
49
- console.log(`Order: ${result.order_id}`);
50
- } else {
51
- console.log(`Booking link (nothing charged): ${result.booking_url}`);
52
- }
72
+ const bookingRef = result.booking_ref as string;
73
+
74
+ // Poll until it lands (every 20-30 s): completed | failed | needs_attention
75
+ let status: Record<string, unknown>;
76
+ do {
77
+ await new Promise(r => setTimeout(r, 25_000));
78
+ status = await (await fetch('https://letsfg.co/api/agent-book/status', {
79
+ method: 'POST',
80
+ headers: { Authorization: 'Bearer eyJ...', 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ booking_ref: bookingRef }),
82
+ })).json();
83
+ } while (status.state === 'booking_in_progress');
84
+ console.log(status); // { state: 'completed', pnr: 'ABC123', charged_amount: 93, currency: 'EUR' }
53
85
  ```
54
86
 
87
+ ### How booking works
88
+
89
+ `bt.book()` posts to `POST /api/agent-book` and does exactly what the website
90
+ checkout does: the fare plus LetsFG's markup is **held** on the connected card
91
+ (not taken), a LetsFG booking agent buys the ticket from the seller, and the
92
+ hold is captured only once a real airline PNR exists. If the booking fails the
93
+ hold is released and nothing is charged. Every offer can be booked this way —
94
+ no unlock step, no booking-link fallback, no separate LetsFG fee.
95
+
96
+ The call returns within seconds with a `booking_ref`; the booking itself takes
97
+ 4–11 minutes. Poll `POST /api/agent-book/status` with `{"booking_ref": ...}`
98
+ every 20–30 s (the SDK has no helper for this yet):
99
+
100
+ | `state` | Meaning |
101
+ |---|---|
102
+ | `booking_in_progress` | the agent is at the seller's checkout — keep waiting |
103
+ | `completed` | booked — `pnr`, `charged_amount`, `currency` are in the answer |
104
+ | `failed` | not booked — the hold was released, nothing charged; see `failure_reason` |
105
+ | `needs_attention` | a human at LetsFG is checking it — do **not** book again |
106
+
107
+ One traveller per call, with the details an airline checkout asks for: name,
108
+ date of birth, gender, nationality, email, phone with its country, residence
109
+ address (passport optional). A missing detail returns `missing_details` with
110
+ `missing_fields` and charges nothing. Never start a second booking for the
111
+ same trip while one is in progress — that would place a second hold.
112
+
55
113
  Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken` —
56
114
  `search()`/`book()` dispatch automatically. That path requires `unlock()`
57
115
  (1% fee, min $3) before `book()`.
@@ -59,11 +117,12 @@ Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken`
59
117
  ## Quick Start (CLI)
60
118
 
61
119
  ```bash
62
- export LETSFG_BEARER_TOKEN=<your-bearer-token> # from `letsfg auth`
120
+ export LETSFG_BEARER_TOKEN=<your-bearer-token> # card-backed, from the connect flow
63
121
 
64
122
  letsfg search GDN BER 2026-03-03 --sort price
65
123
  letsfg search LON BCN 2026-04-01 --json # Machine-readable
66
- letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":"Doe","born_on":"1990-01-15","gender":"m"}' -e john@example.com
124
+ letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":"Doe","born_on":"1990-01-15","gender":"m","nationality":"GB","phone_number":"+447700900123","phone_country":"GB","address_line1":"1 Analytical Way","address_city":"London","address_postal":"N1 9GU","address_country":"GB"}' -e john@example.com
125
+ # prints the booking_ref — poll POST /api/agent-book/status until completed
67
126
  ```
68
127
 
69
128
  ## API
@@ -74,8 +133,9 @@ letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":
74
133
  ### `bt.resolveLocation(query)`
75
134
  ### `bt.unlock(offerId)` — Developer API only
76
135
  ### `bt.book(offerId, passengers, contactEmail, contactPhone?, idempotencyKey?, searchId?)`
77
- Dispatches on which credential is set: `bearerToken` → free PFS booking via
78
- `POST /api/agent-book` (pass `searchId`, one passenger). `apiKey` → paid
136
+ Dispatches on which credential is set: `bearerToken` → PFS booking via
137
+ `POST /api/agent-book` (pass `searchId`, one passenger with full details;
138
+ returns `booking_ref` — poll `POST /api/agent-book/status`). `apiKey` → paid
79
139
  Developer API `book` (requires `unlock()` first, supports multiple passengers
80
140
  and `idempotencyKey`).
81
141
  ### `bt.setupPayment(token?)` — Developer API only
@@ -118,7 +178,7 @@ MIT
118
178
 
119
179
  ## 🏨 Hotels — new, and live
120
180
 
121
- Your agent can now book hotels, not just flights. Same API key, same card on file.
181
+ Your agent can book hotels as well as flights. Same card-backed token or API key, same card on file.
122
182
 
123
183
  ```python
124
184
  from letsfg import LetsFG
@@ -1558,8 +1558,12 @@ function cheapestOffer(result) {
1558
1558
  return result.offers.reduce((min, o) => o.price < min.price ? o : min, result.offers[0]);
1559
1559
  }
1560
1560
  var DEFAULT_BASE_URL = "https://letsfg.co";
1561
- var PFS_POLL_INTERVAL_MS = 1e4;
1561
+ var PFS_POLL_INTERVAL_MS = 2e3;
1562
1562
  var PFS_POLL_TIMEOUT_MS = 12e4;
1563
+ var LATE_MERGE_POLL_MS = 3e3;
1564
+ var LATE_MERGE_GRACE_MS = 9e4;
1565
+ var WAIT_FOR_SPLIT = (process.env.LETSFG_WAIT_FOR_SPLIT || "").trim() !== "0";
1566
+ var NON_TERMINAL = ["pending", "searching"];
1563
1567
  var LetsFG = class {
1564
1568
  bearerToken;
1565
1569
  apiKey;
@@ -1595,7 +1599,7 @@ var LetsFG = class {
1595
1599
  *
1596
1600
  * Uses PFS (Bearer token) or Developer API (X-API-Key) depending on config.
1597
1601
  * PFS: async polling (POST /api/search -> poll /api/results/<id> every 10s).
1598
- * Developer API: synchronous 60-90s call.
1602
+ * Developer API: synchronous call.
1599
1603
  *
1600
1604
  * @param origin - IATA code (e.g., "GDN", "LON")
1601
1605
  * @param destination - IATA code (e.g., "BER", "BCN")
@@ -1627,17 +1631,28 @@ var LetsFG = class {
1627
1631
  /** PFS path: POST /api/search -> poll /api/results/<id> */
1628
1632
  async searchPFS(body) {
1629
1633
  const { search_id } = await this.postWithBearer("/api/search", body);
1634
+ const poll = () => this.getNoAuth(`/api/results/${search_id}`);
1635
+ const inbound = (r) => Boolean(r.split_ticket_pending || r.gf_enrich_pending);
1630
1636
  const deadline = Date.now() + PFS_POLL_TIMEOUT_MS;
1637
+ let terminal = null;
1631
1638
  while (Date.now() < deadline) {
1632
- await new Promise((r) => setTimeout(r, PFS_POLL_INTERVAL_MS));
1633
- const result = await this.getNoAuth(
1634
- `/api/results/${search_id}`
1635
- );
1636
- if (!["pending", "searching"].includes(result.status)) {
1637
- return result;
1639
+ const result = await poll();
1640
+ if (!NON_TERMINAL.includes(result.status)) {
1641
+ terminal = result;
1642
+ break;
1638
1643
  }
1644
+ await new Promise((r) => setTimeout(r, PFS_POLL_INTERVAL_MS));
1645
+ }
1646
+ if (!terminal) {
1647
+ throw new LetsFGError("Search timed out after 120s. Try polling /api/results/<id> directly.", 504);
1648
+ }
1649
+ const lateDeadline = Date.now() + LATE_MERGE_GRACE_MS;
1650
+ while (WAIT_FOR_SPLIT && inbound(terminal) && Date.now() < lateDeadline) {
1651
+ await new Promise((r) => setTimeout(r, LATE_MERGE_POLL_MS));
1652
+ const merged = await poll();
1653
+ if (!NON_TERMINAL.includes(merged.status)) terminal = merged;
1639
1654
  }
1640
- throw new LetsFGError("Search timed out after 120s. Try polling /api/results/<id> directly.", 504);
1655
+ return terminal;
1641
1656
  }
1642
1657
  /**
1643
1658
  * Resolve a city/airport name to IATA codes.
@@ -1657,8 +1672,8 @@ var LetsFG = class {
1657
1672
  }
1658
1673
  /**
1659
1674
  * Unlock a flight offer — confirms live price, reveals direct airline booking URL.
1660
- * Cost: 1% of ticket price, min $3. Developer API only — there is no unlock
1661
- * endpoint on a PFS Bearer token, so PFS callers use book() directly.
1675
+ * Developer API only, legacy — there is no unlock endpoint on a PFS Bearer
1676
+ * token, so PFS callers use book() directly.
1662
1677
  */
1663
1678
  async unlock(offerId) {
1664
1679
  this.requireApiKey();