letsfg 2026.5.71 → 2026.5.73
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 +283 -221
- package/dist/{chunk-GO3FXQXC.mjs → chunk-F5BBI6XX.mjs} +134 -20
- package/dist/cli.js +381 -216
- package/dist/cli.mjs +248 -61
- package/dist/index.d.mts +92 -14
- package/dist/index.d.ts +92 -14
- package/dist/index.js +134 -20
- package/dist/index.mjs +1 -1
- package/package.json +56 -56
- package/dist/auth-SYZ5AYZ6.mjs +0 -16
- package/dist/chunk-XNO2W77S.mjs +0 -126
package/dist/cli.mjs
CHANGED
|
@@ -3,12 +3,213 @@ import {
|
|
|
3
3
|
LetsFG,
|
|
4
4
|
LetsFGError,
|
|
5
5
|
offerSummary
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "
|
|
6
|
+
} from "./chunk-F5BBI6XX.mjs";
|
|
7
|
+
|
|
8
|
+
// src/auth.ts
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "fs";
|
|
10
|
+
import { homedir, platform } from "os";
|
|
11
|
+
import { join, dirname } from "path";
|
|
12
|
+
import { createHash, randomBytes } from "crypto";
|
|
13
|
+
import { createServer } from "http";
|
|
14
|
+
var BASE_URL = process.env.LETSFG_BASE_URL || "https://letsfg.co";
|
|
15
|
+
var DEV_ROOT = `${BASE_URL}/developers/api`;
|
|
16
|
+
var FALLBACK_TTL_MS = 55 * 60 * 1e3;
|
|
17
|
+
var REFRESH_SKEW_MS = 5 * 60 * 1e3;
|
|
18
|
+
var BearerTokenError = class extends Error {
|
|
19
|
+
};
|
|
20
|
+
function configPath() {
|
|
21
|
+
const base = platform() === "win32" ? process.env.APPDATA || homedir() : homedir();
|
|
22
|
+
return join(base, ".letsfg", "config.json");
|
|
23
|
+
}
|
|
24
|
+
function loadConfig() {
|
|
25
|
+
const p = configPath();
|
|
26
|
+
if (!existsSync(p)) return {};
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function saveConfig(cfg) {
|
|
34
|
+
const p = configPath();
|
|
35
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
36
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2));
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(p, 384);
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function saveToken(token, expiresAt, extra) {
|
|
43
|
+
const cfg = loadConfig();
|
|
44
|
+
cfg.pfs_auth = {
|
|
45
|
+
...cfg.pfs_auth ?? {},
|
|
46
|
+
...extra ?? {},
|
|
47
|
+
token,
|
|
48
|
+
expires_at: expiresAt ?? Date.now() + FALLBACK_TTL_MS
|
|
49
|
+
};
|
|
50
|
+
saveConfig(cfg);
|
|
51
|
+
}
|
|
52
|
+
function getBearerToken() {
|
|
53
|
+
const env = process.env.LETSFG_BEARER_TOKEN;
|
|
54
|
+
if (env) return env;
|
|
55
|
+
const auth = loadConfig().pfs_auth;
|
|
56
|
+
if (auth?.token && Date.now() < auth.expires_at - REFRESH_SKEW_MS) {
|
|
57
|
+
return auth.token;
|
|
58
|
+
}
|
|
59
|
+
if (auth?.refresh_token) {
|
|
60
|
+
throw new BearerTokenError(
|
|
61
|
+
"LetsFG token expired. Call ensureBearerToken() to refresh it, or run: letsfg auth"
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
throw new BearerTokenError(
|
|
65
|
+
"No valid LetsFG Bearer token.\n Run: letsfg auth (connects a card at letsfg.co/connect - nothing is charged)\n Or: export LETSFG_BEARER_TOKEN=<token>"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
async function discover() {
|
|
69
|
+
const fallback = {
|
|
70
|
+
authorization_endpoint: `${BASE_URL}/connect`,
|
|
71
|
+
token_endpoint: `${DEV_ROOT}/oauth/token`,
|
|
72
|
+
registration_endpoint: `${DEV_ROOT}/oauth/register`
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const resp = await fetch(`${DEV_ROOT}/.well-known/oauth-authorization-server`);
|
|
76
|
+
if (!resp.ok) return fallback;
|
|
77
|
+
const d = await resp.json();
|
|
78
|
+
return {
|
|
79
|
+
authorization_endpoint: d.authorization_endpoint || fallback.authorization_endpoint,
|
|
80
|
+
token_endpoint: d.token_endpoint || fallback.token_endpoint,
|
|
81
|
+
registration_endpoint: d.registration_endpoint || fallback.registration_endpoint
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
return fallback;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function pkce() {
|
|
88
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
89
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
90
|
+
return { verifier, challenge };
|
|
91
|
+
}
|
|
92
|
+
async function postForm(url, form) {
|
|
93
|
+
const resp = await fetch(url, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
// RFC 6749 4.1.3 - the token endpoint takes form encoding. The server also
|
|
96
|
+
// accepts JSON, but sending the spec encoding keeps this honest.
|
|
97
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Client-Type": "js-sdk" },
|
|
98
|
+
body: new URLSearchParams(form).toString()
|
|
99
|
+
});
|
|
100
|
+
const data = await resp.json().catch(() => ({}));
|
|
101
|
+
return { status: resp.status, data };
|
|
102
|
+
}
|
|
103
|
+
function openBrowser(url) {
|
|
104
|
+
import("child_process").then(({ exec }) => {
|
|
105
|
+
const cmd = platform() === "win32" ? `start "" "${url}"` : platform() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
106
|
+
exec(cmd, () => {
|
|
107
|
+
});
|
|
108
|
+
}).catch(() => {
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function startCallbackServer(expectedState) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
let settle = null;
|
|
114
|
+
let fail = null;
|
|
115
|
+
const codePromise = new Promise((res, rej) => {
|
|
116
|
+
settle = res;
|
|
117
|
+
fail = rej;
|
|
118
|
+
});
|
|
119
|
+
const server = createServer((req, res) => {
|
|
120
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
121
|
+
if (!url.pathname.startsWith("/callback")) {
|
|
122
|
+
res.writeHead(404).end();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const err = url.searchParams.get("error");
|
|
126
|
+
const code = url.searchParams.get("code");
|
|
127
|
+
const state = url.searchParams.get("state");
|
|
128
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
129
|
+
if (err || !code) {
|
|
130
|
+
res.end("<h1>LetsFG</h1><p>That did not complete. Nothing was charged - run <code>letsfg auth</code> again.</p>");
|
|
131
|
+
fail?.(new BearerTokenError(`Authorisation failed: ${err || "no code returned"}`));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (state !== expectedState) {
|
|
135
|
+
res.end("<h1>LetsFG</h1><p>State mismatch - nothing was charged. Run <code>letsfg auth</code> again.</p>");
|
|
136
|
+
fail?.(new BearerTokenError("State mismatch on the OAuth callback - aborted."));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
res.end("<h1>LetsFG</h1><p>Connected. You can close this tab and go back to your terminal.</p>");
|
|
140
|
+
settle?.(code);
|
|
141
|
+
});
|
|
142
|
+
server.on("error", reject);
|
|
143
|
+
server.listen(0, "127.0.0.1", () => {
|
|
144
|
+
const addr = server.address();
|
|
145
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
146
|
+
if (!port) {
|
|
147
|
+
reject(new BearerTokenError("Could not open a local callback port."));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
resolve({ port, waitForCode: () => codePromise, close: () => server.close() });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async function connectAuth(openBrowserFlag = true) {
|
|
155
|
+
const meta = await discover();
|
|
156
|
+
const { verifier, challenge } = pkce();
|
|
157
|
+
const state = randomBytes(16).toString("base64url");
|
|
158
|
+
const { port, waitForCode, close } = await startCallbackServer(state);
|
|
159
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
160
|
+
try {
|
|
161
|
+
const reg = await fetch(meta.registration_endpoint, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "Content-Type": "application/json" },
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
client_name: "letsfg-cli",
|
|
166
|
+
redirect_uris: [redirectUri],
|
|
167
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
168
|
+
response_types: ["code"],
|
|
169
|
+
token_endpoint_auth_method: "none"
|
|
170
|
+
})
|
|
171
|
+
});
|
|
172
|
+
const regData = await reg.json().catch(() => ({}));
|
|
173
|
+
const clientId = typeof regData.client_id === "string" ? regData.client_id : "";
|
|
174
|
+
if (!reg.ok || !clientId) {
|
|
175
|
+
throw new BearerTokenError(
|
|
176
|
+
`Could not register with LetsFG (HTTP ${reg.status}). See ${BASE_URL}/for-agents`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const authUrl = `${meta.authorization_endpoint}?response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256&state=${encodeURIComponent(state)}&scope=flights`;
|
|
180
|
+
console.log("\n LetsFG needs a card connected before it can search or book.");
|
|
181
|
+
console.log(" Nothing is charged now - you pay the fare only when you book,");
|
|
182
|
+
console.log(" and it is held, not taken, until the airline confirms.\n");
|
|
183
|
+
console.log(" Open this and add a card (or pay 0.00 with Revolut Pay):\n");
|
|
184
|
+
console.log(` ${authUrl}
|
|
185
|
+
`);
|
|
186
|
+
if (openBrowserFlag) openBrowser(authUrl);
|
|
187
|
+
process.stdout.write(" Waiting for you to finish... ");
|
|
188
|
+
const code = await waitForCode();
|
|
189
|
+
const { status, data } = await postForm(meta.token_endpoint, {
|
|
190
|
+
grant_type: "authorization_code",
|
|
191
|
+
code,
|
|
192
|
+
client_id: clientId,
|
|
193
|
+
redirect_uri: redirectUri,
|
|
194
|
+
code_verifier: verifier
|
|
195
|
+
});
|
|
196
|
+
if (status !== 200 || !data.access_token) {
|
|
197
|
+
throw new BearerTokenError(
|
|
198
|
+
`Could not complete authentication (HTTP ${status}). ${String(data.error_description || data.error || "")}`.trim()
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in * 1e3 : FALLBACK_TTL_MS;
|
|
202
|
+
saveToken(String(data.access_token), Date.now() + expiresIn, {
|
|
203
|
+
refresh_token: typeof data.refresh_token === "string" ? data.refresh_token : void 0,
|
|
204
|
+
client_id: clientId
|
|
205
|
+
});
|
|
206
|
+
console.log("done. Card connected - the token refreshes itself from now on.");
|
|
207
|
+
return String(data.access_token);
|
|
208
|
+
} finally {
|
|
209
|
+
close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
var paymentAuth = connectAuth;
|
|
12
213
|
|
|
13
214
|
// src/cli.ts
|
|
14
215
|
function resolveCredentials(apiKeyFlag) {
|
|
@@ -128,38 +329,15 @@ async function cmdSearch(args) {
|
|
|
128
329
|
`);
|
|
129
330
|
} else {
|
|
130
331
|
console.log(`
|
|
131
|
-
To
|
|
132
|
-
console.log(` Passenger IDs needed for booking: ${JSON.stringify(result.passenger_ids)}
|
|
332
|
+
To book: letsfg book <offer_id> --search-id ${result.search_id} --passenger '{...}' --email you@example.com
|
|
133
333
|
`);
|
|
134
334
|
}
|
|
135
335
|
}
|
|
136
|
-
async function cmdUnlock(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (!offerId) {
|
|
142
|
-
console.error("Usage: letsfg unlock <offer_id>");
|
|
143
|
-
process.exit(1);
|
|
144
|
-
}
|
|
145
|
-
const bt = new LetsFG({ apiKey, baseUrl });
|
|
146
|
-
const result = await bt.unlock(offerId);
|
|
147
|
-
if (jsonOut) {
|
|
148
|
-
console.log(JSON.stringify(result, null, 2));
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
if (result.unlock_status === "unlocked") {
|
|
152
|
-
console.log(`
|
|
153
|
-
\u2713 Offer unlocked!`);
|
|
154
|
-
console.log(` Confirmed price: ${result.confirmed_currency} ${result.confirmed_price?.toFixed(2)}`);
|
|
155
|
-
console.log(` Expires at: ${result.offer_expires_at}`);
|
|
156
|
-
console.log(`
|
|
157
|
-
Next: letsfg book ${offerId} --passenger '{...}' --email you@example.com
|
|
158
|
-
`);
|
|
159
|
-
} else {
|
|
160
|
-
console.error(` \u2717 Unlock failed: ${result.message}`);
|
|
161
|
-
process.exit(1);
|
|
162
|
-
}
|
|
336
|
+
async function cmdUnlock(_args) {
|
|
337
|
+
console.error(
|
|
338
|
+
"\n letsfg unlock was retired on 2026-09-08 and the endpoint answers 410 Gone.\n\n There is no unlock step any more. Book directly:\n letsfg book <offer_id> --search-id <search_id> --passenger '{...}' --email you@example.com\n\n The fare is HELD on your connected payment method and captured only once a real\n airline PNR exists, which is what unlock existed to protect against. If the fare moves\n at checkout you are asked to accept or decline it.\n"
|
|
339
|
+
);
|
|
340
|
+
process.exit(1);
|
|
163
341
|
}
|
|
164
342
|
async function cmdBook(args) {
|
|
165
343
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
@@ -246,17 +424,19 @@ async function cmdLocations(args) {
|
|
|
246
424
|
}
|
|
247
425
|
}
|
|
248
426
|
async function cmdAuth(args) {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
427
|
+
for (const dead of ["--card-token", "--payment-method"]) {
|
|
428
|
+
if (getFlag(args, dead)) {
|
|
429
|
+
console.error(
|
|
430
|
+
`
|
|
431
|
+
${dead} was part of the Stripe enrolment, retired 2026-09-02.
|
|
432
|
+
Run plain \`letsfg auth\` instead - it opens letsfg.co/connect to add a card.
|
|
433
|
+
`
|
|
434
|
+
);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
258
437
|
}
|
|
259
|
-
|
|
438
|
+
await paymentAuth(!hasFlag(args, "--no-browser"));
|
|
439
|
+
console.log("\n You are all set. Run: letsfg search WAW BCN 2026-07-15\n");
|
|
260
440
|
}
|
|
261
441
|
async function cmdRegister(args) {
|
|
262
442
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
@@ -289,19 +469,26 @@ async function cmdSetupPayment(args) {
|
|
|
289
469
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
290
470
|
const apiKey = getFlag(args, "--api-key", "-k");
|
|
291
471
|
const baseUrl = getFlag(args, "--base-url");
|
|
292
|
-
|
|
472
|
+
if (getFlag(args, "--token", "-t")) {
|
|
473
|
+
console.log("\n Note: --token was part of the Stripe enrolment, retired 2026-09-08. Ignoring it.");
|
|
474
|
+
}
|
|
293
475
|
const bt = new LetsFG({ apiKey, baseUrl });
|
|
294
|
-
const result = await bt.
|
|
476
|
+
const result = await bt.connectPayment();
|
|
295
477
|
if (jsonOut) {
|
|
296
478
|
console.log(JSON.stringify(result, null, 2));
|
|
297
479
|
return;
|
|
298
480
|
}
|
|
299
|
-
|
|
481
|
+
const url = result.connect_url;
|
|
482
|
+
if (url) {
|
|
300
483
|
console.log(`
|
|
301
|
-
|
|
484
|
+
Open this in a browser to connect a payment method:
|
|
485
|
+
`);
|
|
486
|
+
console.log(` ${url}
|
|
487
|
+
`);
|
|
488
|
+
console.log(` Nothing is charged. Run \`letsfg me\` afterwards to confirm it landed.
|
|
302
489
|
`);
|
|
303
490
|
} else {
|
|
304
|
-
console.error(` \u2717
|
|
491
|
+
console.error(` \u2717 Could not mint a connect link: ${result.message || result.status}`);
|
|
305
492
|
process.exit(1);
|
|
306
493
|
}
|
|
307
494
|
}
|
|
@@ -322,10 +509,9 @@ async function cmdMe(args) {
|
|
|
322
509
|
console.log(` Email: ${p.email}`);
|
|
323
510
|
console.log(` Tier: ${p.tier}`);
|
|
324
511
|
const access = p.access_granted || false;
|
|
325
|
-
console.log(` Access: ${access ? "\u2713 Granted (search,
|
|
512
|
+
console.log(` Access: ${access ? "\u2713 Granted (search, book)" : "\u2717 Not granted"}`);
|
|
326
513
|
console.log(` Payment: ${p.payment_ready ? "\u2713 Ready" : "\u2014"}`);
|
|
327
514
|
console.log(` Searches: ${u.total_searches || 0}`);
|
|
328
|
-
console.log(` Unlocks: ${u.total_unlocks || 0}`);
|
|
329
515
|
console.log(` Bookings: ${u.total_bookings || 0}`);
|
|
330
516
|
console.log(` Total spent: $${((u.total_spent_cents || 0) / 100).toFixed(2)}
|
|
331
517
|
`);
|
|
@@ -334,29 +520,29 @@ var HELP = `
|
|
|
334
520
|
LetsFG \u2014 Agent-native flight search & booking.
|
|
335
521
|
|
|
336
522
|
Search hundreds of airlines via the LetsFG cloud engine.
|
|
337
|
-
Authenticate once with letsfg auth \u2014
|
|
338
|
-
charged
|
|
523
|
+
Authenticate once with letsfg auth \u2014 it opens letsfg.co/connect so you can add
|
|
524
|
+
a card. Nothing is charged; you pay the fare only when you book, and it is held,
|
|
525
|
+
not taken, until the airline confirms.
|
|
339
526
|
|
|
340
527
|
Commands:
|
|
341
|
-
auth
|
|
528
|
+
auth Connect a card at letsfg.co/connect. Nothing charged
|
|
342
529
|
search <origin> <dest> <date> Search for flights (free), prints search_id
|
|
343
530
|
locations <query> Resolve city name to IATA codes
|
|
344
|
-
book <offer_id> --search-id ... Book a flight. No
|
|
531
|
+
book <offer_id> --search-id ... Book a flight. No booking or transaction fee, no unlock step
|
|
345
532
|
me Show agent profile
|
|
346
533
|
|
|
347
534
|
Developer API only (a SEPARATE paid product \u2014 most agents should not use these;
|
|
348
535
|
they create a billing account. Use auth above instead):
|
|
349
536
|
register --name ... --email ... Create a paid Developer API account
|
|
350
|
-
|
|
351
|
-
|
|
537
|
+
connect-payment Print a link to connect a card to that paid account
|
|
538
|
+
setup-payment Alias of connect-payment (the Stripe lane retired 2026-09-08)
|
|
539
|
+
unlock <offer_id> RETIRED 2026-09-08 \u2014 no unlock step, book directly
|
|
352
540
|
|
|
353
541
|
Options:
|
|
354
542
|
--json, -j Output raw JSON
|
|
355
543
|
--api-key, -k Developer API key (or set LETSFG_API_KEY) \u2014 switches book/search to the paid path
|
|
356
544
|
--base-url API URL (default: https://letsfg.co)
|
|
357
|
-
--
|
|
358
|
-
--payment-method (auth only) Stripe pm_... you already hold, for a headless auth
|
|
359
|
-
--no-browser (auth only) Don't try to auto-open the card setup page
|
|
545
|
+
--no-browser (auth only) Print the connect URL instead of opening a browser
|
|
360
546
|
|
|
361
547
|
Examples:
|
|
362
548
|
letsfg auth
|
|
@@ -386,6 +572,7 @@ async function main() {
|
|
|
386
572
|
case "register":
|
|
387
573
|
await cmdRegister(args);
|
|
388
574
|
break;
|
|
575
|
+
case "connect-payment":
|
|
389
576
|
case "setup-payment":
|
|
390
577
|
await cmdSetupPayment(args);
|
|
391
578
|
break;
|
package/dist/index.d.mts
CHANGED
|
@@ -282,7 +282,7 @@ declare function getOfferDetailPromptNotes(offer: OfferDetailLike): string[];
|
|
|
282
282
|
* const bt = new LetsFG({ bearerToken: process.env.LETSFG_BEARER_TOKEN });
|
|
283
283
|
* const flights = await bt.search('GDN', 'BER', '2026-03-03');
|
|
284
284
|
*
|
|
285
|
-
* // Developer API (
|
|
285
|
+
* // Developer API (look-to-book search: 200 free after every booking)
|
|
286
286
|
* const bt2 = new LetsFG({ apiKey: 'letsfg_...' });
|
|
287
287
|
* const flights2 = await bt2.search('LHR', 'JFK', '2026-04-15');
|
|
288
288
|
* ```
|
|
@@ -402,7 +402,7 @@ interface SearchOptions {
|
|
|
402
402
|
interface LetsFGConfig {
|
|
403
403
|
/** PFS Bearer token from `letsfg auth`. Enables free search via POST /api/search polling. */
|
|
404
404
|
bearerToken?: string;
|
|
405
|
-
/** Developer API key
|
|
405
|
+
/** Developer API key. Look-to-book search; no booking fee, no transaction fee. */
|
|
406
406
|
apiKey?: string;
|
|
407
407
|
baseUrl?: string;
|
|
408
408
|
timeout?: number;
|
|
@@ -495,11 +495,19 @@ declare class LetsFG {
|
|
|
495
495
|
*/
|
|
496
496
|
resolveLocation(query: string): Promise<Array<Record<string, unknown>>>;
|
|
497
497
|
/**
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
498
|
+
* RETIRED 2026-09-08. Throws instead of calling the server.
|
|
499
|
+
*
|
|
500
|
+
* There is no unlock step on any lane. Unlock existed to confirm a live price
|
|
501
|
+
* before charging; booking now HOLDS the fare on the connected payment method
|
|
502
|
+
* and captures only once a real airline PNR exists, so a fare that moved
|
|
503
|
+
* cannot become a charge for a ticket you did not get. If it moves at
|
|
504
|
+
* checkout you get a `price_change` question to accept or decline instead.
|
|
505
|
+
*
|
|
506
|
+
* Kept as a method, and throwing locally rather than making the request, so an
|
|
507
|
+
* older caller gets one clear sentence at the line that is actually wrong —
|
|
508
|
+
* not a 410 body to decode, and not a TypeError somewhere else.
|
|
501
509
|
*/
|
|
502
|
-
unlock(
|
|
510
|
+
unlock(_offerId: string): Promise<UnlockResult>;
|
|
503
511
|
/**
|
|
504
512
|
* Book a flight.
|
|
505
513
|
*
|
|
@@ -509,11 +517,64 @@ declare class LetsFG {
|
|
|
509
517
|
* complete, { ok, booked: false, booking_url } — hand the link to the user,
|
|
510
518
|
* nothing was charged.
|
|
511
519
|
*
|
|
512
|
-
* Developer API (X-API-Key):
|
|
513
|
-
*
|
|
514
|
-
*
|
|
520
|
+
* Developer API (X-API-Key): POST /flights/book. NO unlock step. searchId is
|
|
521
|
+
* REQUIRED — an offer is bookable only inside the search that produced it.
|
|
522
|
+
* The connected Revolut method is HELD, not charged; a LetsFG booking agent
|
|
523
|
+
* buys the ticket and the hold is captured only against a real airline PNR.
|
|
524
|
+
* Returns the 202 { ok, booking_id, state, held, charged: 0, poll_url } —
|
|
525
|
+
* poll getBooking(bookingId) until `terminal`, or use bookAndWait().
|
|
526
|
+
* Always provide idempotencyKey: a retry with the same key returns the
|
|
527
|
+
* existing booking instead of opening a second hold on the card.
|
|
515
528
|
*/
|
|
516
529
|
book(offerId: string, passengers: Passenger[], contactEmail: string, contactPhone?: string, idempotencyKey?: string, searchId?: string): Promise<BookingResult | Record<string, unknown>>;
|
|
530
|
+
/**
|
|
531
|
+
* Poll a Developer API flight booking.
|
|
532
|
+
*
|
|
533
|
+
* Poll every few seconds until `terminal` is true. The poll is ALSO how LetsFG
|
|
534
|
+
* knows you are still there, which is what keeps a booking paused on a
|
|
535
|
+
* question alive — so do not back off to minutes.
|
|
536
|
+
*
|
|
537
|
+
* States: authorised, card_issued, booking_in_progress, awaiting_settlement,
|
|
538
|
+
* then completed (with `pnr` and `charged_amount`), failed (hold released,
|
|
539
|
+
* nothing charged) or needs_attention (a human at LetsFG is on it — do not
|
|
540
|
+
* book again).
|
|
541
|
+
*/
|
|
542
|
+
getBooking(bookingId: string): Promise<Record<string, unknown>>;
|
|
543
|
+
/**
|
|
544
|
+
* Answer the open `question` on a booking.
|
|
545
|
+
*
|
|
546
|
+
* Echo the question's `round`. A stale round is refused with 409 rather than
|
|
547
|
+
* guessed at, so an answer to an old question can never be applied to a new
|
|
548
|
+
* one. Seat: { seats: [...] } or { skip: true }. Price change or paid extra:
|
|
549
|
+
* { confirm: true } or { skip: true } — declining an extra still completes
|
|
550
|
+
* the booking, without it.
|
|
551
|
+
*/
|
|
552
|
+
answerBooking(bookingId: string, round: number, answer?: {
|
|
553
|
+
seats?: Array<Record<string, unknown>>;
|
|
554
|
+
confirm?: boolean;
|
|
555
|
+
skip?: boolean;
|
|
556
|
+
}): Promise<Record<string, unknown>>;
|
|
557
|
+
/**
|
|
558
|
+
* Book and poll to a terminal state. Mirrors bookHotelAndWait().
|
|
559
|
+
*
|
|
560
|
+
* Blocks for as long as the booking takes (4–11 minutes typically), so use
|
|
561
|
+
* book() + getBooking() instead if your caller has a request timeout.
|
|
562
|
+
*
|
|
563
|
+
* `onQuestion` returns the answer for answerBooking(). Without it, a fare
|
|
564
|
+
* increase is ACCEPTED and a paid extra is DECLINED — the conservative
|
|
565
|
+
* reading of "the traveller asked for this flight".
|
|
566
|
+
*/
|
|
567
|
+
bookAndWait(offerId: string, passengers: Passenger[], contactEmail: string, searchId: string, opts?: {
|
|
568
|
+
contactPhone?: string;
|
|
569
|
+
idempotencyKey?: string;
|
|
570
|
+
pollMs?: number;
|
|
571
|
+
timeoutMs?: number;
|
|
572
|
+
onQuestion?: (q: Record<string, unknown>) => {
|
|
573
|
+
seats?: Array<Record<string, unknown>>;
|
|
574
|
+
confirm?: boolean;
|
|
575
|
+
skip?: boolean;
|
|
576
|
+
};
|
|
577
|
+
}): Promise<Record<string, unknown>>;
|
|
517
578
|
/**
|
|
518
579
|
* Resolve a place name to the city id that searchHotels() needs.
|
|
519
580
|
*
|
|
@@ -621,13 +682,30 @@ declare class LetsFG {
|
|
|
621
682
|
*/
|
|
622
683
|
cancelHotel(confirmation: string): Promise<Record<string, unknown>>;
|
|
623
684
|
/**
|
|
624
|
-
* [Developer API
|
|
685
|
+
* [Developer API] Mint a one-time link for connecting a Revolut payment method.
|
|
686
|
+
*
|
|
687
|
+
* This replaced setupPayment() on 2026-09-08. Nothing is charged to connect, and
|
|
688
|
+
* card details never touch LetsFG: the returned `connect_url` opens a hosted page
|
|
689
|
+
* where the developer saves a card, Revolut Pay or Google Pay. A PERSON must open
|
|
690
|
+
* it in a browser — there is no endpoint that takes card details, so do not ask a
|
|
691
|
+
* user for a card number and do not try to automate this step.
|
|
692
|
+
*
|
|
693
|
+
* Most agents should NOT need a Developer API account at all. To authenticate for
|
|
694
|
+
* search and booking, run `letsfg auth`, which creates no billing account.
|
|
695
|
+
*/
|
|
696
|
+
connectPayment(): Promise<Record<string, unknown>>;
|
|
697
|
+
/**
|
|
698
|
+
* RETIRED 2026-09-08 with Stripe. Throws instead of calling the server.
|
|
699
|
+
*
|
|
700
|
+
* `/agents/setup-payment` answers 410 Gone. Payment enrolment moved onto the same
|
|
701
|
+
* Revolut rail as the rest of the product: call connectPayment() and open the
|
|
702
|
+
* `connect_url` it returns.
|
|
625
703
|
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
704
|
+
* Kept as a method, and throwing locally rather than making the request, for the
|
|
705
|
+
* same reason as unlock() — an older caller gets one clear sentence at the line that
|
|
706
|
+
* is actually wrong, not a 410 body to decode and not a TypeError somewhere else.
|
|
629
707
|
*/
|
|
630
|
-
setupPayment(
|
|
708
|
+
setupPayment(_token?: string): Promise<Record<string, unknown>>;
|
|
631
709
|
/**
|
|
632
710
|
* Get current agent profile and usage stats.
|
|
633
711
|
*/
|