Skip to content

filippofinke/tutti-api

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

71 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

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.

🏠 Homepage

πŸ“– Documentation

Features

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

Install

npm install tutti-api

Requires Node 18+ (global fetch) or a browser.

Usage

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");

Authentication

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 persistence

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 });

Messaging

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

Demos

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)

Scripts

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/

Caveats

  • 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: { … } }).

Author

πŸ‘€ Filippo Finke

🀝 Contributing

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.

Show your support

Give a ⭐️ if this project helped you!

Buy Me A McFlurry

πŸ“ License

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


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

About

πŸ›’ Unofficial, dependency free TypeScript SDK for the reverse engineered tutti.ch API: search, listings, profiles, and messaging.

Topics

Resources

License

Stars

Watchers

Forks

Contributors