π Unofficial, dependency-free TypeScript client for the (reverse-engineered) private API of tutti.ch: search & filters, listings, seller profiles, categories, suggestions, live messaging, and Auth0 login.
β οΈ Not affiliated with tutti.ch. Reverse-engineered for interoperability and research. Respect tutti.ch's terms of service and rate limits β use at your own risk.
π Homepage
π Documentation
- π Fluent search with filters (category, price, location, intervals, single/multi-select) + cursor pagination
- π¦ Listings, seller profiles, categories, featured categories, search suggestions
- π¬ Live messaging β conversation & message streams as async iterators (send, read receipts, start a chat)
- π Auth0 login (authorization-code + PKCE) with a swappable captcha provider (manual, or Google Gemini vision)
- πΌοΈ Built-in dependency-free SVGβPNG engine (renders the login captcha for OCR)
- πΎ Pluggable session persistence (in-memory / file / your own store)
- π§© Object-oriented (one instance per account), zero runtime dependencies, ESM + CJS + types
npm install tutti-apiRequires Node 18+ (global fetch) or a browser.
import { TuttiClient } from "tutti-api";
const client = new TuttiClient(); // anonymous; random device hash
// Fluent search with filters
const result = await client
.search("ledersofa")
.category("furniture")
.price({ min: 100, max: 5000 }) // or .freeOnly()
.location(locality) // from client.localities.search()
.select("companyAd", "private") // generic single-select
.multiSelect("language", ["de"]) // generic multi-select
.interval("year", { min: 2015 }) // generic numeric range
.sort("timestamp", "desc")
.fetch();
result.totalCount; // number
result.listings; // Listing[] (this page)
result.availableFilters; // filter names/options for this category
await result.next(); // next page (or null)
for await (const l of result.paginate()) {
/* every listing across pages */
}
// Listing detail + locality autocomplete + token browse
const listing = await client.listings.get("81078697");
const locs = await client.localities.search("zΓΌr");
const page = await client.browse(searchToken).fetch();
// Filters for a category without fetching a listings page
const { availableFilters } = await client.search().category("cars").updateFilters();
// Categories, featured, seller profiles, autocomplete
await client.categories.tree();
await client.categories.featured();
await client.profiles.get(publicAccountID);
await client.profiles.listings(publicAccountID, { offset: 0, size: 30 });
await client.suggestions.search("sof");tutti uses Auth0, then exchanges the JWT for a tutti session token sent as X-Tutti-Auth (~1-year validity).
import { LLMCaptchaProvider, ManualCaptchaProvider, Session } from "tutti-api";
// Full login. The Auth0 page has a captcha, solved by a swappable provider:
// ManualCaptchaProvider (default) β saves the image, you type the text
// LLMCaptchaProvider β Google Gemini vision (needs GEMINI_API_KEY)
await client.account.login({ username, password, captcha: new LLMCaptchaProvider() });
// Or bring your own token / Auth0 access token:
client.account.useToken("mc1xβ¦");
await client.account.authenticateJWT(auth0AccessToken);Session.toJSON()/fromJSON() give a plain snapshot; a SessionStore persists it under a key. Providers are interchangeable β InMemorySessionStore and FileSessionStore ship; add Redis/DB by implementing save / load / delete / keys.
import { FileSessionStore, Session, TuttiClient } from "tutti-api";
const store = new FileSessionStore("./sessions");
await store.save("alice", client.session.toJSON());
const snap = await store.load("alice"); // restore later, no re-login
const restored = new TuttiClient({ session: snap ? Session.fromJSON(snap) : undefined });Live chat is streamed as NDJSON over a long-lived request, exposed as async iterators (backlog first, then live). Requires an authenticated session.
const ac = new AbortController();
for await (const m of client.messaging.streamMessages(convId, { signal: ac.signal })) {
const mine = m.senderPublicAccountId === client.session.auth?.accountId;
console.log(mine ? "β" : "β", m.content.text);
}
// ac.abort() to stop
await client.messaging.send(convId, "Hello!");
await client.messaging.markRead(convId, offset);
await client.messaging.reply({ itemId, name, email, body }); // start a chat from a listingStore a session once; every demo loads it from ./.tutti-sessions (gitignored):
# 1) store a session (token fast-path, or full login)
TUTTI_TOKEN=<X-Tutti-Auth> npm run demo:session
# or: GEMINI_API_KEY=β¦ TUTTI_USER=β¦ TUTTI_PASS=β¦ npm run demo:session
# 2) the rest load it (anonymous fallback if none)
npm run demo # search "ubiquiti" + pagination
npm run demo:queries # categories, featured, suggestions, updateFilters, profiles
npm run demo:messages # live conversation + message streams (needs auth)npm run build # bundle ESM + CJS + .d.ts into dist/
npm run typecheck # tsc --noEmit
npm run check # Biome lint + format (write)
npm run docs # generate API docs (TypeDoc) into docs/- Filter element shapes are inferred β captured requests only ever sent empty constraint arrays. Keyword search is unaffected; verify
price/location/interval/selectpayloads against live traffic. - Login captcha is interactive by default β the minted session token is long-lived (~1yr), so you log in rarely;
useToken()skips it.auth.tutti.chis behind Cloudflare. - Default headers mirror a captured Android client; override via
new TuttiClient({ app: { β¦ } }).
π€ Filippo Finke
- Website: https://filippofinke.ch
- Twitter: @filippofinke
- Github: @filippofinke
- LinkedIn: @filippofinke
Contributions, issues and feature requests are welcome!
Feel free to check the issues page. Commits follow Conventional Commits; releases are automated with release-please β merged commits accumulate into a Release PR that, once merged, tags the version and publishes to npm.
Give a βοΈ if this project helped you!
Copyright Β© 2026 Filippo Finke.
This project is MIT licensed.
Reverse-engineered for educational purposes β not affiliated with tutti.ch.