letsfg 2026.5.72 → 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 +89 -49
- package/dist/{chunk-GO3FXQXC.mjs → chunk-GVKYPTY3.mjs} +214 -62
- package/dist/cli.js +237 -101
- package/dist/cli.mjs +25 -40
- package/dist/index.d.mts +172 -46
- package/dist/index.d.ts +172 -46
- package/dist/index.js +215 -62
- package/dist/index.mjs +3 -1
- package/package.json +56 -56
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
| **Speed** | 8–10 s to first results; longer on a split | 2–5 s (discover) · 8–10 s to first results (full) |
|
|
15
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
|
+
> **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
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
@@ -54,7 +54,7 @@ const flights = await bt.search('GDN', 'BER', '2026-03-03');
|
|
|
54
54
|
const best = cheapestOffer(flights);
|
|
55
55
|
console.log(offerSummary(best));
|
|
56
56
|
|
|
57
|
-
// Book —
|
|
57
|
+
// Book — no booking fee, no transaction fee — our margin is already in the price you saw; no unlock step. Starts the booking:
|
|
58
58
|
// the fare is HELD on your card and a LetsFG agent buys the ticket (4-11 min).
|
|
59
59
|
const result = await bt.book(
|
|
60
60
|
best.id,
|
|
@@ -111,8 +111,9 @@ address (passport optional). A missing detail returns `missing_details` with
|
|
|
111
111
|
same trip while one is in progress — that would place a second hold.
|
|
112
112
|
|
|
113
113
|
Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken` —
|
|
114
|
-
`search()`/`book()` dispatch automatically. That path
|
|
115
|
-
|
|
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.
|
|
116
117
|
|
|
117
118
|
## Quick Start (CLI)
|
|
118
119
|
|
|
@@ -131,14 +132,15 @@ letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":
|
|
|
131
132
|
|
|
132
133
|
### `bt.search(origin, destination, dateFrom, options?)`
|
|
133
134
|
### `bt.resolveLocation(query)`
|
|
134
|
-
### `bt.unlock(offerId)` — Developer API only
|
|
135
135
|
### `bt.book(offerId, passengers, contactEmail, contactPhone?, idempotencyKey?, searchId?)`
|
|
136
136
|
Dispatches on which credential is set: `bearerToken` → PFS booking via
|
|
137
137
|
`POST /api/agent-book` (pass `searchId`, one passenger with full details;
|
|
138
138
|
returns `booking_ref` — poll `POST /api/agent-book/status`). `apiKey` → paid
|
|
139
|
-
Developer API `book` (requires `unlock
|
|
140
|
-
and `idempotencyKey`).
|
|
141
|
-
### `bt.
|
|
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**
|
|
142
144
|
### `bt.me()`
|
|
143
145
|
### `LetsFG.register(agentName, email, baseUrl?, ownerName?, description?)` — Developer API only, most agents don't need this
|
|
144
146
|
|
|
@@ -178,7 +180,7 @@ MIT
|
|
|
178
180
|
|
|
179
181
|
## 🏨 Hotels — new, and live
|
|
180
182
|
|
|
181
|
-
Your agent can book hotels as well as flights. Same card-backed token or API key, same
|
|
183
|
+
Your agent can book hotels as well as flights. Same card-backed token or API key, same connected payment method.
|
|
182
184
|
|
|
183
185
|
```python
|
|
184
186
|
from letsfg import LetsFG
|
|
@@ -192,36 +194,52 @@ stays = lfg.search_hotels(
|
|
|
192
194
|
|
|
193
195
|
hotel = stays["hotels"][0]
|
|
194
196
|
offer = hotel["offers"][0]
|
|
195
|
-
print(hotel["name"], offer["price"],
|
|
196
|
-
#
|
|
197
|
+
print(hotel["name"], offer["price"], offer["currency"], offer["refundable"])
|
|
198
|
+
# prices are in stays["currency"]: USD unless you pass currency=
|
|
197
199
|
|
|
198
200
|
booking = lfg.book_hotel_and_wait(
|
|
199
|
-
session_id=
|
|
201
|
+
session_id=offer["session_id"],
|
|
200
202
|
hotel_code=hotel["hotel_code"],
|
|
201
203
|
combination_id_v2=offer["combination_id_v2"],
|
|
202
|
-
expected_price=offer["price"],
|
|
203
|
-
|
|
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"],
|
|
204
208
|
city_id=city["Id"], city_name=city["Name"],
|
|
205
209
|
check_in="2026-11-10", check_out="2026-11-12",
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
208
214
|
)
|
|
209
|
-
|
|
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"))
|
|
210
221
|
```
|
|
211
222
|
|
|
212
223
|
### How you pay
|
|
213
224
|
|
|
214
|
-
**
|
|
215
|
-
|
|
216
|
-
the
|
|
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.
|
|
217
231
|
|
|
218
|
-
|
|
219
|
-
|
|
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.)
|
|
220
234
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
you
|
|
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`.
|
|
225
243
|
|
|
226
244
|
### What search costs
|
|
227
245
|
|
|
@@ -236,27 +254,36 @@ is not metered, only the search call itself.
|
|
|
236
254
|
|
|
237
255
|
### Things worth knowing before you build
|
|
238
256
|
|
|
239
|
-
- **A
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
- **
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
- **
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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.
|
|
260
287
|
|
|
261
288
|
### JavaScript
|
|
262
289
|
|
|
@@ -270,8 +297,21 @@ const stays = await lfg.searchHotels({
|
|
|
270
297
|
checkIn: '2026-11-10', checkOut: '2026-11-12', adults: 2,
|
|
271
298
|
});
|
|
272
299
|
|
|
273
|
-
const
|
|
274
|
-
|
|
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);
|
|
275
315
|
```
|
|
276
316
|
|
|
277
317
|
### MCP
|
|
@@ -1564,6 +1564,7 @@ var LATE_MERGE_POLL_MS = 3e3;
|
|
|
1564
1564
|
var LATE_MERGE_GRACE_MS = 9e4;
|
|
1565
1565
|
var WAIT_FOR_SPLIT = (process.env.LETSFG_WAIT_FOR_SPLIT || "").trim() !== "0";
|
|
1566
1566
|
var NON_TERMINAL = ["pending", "searching"];
|
|
1567
|
+
var HOTEL_BOOKING_FINAL_STATUSES = ["succeeded", "failed", "attention"];
|
|
1567
1568
|
var LetsFG = class {
|
|
1568
1569
|
bearerToken;
|
|
1569
1570
|
apiKey;
|
|
@@ -1671,13 +1672,23 @@ var LetsFG = class {
|
|
|
1671
1672
|
return Array.isArray(data) ? data : data.locations || [];
|
|
1672
1673
|
}
|
|
1673
1674
|
/**
|
|
1674
|
-
*
|
|
1675
|
-
*
|
|
1676
|
-
*
|
|
1675
|
+
* RETIRED 2026-09-08. Throws instead of calling the server.
|
|
1676
|
+
*
|
|
1677
|
+
* There is no unlock step on any lane. Unlock existed to confirm a live price
|
|
1678
|
+
* before charging; booking now HOLDS the fare on the connected payment method
|
|
1679
|
+
* and captures only once a real airline PNR exists, so a fare that moved
|
|
1680
|
+
* cannot become a charge for a ticket you did not get. If it moves at
|
|
1681
|
+
* checkout you get a `price_change` question to accept or decline instead.
|
|
1682
|
+
*
|
|
1683
|
+
* Kept as a method, and throwing locally rather than making the request, so an
|
|
1684
|
+
* older caller gets one clear sentence at the line that is actually wrong —
|
|
1685
|
+
* not a 410 body to decode, and not a TypeError somewhere else.
|
|
1677
1686
|
*/
|
|
1678
|
-
async unlock(
|
|
1679
|
-
|
|
1680
|
-
|
|
1687
|
+
async unlock(_offerId) {
|
|
1688
|
+
throw new LetsFGError(
|
|
1689
|
+
"unlock() was retired on 2026-09-08 and the endpoint answers 410 Gone. There is no unlock step: call book() directly. The fare is held on the connected payment method and captured only against a real airline PNR. See https://letsfg.co/developers/api/docs",
|
|
1690
|
+
410
|
|
1691
|
+
);
|
|
1681
1692
|
}
|
|
1682
1693
|
/**
|
|
1683
1694
|
* Book a flight.
|
|
@@ -1688,9 +1699,14 @@ var LetsFG = class {
|
|
|
1688
1699
|
* complete, { ok, booked: false, booking_url } — hand the link to the user,
|
|
1689
1700
|
* nothing was charged.
|
|
1690
1701
|
*
|
|
1691
|
-
* Developer API (X-API-Key):
|
|
1692
|
-
*
|
|
1693
|
-
*
|
|
1702
|
+
* Developer API (X-API-Key): POST /flights/book. NO unlock step. searchId is
|
|
1703
|
+
* REQUIRED — an offer is bookable only inside the search that produced it.
|
|
1704
|
+
* The connected Revolut method is HELD, not charged; a LetsFG booking agent
|
|
1705
|
+
* buys the ticket and the hold is captured only against a real airline PNR.
|
|
1706
|
+
* Returns the 202 { ok, booking_id, state, held, charged: 0, poll_url } —
|
|
1707
|
+
* poll getBooking(bookingId) until `terminal`, or use bookAndWait().
|
|
1708
|
+
* Always provide idempotencyKey: a retry with the same key returns the
|
|
1709
|
+
* existing booking instead of opening a second hold on the card.
|
|
1694
1710
|
*/
|
|
1695
1711
|
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "", searchId = "") {
|
|
1696
1712
|
this.requireAuth();
|
|
@@ -1711,28 +1727,107 @@ var LetsFG = class {
|
|
|
1711
1727
|
});
|
|
1712
1728
|
}
|
|
1713
1729
|
this.requireApiKey();
|
|
1730
|
+
if (!searchId) {
|
|
1731
|
+
throw new LetsFGError(
|
|
1732
|
+
"searchId is required to book on the Developer API \u2014 pass the search_id from search()'s result. An offer can only be booked inside the search that produced it. (Before 2026-09-08 this argument was ignored on this path.)",
|
|
1733
|
+
400
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
const pax = passengers.map((p) => ({ ...p }));
|
|
1737
|
+
if (contactPhone && pax.length && !pax[0].phone_number) pax[0].phone_number = contactPhone;
|
|
1714
1738
|
const body = {
|
|
1739
|
+
search_id: searchId,
|
|
1715
1740
|
offer_id: offerId,
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
contact_email: contactEmail,
|
|
1719
|
-
contact_phone: contactPhone
|
|
1741
|
+
passengers: pax,
|
|
1742
|
+
contact_email: contactEmail
|
|
1720
1743
|
};
|
|
1721
1744
|
if (idempotencyKey) body.idempotency_key = idempotencyKey;
|
|
1722
|
-
return this.post("/developers/api/v1/
|
|
1745
|
+
return this.post("/developers/api/v1/flights/book", body);
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Poll a Developer API flight booking.
|
|
1749
|
+
*
|
|
1750
|
+
* Poll every few seconds until `terminal` is true. The poll is ALSO how LetsFG
|
|
1751
|
+
* knows you are still there, which is what keeps a booking paused on a
|
|
1752
|
+
* question alive — so do not back off to minutes.
|
|
1753
|
+
*
|
|
1754
|
+
* States: authorised, card_issued, booking_in_progress, awaiting_settlement,
|
|
1755
|
+
* then completed (with `pnr` and `charged_amount`), failed (hold released,
|
|
1756
|
+
* nothing charged) or needs_attention (a human at LetsFG is on it — do not
|
|
1757
|
+
* book again).
|
|
1758
|
+
*/
|
|
1759
|
+
async getBooking(bookingId) {
|
|
1760
|
+
this.requireApiKey();
|
|
1761
|
+
return this.getWithAuth(
|
|
1762
|
+
`/developers/api/v1/flights/bookings/${encodeURIComponent(bookingId)}`
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Answer the open `question` on a booking.
|
|
1767
|
+
*
|
|
1768
|
+
* Echo the question's `round`. A stale round is refused with 409 rather than
|
|
1769
|
+
* guessed at, so an answer to an old question can never be applied to a new
|
|
1770
|
+
* one. Seat: { seats: [...] } or { skip: true }. Price change or paid extra:
|
|
1771
|
+
* { confirm: true } or { skip: true } — declining an extra still completes
|
|
1772
|
+
* the booking, without it.
|
|
1773
|
+
*/
|
|
1774
|
+
async answerBooking(bookingId, round, answer = {}) {
|
|
1775
|
+
this.requireApiKey();
|
|
1776
|
+
return this.post(
|
|
1777
|
+
`/developers/api/v1/flights/bookings/${encodeURIComponent(bookingId)}/answer`,
|
|
1778
|
+
{ round, ...answer }
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Book and poll to a terminal state. Mirrors bookHotelAndWait().
|
|
1783
|
+
*
|
|
1784
|
+
* Blocks for as long as the booking takes (4–11 minutes typically), so use
|
|
1785
|
+
* book() + getBooking() instead if your caller has a request timeout.
|
|
1786
|
+
*
|
|
1787
|
+
* `onQuestion` returns the answer for answerBooking(). Without it, a fare
|
|
1788
|
+
* increase is ACCEPTED and a paid extra is DECLINED — the conservative
|
|
1789
|
+
* reading of "the traveller asked for this flight".
|
|
1790
|
+
*/
|
|
1791
|
+
async bookAndWait(offerId, passengers, contactEmail, searchId, opts = {}) {
|
|
1792
|
+
const { contactPhone = "", idempotencyKey = "", pollMs = 5e3, timeoutMs = 9e5, onQuestion } = opts;
|
|
1793
|
+
const started = await this.book(
|
|
1794
|
+
offerId,
|
|
1795
|
+
passengers,
|
|
1796
|
+
contactEmail,
|
|
1797
|
+
contactPhone,
|
|
1798
|
+
idempotencyKey,
|
|
1799
|
+
searchId
|
|
1800
|
+
);
|
|
1801
|
+
if (!started || started.ok !== true) return started;
|
|
1802
|
+
const bookingId = String(started.booking_id);
|
|
1803
|
+
const deadline = Date.now() + timeoutMs;
|
|
1804
|
+
while (Date.now() < deadline) {
|
|
1805
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
1806
|
+
const state = await this.getBooking(bookingId);
|
|
1807
|
+
const question = state.question;
|
|
1808
|
+
if (question) {
|
|
1809
|
+
const answer = onQuestion ? onQuestion(question) : question.kind === "extra" ? { skip: true } : { confirm: true };
|
|
1810
|
+
await this.answerBooking(bookingId, Number(question.round), answer);
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
if (state.terminal) return state;
|
|
1814
|
+
}
|
|
1815
|
+
return this.getBooking(bookingId);
|
|
1723
1816
|
}
|
|
1724
1817
|
// ── Hotels ──────────────────────────────────────────────────────────
|
|
1725
1818
|
//
|
|
1726
|
-
// A
|
|
1727
|
-
// deliberate: a hotel search opens a real session at the
|
|
1728
|
-
// blocks a real rate, so a caller is never allowed to
|
|
1729
|
-
// commitment only to discover it cannot pay. The same
|
|
1730
|
-
// flight booking authorises hotels
|
|
1819
|
+
// A connected payment method is required for EVERY hotel call, search
|
|
1820
|
+
// included. That is deliberate: a hotel search opens a real session at the
|
|
1821
|
+
// supplier and booking blocks a real rate, so a caller is never allowed to
|
|
1822
|
+
// reach the point of commitment only to discover it cannot pay. The same
|
|
1823
|
+
// method that authorises flight booking authorises hotels.
|
|
1731
1824
|
//
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1734
|
-
//
|
|
1735
|
-
//
|
|
1825
|
+
// How a hotel is paid (since 2026-09-11): the full `price` is HELD on the
|
|
1826
|
+
// connected Revolut method, LetsFG books and pays the supplier itself, and the
|
|
1827
|
+
// hold is captured only once the supplier has confirmed. A booking that fails
|
|
1828
|
+
// releases the hold. There is no reservation fee, no deposit and no pay link —
|
|
1829
|
+
// those belonged to the process retired on 2026-09-11. Every rate type is
|
|
1830
|
+
// sold, refundable and non-refundable.
|
|
1736
1831
|
/**
|
|
1737
1832
|
* Resolve a place name to the city id that searchHotels() needs.
|
|
1738
1833
|
*
|
|
@@ -1753,12 +1848,17 @@ var LetsFG = class {
|
|
|
1753
1848
|
* Slow by nature — the supplier streams a whole city and every rate is priced
|
|
1754
1849
|
* — so this gets its own generous timeout rather than the client default.
|
|
1755
1850
|
*
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
* `
|
|
1851
|
+
* The response carries `session_id`, `currency`, `supplier_currency`,
|
|
1852
|
+
* `markup_rate`, `fx_rate`, `fx_as_of`, `count`, `hotels`, `terms` and
|
|
1853
|
+
* `caveats`. Each offer carries `price` (what the guest pays, in `currency`),
|
|
1854
|
+
* `currency`, `fx_rate`, `expected_cost` (the supplier's cost, in PLN),
|
|
1855
|
+
* `refundable`, `free_cancellation_until` (refundable rates only),
|
|
1856
|
+
* `cancellation_policy` and its own `session_id`.
|
|
1759
1857
|
*
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
1858
|
+
* `price` is the supplier's cost plus `markup_rate` (6.4% for Revolut Pay or an
|
|
1859
|
+
* EEA-issued card, 8.3% for a card issued outside the EEA); nothing is added at
|
|
1860
|
+
* booking. Keep the chosen offer whole: bookHotel() needs its `session_id`,
|
|
1861
|
+
* `combination_id_v2`, `price`, `expected_cost`, `currency` and `fx_rate`.
|
|
1762
1862
|
*/
|
|
1763
1863
|
async searchHotels(params) {
|
|
1764
1864
|
this.requireApiKey();
|
|
@@ -1771,7 +1871,8 @@ var LetsFG = class {
|
|
|
1771
1871
|
children: params.children ?? 0,
|
|
1772
1872
|
nationality: params.nationality ?? "PL",
|
|
1773
1873
|
limit: params.limit ?? 40,
|
|
1774
|
-
with_images: params.withImages ?? true
|
|
1874
|
+
with_images: params.withImages ?? true,
|
|
1875
|
+
currency: params.currency ?? "USD"
|
|
1775
1876
|
};
|
|
1776
1877
|
if (params.childAges?.length) body.child_ages = params.childAges;
|
|
1777
1878
|
return this.post("/developers/api/v1/hotels/search", body, 24e4);
|
|
@@ -1779,30 +1880,42 @@ var LetsFG = class {
|
|
|
1779
1880
|
/**
|
|
1780
1881
|
* Start a booking. Returns a job immediately — it does NOT book inline.
|
|
1781
1882
|
*
|
|
1782
|
-
*
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1883
|
+
* What happens, in order: the offer's full `price` is HELD on the Revolut
|
|
1884
|
+
* payment method connected to this account (authorised, not taken); LetsFG
|
|
1885
|
+
* books the room with the supplier and pays the supplier itself; the hold is
|
|
1886
|
+
* captured only once the supplier has confirmed. If the booking fails for any
|
|
1887
|
+
* reason, the hold is released and nothing is charged. There is no
|
|
1888
|
+
* reservation fee, no deposit and no pay link.
|
|
1787
1889
|
*
|
|
1788
|
-
*
|
|
1789
|
-
*
|
|
1890
|
+
* A booking takes minutes and no proxy holds a connection that long, so this
|
|
1891
|
+
* returns at once and you poll hotelBooking() for the outcome. Use
|
|
1892
|
+
* bookHotelAndWait() if you would rather block.
|
|
1790
1893
|
*
|
|
1791
|
-
* Send `expectedPrice`
|
|
1792
|
-
* them
|
|
1793
|
-
*
|
|
1894
|
+
* Send `expectedPrice` (the offer's `price`), `expectedCost`, `currency` and
|
|
1895
|
+
* `fxRate` exactly as search returned them. The booking is refused if the
|
|
1896
|
+
* supplier's live cost is above `expectedCost`, and a USD offer sent without
|
|
1897
|
+
* its `currency` is refused with 400 price_mismatch (the API assumes PLN).
|
|
1898
|
+
* Guest names, phone and e-mail are checked before anything is held; a problem
|
|
1899
|
+
* returns 400 invalid_details naming the fields.
|
|
1794
1900
|
*
|
|
1795
|
-
* Do NOT call this again for
|
|
1796
|
-
* the
|
|
1901
|
+
* Do NOT call this again for a booking whose job is still running: poll it.
|
|
1902
|
+
* A retry of the same booking returns the job already under way
|
|
1903
|
+
* (`duplicate: true`) rather than holding the money twice.
|
|
1797
1904
|
*/
|
|
1798
1905
|
async bookHotel(params) {
|
|
1799
1906
|
this.requireApiKey();
|
|
1907
|
+
if (typeof params.expectedCost !== "number") {
|
|
1908
|
+
throw new LetsFGError(
|
|
1909
|
+
"bookHotel() needs expectedCost: copy the offer's expected_cost, currency and fx_rate. " + ("expectedBalance" in params ? "expectedBalance belonged to the reservation-fee process retired on 2026-09-11 and is not sent. " : "") + "See https://letsfg.co/developers/api/docs",
|
|
1910
|
+
400
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1800
1913
|
const body = {
|
|
1801
1914
|
session_id: params.sessionId,
|
|
1802
1915
|
hotel_code: params.hotelCode,
|
|
1803
1916
|
combination_id_v2: params.combinationIdV2,
|
|
1804
1917
|
expected_price: params.expectedPrice,
|
|
1805
|
-
|
|
1918
|
+
expected_cost: params.expectedCost,
|
|
1806
1919
|
city_id: params.cityId,
|
|
1807
1920
|
city_name: params.cityName,
|
|
1808
1921
|
check_in: params.checkIn,
|
|
@@ -1814,16 +1927,30 @@ var LetsFG = class {
|
|
|
1814
1927
|
phone_country_code: params.phoneCountryCode ?? "48",
|
|
1815
1928
|
special_requests: params.specialRequests ?? []
|
|
1816
1929
|
};
|
|
1930
|
+
if (params.currency) body.currency = params.currency;
|
|
1931
|
+
if (params.fxRate != null) body.fx_rate = params.fxRate;
|
|
1817
1932
|
if (params.combinationId != null) body.combination_id = params.combinationId;
|
|
1818
1933
|
if (params.hotelName) body.hotel_name = params.hotelName;
|
|
1934
|
+
if (params.idempotencyKey) body.idempotency_key = params.idempotencyKey;
|
|
1819
1935
|
return this.post("/developers/api/v1/hotels/book", body, 9e4);
|
|
1820
1936
|
}
|
|
1821
1937
|
/**
|
|
1822
1938
|
* Collect the result of a booking started with bookHotel().
|
|
1823
1939
|
*
|
|
1824
|
-
* `status` is 'in_progress', 'succeeded'
|
|
1825
|
-
*
|
|
1826
|
-
*
|
|
1940
|
+
* `status` is 'in_progress', 'succeeded', 'failed' or 'attention'; the last
|
|
1941
|
+
* three are final (HOTEL_BOOKING_FINAL_STATUSES).
|
|
1942
|
+
*
|
|
1943
|
+
* - succeeded: `confirmation`, `booking_id`, `hotel`, `room`, `total_price` +
|
|
1944
|
+
* `currency` (what the guest is charged), `supplier_paid` +
|
|
1945
|
+
* `supplier_currency` (what the supplier was paid), `payment_status`,
|
|
1946
|
+
* `refundable`, `free_cancellation_until`, `cancellation_ladder`, `terms`.
|
|
1947
|
+
* - failed: `error`, written for the guest. The hold has been released and
|
|
1948
|
+
* nothing was charged.
|
|
1949
|
+
* - attention: `error` (and `confirmation` when known). The outcome could not
|
|
1950
|
+
* be settled automatically; the hold is kept — nothing is charged — while a
|
|
1951
|
+
* person checks with the supplier. Do not book again.
|
|
1952
|
+
*
|
|
1953
|
+
* The guest is e-mailed in every case.
|
|
1827
1954
|
*/
|
|
1828
1955
|
async hotelBooking(bookingJobId) {
|
|
1829
1956
|
this.requireApiKey();
|
|
@@ -1835,13 +1962,15 @@ var LetsFG = class {
|
|
|
1835
1962
|
/**
|
|
1836
1963
|
* bookHotel(), then poll until the booking settles. Convenience only.
|
|
1837
1964
|
*
|
|
1838
|
-
*
|
|
1839
|
-
*
|
|
1840
|
-
*
|
|
1965
|
+
* Stops at 'succeeded', 'failed' or 'attention' and never re-books. Giving up
|
|
1966
|
+
* after `maxWaitMs` (default 30 minutes: a booking usually takes 5-10, and
|
|
1967
|
+
* hotel bookings run one at a time) does NOT cancel anything — the booking may
|
|
1968
|
+
* still complete. The returned object carries `booking_job_id` so you can keep
|
|
1969
|
+
* polling, and the guest is e-mailed the outcome regardless.
|
|
1841
1970
|
*/
|
|
1842
1971
|
async bookHotelAndWait(params) {
|
|
1843
1972
|
const pollIntervalMs = params.pollIntervalMs ?? 2e4;
|
|
1844
|
-
const maxWaitMs = params.maxWaitMs ??
|
|
1973
|
+
const maxWaitMs = params.maxWaitMs ?? 18e5;
|
|
1845
1974
|
const job = await this.bookHotel(params);
|
|
1846
1975
|
const jobId = job.booking_job_id;
|
|
1847
1976
|
if (!jobId) return job;
|
|
@@ -1851,19 +1980,19 @@ var LetsFG = class {
|
|
|
1851
1980
|
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
1852
1981
|
waited += pollIntervalMs;
|
|
1853
1982
|
result = await this.hotelBooking(jobId);
|
|
1854
|
-
|
|
1855
|
-
if (st === "succeeded" || st === "failed") return result;
|
|
1983
|
+
if (HOTEL_BOOKING_FINAL_STATUSES.includes(result.status)) return result;
|
|
1856
1984
|
}
|
|
1857
1985
|
if (result.booking_job_id == null) result.booking_job_id = jobId;
|
|
1858
1986
|
return result;
|
|
1859
1987
|
}
|
|
1860
1988
|
/**
|
|
1861
|
-
* Release a reservation at the supplier.
|
|
1989
|
+
* Release a reservation at the supplier and refund the guest.
|
|
1862
1990
|
*
|
|
1863
|
-
*
|
|
1864
|
-
*
|
|
1865
|
-
*
|
|
1866
|
-
*
|
|
1991
|
+
* Only this account's own bookings can be cancelled (anything else is 404). A
|
|
1992
|
+
* zero-charge cancellation — a refundable rate before its
|
|
1993
|
+
* `free_cancellation_until` — refunds the charge in full, or releases a hold
|
|
1994
|
+
* not yet captured. A cancellation that would cost money is refused with 409;
|
|
1995
|
+
* the hotel's own ladder is in the booking's `terms`.
|
|
1867
1996
|
*
|
|
1868
1997
|
* This drives a browser at the supplier and takes over a minute. If it times
|
|
1869
1998
|
* out, do not assume it failed — re-check before retrying.
|
|
@@ -1877,15 +2006,37 @@ var LetsFG = class {
|
|
|
1877
2006
|
);
|
|
1878
2007
|
}
|
|
1879
2008
|
/**
|
|
1880
|
-
* [Developer API
|
|
2009
|
+
* [Developer API] Mint a one-time link for connecting a Revolut payment method.
|
|
2010
|
+
*
|
|
2011
|
+
* This replaced setupPayment() on 2026-09-08. Nothing is charged to connect, and
|
|
2012
|
+
* card details never touch LetsFG: the returned `connect_url` opens a hosted page
|
|
2013
|
+
* where the developer saves a card, Revolut Pay or Google Pay. A PERSON must open
|
|
2014
|
+
* it in a browser — there is no endpoint that takes card details, so do not ask a
|
|
2015
|
+
* user for a card number and do not try to automate this step.
|
|
1881
2016
|
*
|
|
1882
|
-
* Most agents should NOT
|
|
1883
|
-
* search and booking
|
|
1884
|
-
* through a zero-amount setup and creates no billing account.
|
|
2017
|
+
* Most agents should NOT need a Developer API account at all. To authenticate for
|
|
2018
|
+
* search and booking, run `letsfg auth`, which creates no billing account.
|
|
1885
2019
|
*/
|
|
1886
|
-
async
|
|
2020
|
+
async connectPayment() {
|
|
1887
2021
|
this.requireApiKey();
|
|
1888
|
-
return this.post("/developers/api/v1/agents/
|
|
2022
|
+
return this.post("/developers/api/v1/agents/connect-payment", {});
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* RETIRED 2026-09-08 with Stripe. Throws instead of calling the server.
|
|
2026
|
+
*
|
|
2027
|
+
* `/agents/setup-payment` answers 410 Gone. Payment enrolment moved onto the same
|
|
2028
|
+
* Revolut rail as the rest of the product: call connectPayment() and open the
|
|
2029
|
+
* `connect_url` it returns.
|
|
2030
|
+
*
|
|
2031
|
+
* Kept as a method, and throwing locally rather than making the request, for the
|
|
2032
|
+
* same reason as unlock() — an older caller gets one clear sentence at the line that
|
|
2033
|
+
* is actually wrong, not a 410 body to decode and not a TypeError somewhere else.
|
|
2034
|
+
*/
|
|
2035
|
+
async setupPayment(_token) {
|
|
2036
|
+
throw new LetsFGError(
|
|
2037
|
+
"setupPayment() was retired on 2026-09-08 with Stripe and the endpoint answers 410 Gone. Call connectPayment() instead and open the connect_url it returns; nothing is charged to connect. See https://letsfg.co/developers/api/docs",
|
|
2038
|
+
410
|
|
2039
|
+
);
|
|
1889
2040
|
}
|
|
1890
2041
|
/**
|
|
1891
2042
|
* Get current agent profile and usage stats.
|
|
@@ -1994,6 +2145,7 @@ export {
|
|
|
1994
2145
|
ValidationError,
|
|
1995
2146
|
offerSummary,
|
|
1996
2147
|
cheapestOffer,
|
|
2148
|
+
HOTEL_BOOKING_FINAL_STATUSES,
|
|
1997
2149
|
LetsFG,
|
|
1998
2150
|
index_default,
|
|
1999
2151
|
BoostedTravel,
|