tutti-api - v2.1.0
    Preparing search index...

    tutti-api - v2.1.0

    Welcome to tutti-api πŸ‘‹

    Version Documentation License: MIT CI Twitter: filippofinke

    🌐 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.

    • πŸ” 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-api
    

    Requires 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 listing

    Store 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/select payloads 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.ch is behind Cloudflare.
    • Default headers mirror a captured Android client; override via new TuttiClient({ app: { … } }).

    πŸ‘€ Filippo Finke

    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!

    Buy Me A McFlurry

    Copyright Β© 2026 Filippo Finke.
    This project is MIT licensed.


    Reverse-engineered for educational purposes β€” not affiliated with tutti.ch.