letsfg 2026.5.73 → 2026.5.74
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 +321 -283
- package/dist/{chunk-F5BBI6XX.mjs → chunk-GVKYPTY3.mjs} +80 -42
- package/dist/cli.js +79 -42
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +80 -32
- package/dist/index.d.ts +80 -32
- package/dist/index.js +81 -42
- package/dist/index.mjs +3 -1
- package/package.json +56 -56
package/README.md
CHANGED
|
@@ -1,283 +1,321 @@
|
|
|
1
|
-
# LetsFG — Your AI agent just learned to book flights. (Node.js)
|
|
2
|
-
|
|
3
|
-
**Server-side search engine. Real prices. One function call.** Search hundreds of airlines at raw airline prices — **$20–$50 cheaper** than Booking.com, Kayak, and other OTAs. Zero dependencies. Built for AI agents.
|
|
4
|
-
|
|
5
|
-
[](https://github.com/LetsFG/LetsFG)
|
|
6
|
-
[](https://www.npmjs.com/package/letsfg)
|
|
7
|
-
|
|
8
|
-
## Two ways to use LetsFG
|
|
9
|
-
|
|
10
|
-
| | **CLI / SDK** (this package) | **Developer API** |
|
|
11
|
-
|---|---|---|
|
|
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
|
-
|
|
17
|
-
> **Building a product, or need hotels?** Use the [Developer API](https://letsfg.co/developers) — look-to-book search (200 free after every booking, then $0.01), booking through `POST /flights/book`, no booking fee and no transaction fee.
|
|
18
|
-
|
|
19
|
-
## Install
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
npm install letsfg
|
|
23
|
-
```
|
|
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
|
-
|
|
44
|
-
## Quick Start (SDK)
|
|
45
|
-
|
|
46
|
-
```typescript
|
|
47
|
-
import { LetsFG, cheapestOffer, offerSummary } from 'letsfg';
|
|
48
|
-
|
|
49
|
-
// PFS — free. The card-backed token from the connect flow (see above).
|
|
50
|
-
const bt = new LetsFG({ bearerToken: 'eyJ...' });
|
|
51
|
-
|
|
52
|
-
// Search — FREE
|
|
53
|
-
const flights = await bt.search('GDN', 'BER', '2026-03-03');
|
|
54
|
-
const best = cheapestOffer(flights);
|
|
55
|
-
console.log(offerSummary(best));
|
|
56
|
-
|
|
57
|
-
// Book — no booking fee, no transaction fee — our margin is already in the price you saw; no unlock step. Starts the booking:
|
|
58
|
-
// the fare is HELD on your card and a LetsFG agent buys the ticket (4-11 min).
|
|
59
|
-
const result = await bt.book(
|
|
60
|
-
best.id,
|
|
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
|
-
}],
|
|
67
|
-
'john@example.com',
|
|
68
|
-
'',
|
|
69
|
-
'',
|
|
70
|
-
flights.search_id,
|
|
71
|
-
);
|
|
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' }
|
|
85
|
-
```
|
|
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
|
-
|
|
113
|
-
Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken` —
|
|
114
|
-
`search()`/`book()` dispatch automatically. That path needs a `searchId` (an offer
|
|
115
|
-
is bookable only inside the search that produced it) and has no unlock step:
|
|
116
|
-
`unlock()` was retired 2026-09-08, the route answers `410 Gone`, and there is no fee.
|
|
117
|
-
|
|
118
|
-
## Quick Start (CLI)
|
|
119
|
-
|
|
120
|
-
```bash
|
|
121
|
-
export LETSFG_BEARER_TOKEN=<your-bearer-token> # card-backed, from the connect flow
|
|
122
|
-
|
|
123
|
-
letsfg search GDN BER 2026-03-03 --sort price
|
|
124
|
-
letsfg search LON BCN 2026-04-01 --json # Machine-readable
|
|
125
|
-
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
|
|
126
|
-
# prints the booking_ref — poll POST /api/agent-book/status until completed
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
## API
|
|
130
|
-
|
|
131
|
-
### `new LetsFG({ bearerToken?, apiKey?, baseUrl?, timeout? })`
|
|
132
|
-
|
|
133
|
-
### `bt.search(origin, destination, dateFrom, options?)`
|
|
134
|
-
### `bt.resolveLocation(query)`
|
|
135
|
-
### `bt.book(offerId, passengers, contactEmail, contactPhone?, idempotencyKey?, searchId?)`
|
|
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
|
|
139
|
-
Developer API `book` (requires `searchId`, no unlock step, supports multiple
|
|
140
|
-
passengers and `idempotencyKey`; returns a `booking_id` to poll).
|
|
141
|
-
### `bt.getBooking(bookingId)` / `bt.answerBooking(...)` / `bt.bookAndWait(...)` — Developer API only
|
|
142
|
-
### `bt.connectPayment()` — Developer API only. Returns `connect_url`; nothing is charged
|
|
143
|
-
### `bt.setupPayment()` / `bt.unlock()` — **retired 2026-09-08, both throw locally**
|
|
144
|
-
### `bt.me()`
|
|
145
|
-
### `LetsFG.register(agentName, email, baseUrl?, ownerName?, description?)` — Developer API only, most agents don't need this
|
|
146
|
-
|
|
147
|
-
### Helpers
|
|
148
|
-
- `offerSummary(offer)` — One-line string summary
|
|
149
|
-
- `cheapestOffer(result)` — Get cheapest offer from search
|
|
150
|
-
|
|
151
|
-
## Starlink Wi-Fi
|
|
152
|
-
|
|
153
|
-
Offers may carry `starlink`: `confirmed_all` / `confirmed_some` mean the carrier
|
|
154
|
-
has **fully** fitted that aircraft type; `likely_all` / `likely_some` mean the
|
|
155
|
-
rollout on that type is underway but incomplete. Segments carry `confirmed` or
|
|
156
|
-
`likely`.
|
|
157
|
-
|
|
158
|
-
Only `confirmed_*` is safe to state as fact — `likely_*` is a signal, not a
|
|
159
|
-
promise. Anything ending `_some` has at least one leg without it. An **absent**
|
|
160
|
-
field means no information, **not** an absence of Wi-Fi.
|
|
161
|
-
|
|
162
|
-
Full semantics: [docs/api-search.md](https://github.com/LetsFG/LetsFG/blob/main/docs/api-search.md#starlink-wi-fi).
|
|
163
|
-
|
|
164
|
-
## Zero Dependencies
|
|
165
|
-
|
|
166
|
-
Uses native `fetch` (Node 18+). No `axios`, no `node-fetch`, nothing. Safe for sandboxed environments.
|
|
167
|
-
|
|
168
|
-
## Also Available As
|
|
169
|
-
|
|
170
|
-
- **MCP Server**: `npx letsfg-mcp` — [npm](https://www.npmjs.com/package/letsfg-mcp)
|
|
171
|
-
- **Python SDK + CLI**: `pip install letsfg` — [PyPI](https://pypi.org/project/letsfg/)
|
|
172
|
-
- **Try without installing**: [letsfg.co](https://letsfg.co) — search instantly in your browser
|
|
173
|
-
- **GitHub**: [LetsFG/LetsFG](https://github.com/LetsFG/LetsFG)
|
|
174
|
-
|
|
175
|
-
> ⭐ **[Star the repo](https://github.com/LetsFG/LetsFG)** — we appreciate the support.
|
|
176
|
-
|
|
177
|
-
## License
|
|
178
|
-
|
|
179
|
-
MIT
|
|
180
|
-
|
|
181
|
-
## 🏨 Hotels — new, and live
|
|
182
|
-
|
|
183
|
-
Your agent can book hotels as well as flights. Same card-backed token or API key, same
|
|
184
|
-
|
|
185
|
-
```python
|
|
186
|
-
from letsfg import LetsFG
|
|
187
|
-
lfg = LetsFG()
|
|
188
|
-
|
|
189
|
-
city = lfg.hotel_destinations("Warsaw")[0]
|
|
190
|
-
stays = lfg.search_hotels(
|
|
191
|
-
city_id=city["Id"], city_name=city["Name"],
|
|
192
|
-
check_in="2026-11-10", check_out="2026-11-12", adults=2,
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
hotel = stays["hotels"][0]
|
|
196
|
-
offer = hotel["offers"][0]
|
|
197
|
-
print(hotel["name"], offer["price"],
|
|
198
|
-
#
|
|
199
|
-
|
|
200
|
-
booking = lfg.book_hotel_and_wait(
|
|
201
|
-
session_id=
|
|
202
|
-
hotel_code=hotel["hotel_code"],
|
|
203
|
-
combination_id_v2=offer["combination_id_v2"],
|
|
204
|
-
expected_price=offer["price"],
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
**
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
is
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
1
|
+
# LetsFG — Your AI agent just learned to book flights. (Node.js)
|
|
2
|
+
|
|
3
|
+
**Server-side search engine. Real prices. One function call.** Search hundreds of airlines at raw airline prices — **$20–$50 cheaper** than Booking.com, Kayak, and other OTAs. Zero dependencies. Built for AI agents.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/LetsFG/LetsFG)
|
|
6
|
+
[](https://www.npmjs.com/package/letsfg)
|
|
7
|
+
|
|
8
|
+
## Two ways to use LetsFG
|
|
9
|
+
|
|
10
|
+
| | **CLI / SDK** (this package) | **Developer API** |
|
|
11
|
+
|---|---|---|
|
|
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
|
+
|
|
17
|
+
> **Building a product, or need hotels?** Use the [Developer API](https://letsfg.co/developers) — look-to-book search (200 free after every booking, then $0.01), booking through `POST /flights/book`, no booking fee and no transaction fee.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install letsfg
|
|
23
|
+
```
|
|
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
|
+
|
|
44
|
+
## Quick Start (SDK)
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { LetsFG, cheapestOffer, offerSummary } from 'letsfg';
|
|
48
|
+
|
|
49
|
+
// PFS — free. The card-backed token from the connect flow (see above).
|
|
50
|
+
const bt = new LetsFG({ bearerToken: 'eyJ...' });
|
|
51
|
+
|
|
52
|
+
// Search — FREE
|
|
53
|
+
const flights = await bt.search('GDN', 'BER', '2026-03-03');
|
|
54
|
+
const best = cheapestOffer(flights);
|
|
55
|
+
console.log(offerSummary(best));
|
|
56
|
+
|
|
57
|
+
// Book — no booking fee, no transaction fee — our margin is already in the price you saw; no unlock step. Starts the booking:
|
|
58
|
+
// the fare is HELD on your card and a LetsFG agent buys the ticket (4-11 min).
|
|
59
|
+
const result = await bt.book(
|
|
60
|
+
best.id,
|
|
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
|
+
}],
|
|
67
|
+
'john@example.com',
|
|
68
|
+
'',
|
|
69
|
+
'',
|
|
70
|
+
flights.search_id,
|
|
71
|
+
);
|
|
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' }
|
|
85
|
+
```
|
|
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
|
+
|
|
113
|
+
Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken` —
|
|
114
|
+
`search()`/`book()` dispatch automatically. That path needs a `searchId` (an offer
|
|
115
|
+
is bookable only inside the search that produced it) and has no unlock step:
|
|
116
|
+
`unlock()` was retired 2026-09-08, the route answers `410 Gone`, and there is no fee.
|
|
117
|
+
|
|
118
|
+
## Quick Start (CLI)
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
export LETSFG_BEARER_TOKEN=<your-bearer-token> # card-backed, from the connect flow
|
|
122
|
+
|
|
123
|
+
letsfg search GDN BER 2026-03-03 --sort price
|
|
124
|
+
letsfg search LON BCN 2026-04-01 --json # Machine-readable
|
|
125
|
+
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
|
|
126
|
+
# prints the booking_ref — poll POST /api/agent-book/status until completed
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## API
|
|
130
|
+
|
|
131
|
+
### `new LetsFG({ bearerToken?, apiKey?, baseUrl?, timeout? })`
|
|
132
|
+
|
|
133
|
+
### `bt.search(origin, destination, dateFrom, options?)`
|
|
134
|
+
### `bt.resolveLocation(query)`
|
|
135
|
+
### `bt.book(offerId, passengers, contactEmail, contactPhone?, idempotencyKey?, searchId?)`
|
|
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
|
|
139
|
+
Developer API `book` (requires `searchId`, no unlock step, supports multiple
|
|
140
|
+
passengers and `idempotencyKey`; returns a `booking_id` to poll).
|
|
141
|
+
### `bt.getBooking(bookingId)` / `bt.answerBooking(...)` / `bt.bookAndWait(...)` — Developer API only
|
|
142
|
+
### `bt.connectPayment()` — Developer API only. Returns `connect_url`; nothing is charged
|
|
143
|
+
### `bt.setupPayment()` / `bt.unlock()` — **retired 2026-09-08, both throw locally**
|
|
144
|
+
### `bt.me()`
|
|
145
|
+
### `LetsFG.register(agentName, email, baseUrl?, ownerName?, description?)` — Developer API only, most agents don't need this
|
|
146
|
+
|
|
147
|
+
### Helpers
|
|
148
|
+
- `offerSummary(offer)` — One-line string summary
|
|
149
|
+
- `cheapestOffer(result)` — Get cheapest offer from search
|
|
150
|
+
|
|
151
|
+
## Starlink Wi-Fi
|
|
152
|
+
|
|
153
|
+
Offers may carry `starlink`: `confirmed_all` / `confirmed_some` mean the carrier
|
|
154
|
+
has **fully** fitted that aircraft type; `likely_all` / `likely_some` mean the
|
|
155
|
+
rollout on that type is underway but incomplete. Segments carry `confirmed` or
|
|
156
|
+
`likely`.
|
|
157
|
+
|
|
158
|
+
Only `confirmed_*` is safe to state as fact — `likely_*` is a signal, not a
|
|
159
|
+
promise. Anything ending `_some` has at least one leg without it. An **absent**
|
|
160
|
+
field means no information, **not** an absence of Wi-Fi.
|
|
161
|
+
|
|
162
|
+
Full semantics: [docs/api-search.md](https://github.com/LetsFG/LetsFG/blob/main/docs/api-search.md#starlink-wi-fi).
|
|
163
|
+
|
|
164
|
+
## Zero Dependencies
|
|
165
|
+
|
|
166
|
+
Uses native `fetch` (Node 18+). No `axios`, no `node-fetch`, nothing. Safe for sandboxed environments.
|
|
167
|
+
|
|
168
|
+
## Also Available As
|
|
169
|
+
|
|
170
|
+
- **MCP Server**: `npx letsfg-mcp` — [npm](https://www.npmjs.com/package/letsfg-mcp)
|
|
171
|
+
- **Python SDK + CLI**: `pip install letsfg` — [PyPI](https://pypi.org/project/letsfg/)
|
|
172
|
+
- **Try without installing**: [letsfg.co](https://letsfg.co) — search instantly in your browser
|
|
173
|
+
- **GitHub**: [LetsFG/LetsFG](https://github.com/LetsFG/LetsFG)
|
|
174
|
+
|
|
175
|
+
> ⭐ **[Star the repo](https://github.com/LetsFG/LetsFG)** — we appreciate the support.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
|
180
|
+
|
|
181
|
+
## 🏨 Hotels — new, and live
|
|
182
|
+
|
|
183
|
+
Your agent can book hotels as well as flights. Same card-backed token or API key, same connected payment method.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from letsfg import LetsFG
|
|
187
|
+
lfg = LetsFG()
|
|
188
|
+
|
|
189
|
+
city = lfg.hotel_destinations("Warsaw")[0]
|
|
190
|
+
stays = lfg.search_hotels(
|
|
191
|
+
city_id=city["Id"], city_name=city["Name"],
|
|
192
|
+
check_in="2026-11-10", check_out="2026-11-12", adults=2,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
hotel = stays["hotels"][0]
|
|
196
|
+
offer = hotel["offers"][0]
|
|
197
|
+
print(hotel["name"], offer["price"], offer["currency"], offer["refundable"])
|
|
198
|
+
# prices are in stays["currency"]: USD unless you pass currency=
|
|
199
|
+
|
|
200
|
+
booking = lfg.book_hotel_and_wait(
|
|
201
|
+
session_id=offer["session_id"],
|
|
202
|
+
hotel_code=hotel["hotel_code"],
|
|
203
|
+
combination_id_v2=offer["combination_id_v2"],
|
|
204
|
+
expected_price=offer["price"], # copy these four from the offer, verbatim
|
|
205
|
+
expected_cost=offer["expected_cost"],
|
|
206
|
+
currency=offer["currency"],
|
|
207
|
+
fx_rate=offer["fx_rate"],
|
|
208
|
+
city_id=city["Id"], city_name=city["Name"],
|
|
209
|
+
check_in="2026-11-10", check_out="2026-11-12",
|
|
210
|
+
# ONE entry per guest in the room, children included: adults first, then children in child_ages order
|
|
211
|
+
guests=[{"title": "Mr", "first_name": "Jan", "last_name": "Kowalski"},
|
|
212
|
+
{"title": "Mrs", "first_name": "Anna", "last_name": "Kowalska"}],
|
|
213
|
+
email="GUEST_EMAIL", phone="512345678", # the guest's real e-mail and phone
|
|
214
|
+
)
|
|
215
|
+
if booking["status"] == "succeeded":
|
|
216
|
+
print(booking["confirmation"], booking["total_price"], booking["currency"])
|
|
217
|
+
elif booking["status"] == "failed":
|
|
218
|
+
print("Not booked, nothing charged:", booking["error"])
|
|
219
|
+
else: # "attention": a person is checking it with the supplier, the hold is kept. Do not book again.
|
|
220
|
+
print(booking["status"], booking.get("error"))
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### How you pay
|
|
224
|
+
|
|
225
|
+
**The full price is held, and taken only once the hotel confirms.** Booking holds the offer's
|
|
226
|
+
`price` on the Revolut payment method connected to your account — authorised, not charged.
|
|
227
|
+
LetsFG then books the room with the supplier and pays the supplier itself. The hold is captured
|
|
228
|
+
only after the supplier has confirmed the booking; if the booking fails for any reason (the rate
|
|
229
|
+
is gone, the price moved, the supplier declined, a guest detail was rejected), the hold is
|
|
230
|
+
released and nothing is charged.
|
|
231
|
+
|
|
232
|
+
There is **no reservation fee, no deposit and no pay link**, and the guest owes the hotel nothing
|
|
233
|
+
further. (Those belonged to the process retired on 2026-09-11.)
|
|
234
|
+
|
|
235
|
+
`price` is the supplier's cost plus 6.4% (our margin and payment processing) for Revolut Pay or a
|
|
236
|
+
card issued in the EEA, or 8.3% for a card issued outside the EEA — the search response's
|
|
237
|
+
`markup_rate` says which. Nothing is added at booking. Prices are in the currency you search in:
|
|
238
|
+
USD unless you ask for another.
|
|
239
|
+
|
|
240
|
+
Cancelling a refundable rate before its `free_cancellation_until` costs nothing and refunds the
|
|
241
|
+
charge in full. A cancellation that would cost money is refused (409); the hotel's own ladder is in
|
|
242
|
+
the booking's `terms`.
|
|
243
|
+
|
|
244
|
+
### What search costs
|
|
245
|
+
|
|
246
|
+
Search is metered separately from booking, on **either** auth path (free PFS
|
|
247
|
+
Bearer token or Developer API key — both count against the same agent):
|
|
248
|
+
**the first 1,000 `search_hotels` calls since your last hotel booking are
|
|
249
|
+
free.** Past that, searches are billed in blocks of 1,000 for **$5**
|
|
250
|
+
(~$0.005/search) from your prepaid balance — refused with a 402 if the
|
|
251
|
+
balance can't cover the next block, never silently allowed. Book a hotel
|
|
252
|
+
and the count resets to zero. Resolving a city name (`hotel_destinations`)
|
|
253
|
+
is not metered, only the search call itself.
|
|
254
|
+
|
|
255
|
+
### Things worth knowing before you build
|
|
256
|
+
|
|
257
|
+
- **A connected payment method is required for every hotel call, including search.** A hotel
|
|
258
|
+
search opens a real session at the supplier and booking blocks a real rate, so we refuse up
|
|
259
|
+
front rather than let you reach the point of commitment and discover you cannot pay. The same
|
|
260
|
+
method authorises flights and hotels — there is no separate hotel signup.
|
|
261
|
+
- **Every rate type is sold**, refundable and non-refundable. Each offer's `refundable` and
|
|
262
|
+
`free_cancellation_until` say which one you are buying.
|
|
263
|
+
- **Booking is asynchronous.** `book_hotel` returns a `booking_job_id`, not a booking — the real
|
|
264
|
+
thing takes minutes. Poll `hotel_booking(job_id)` every ~20 s until `status` is `succeeded`,
|
|
265
|
+
`failed` or `attention`, or call `book_hotel_and_wait` and let the SDK do it. All three are final:
|
|
266
|
+
- `succeeded` — `confirmation`, `total_price` + `currency` (what the guest is charged),
|
|
267
|
+
`supplier_paid` + `supplier_currency`, `refundable`, `free_cancellation_until`,
|
|
268
|
+
`cancellation_ladder` and `terms`.
|
|
269
|
+
- `failed` — `error`, written for the guest. The hold has been released; nothing was charged.
|
|
270
|
+
- `attention` — the outcome could not be settled automatically. The hold is kept (nothing is
|
|
271
|
+
charged) while a person checks with the supplier. Do not book again.
|
|
272
|
+
- **Copy the offer verbatim.** Send `expected_price` (the offer's `price`), `expected_cost`,
|
|
273
|
+
`currency` and `fx_rate` exactly as search returned them. A mis-copied price, or a USD offer sent
|
|
274
|
+
without its `currency`, is refused with `400 price_mismatch` before anything is held.
|
|
275
|
+
- **Guest details are checked before anything is held**: Latin-script names, a phone number valid
|
|
276
|
+
for its country code, and an e-mail. A problem returns `400 invalid_details` naming the fields.
|
|
277
|
+
- **One name per guest in the room, children included.** For a family, search with `children` and
|
|
278
|
+
`child_ages`; the party travels with the offer's session. `guests` then lists every guest — adults
|
|
279
|
+
first, then children in `child_ages` order. The hotel requires a name for every guest: fewer names
|
|
280
|
+
than guests is refused before anything is submitted, and the hold is released.
|
|
281
|
+
- **The guest is e-mailed however it ends**: a confirmation with the code and the cancellation
|
|
282
|
+
term, a note that it did not go through and nothing was charged, or a note that it is being
|
|
283
|
+
confirmed with the supplier.
|
|
284
|
+
- **A retry never books twice.** A second `book_hotel` for the same rate and guest returns the job
|
|
285
|
+
already under way (`duplicate: true`); pass `idempotency_key` to make that explicit. Still, never
|
|
286
|
+
re-post a booking whose job is running — poll it.
|
|
287
|
+
|
|
288
|
+
### JavaScript
|
|
289
|
+
|
|
290
|
+
```javascript
|
|
291
|
+
import { LetsFG } from 'letsfg';
|
|
292
|
+
const lfg = new LetsFG({ apiKey: process.env.LETSFG_API_KEY });
|
|
293
|
+
|
|
294
|
+
const [city] = await lfg.hotelDestinations('Warsaw');
|
|
295
|
+
const stays = await lfg.searchHotels({
|
|
296
|
+
cityId: city.Id, cityName: city.Name,
|
|
297
|
+
checkIn: '2026-11-10', checkOut: '2026-11-12', adults: 2,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const hotel = stays.hotels[0];
|
|
301
|
+
const offer = hotel.offers[0];
|
|
302
|
+
const booking = await lfg.bookHotelAndWait({
|
|
303
|
+
sessionId: offer.session_id, hotelCode: hotel.hotel_code,
|
|
304
|
+
combinationIdV2: offer.combination_id_v2,
|
|
305
|
+
expectedPrice: offer.price, expectedCost: offer.expected_cost,
|
|
306
|
+
currency: offer.currency, fxRate: offer.fx_rate,
|
|
307
|
+
cityId: city.Id, cityName: city.Name,
|
|
308
|
+
checkIn: '2026-11-10', checkOut: '2026-11-12',
|
|
309
|
+
// ONE entry per guest in the room, children included: adults first, then children in childAges order
|
|
310
|
+
guests: [{ title: 'Mr', first_name: 'Jan', last_name: 'Kowalski' },
|
|
311
|
+
{ title: 'Mrs', first_name: 'Anna', last_name: 'Kowalska' }],
|
|
312
|
+
email: 'GUEST_EMAIL', phone: '512345678',
|
|
313
|
+
});
|
|
314
|
+
console.log(booking.status, booking.confirmation, booking.total_price, booking.currency);
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### MCP
|
|
318
|
+
|
|
319
|
+
Five new tools, in the order you call them: `resolve_hotel_city` →
|
|
320
|
+
`search_hotels` → `book_hotel` → `get_hotel_booking` → `cancel_hotel_booking`.
|
|
321
|
+
|