// mrktplaats — command-line interface to the marktplaats.nl API // // Designed for use by both humans and LLM agents. // All output is JSON on stdout. Errors go to stderr with exit code 1. // // Token resolution order: // 1. MRKTPLAATS_TOKEN environment variable // 2. ~/.config/mrktplaats/token (JSON: {accessToken, refreshToken, expiresIn}) // 3. ./.mrktplaats-token in the current directory // // After login the token is written to ~/.config/mrktplaats/token automatically. // The token expires after ~12 hours; re-login when prompted. package main import ( "bytes" "context" "encoding/json" "flag" "fmt" "math" "os" "path/filepath" "strconv" "strings" "time" "github.com/joren/mrktplaats" ) // ── helpers ─────────────────────────────────────────────────────────────────── func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...) os.Exit(1) } func out(v any) { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") if err := enc.Encode(v); err != nil { fatalf("encoding output: %v", err) } } func ctx() context.Context { c, cancel := context.WithTimeout(context.Background(), 30*time.Second) // cancel is intentionally not deferred here — the CLI calls one operation // per invocation and exits immediately after, so the leak is benign. _ = cancel return c } // tokenPath returns the path to the persisted token file. func tokenPath() string { if cfg, err := os.UserConfigDir(); err == nil { return filepath.Join(cfg, "mrktplaats", "token") } return filepath.Join(os.Getenv("HOME"), ".config", "mrktplaats", "token") } // savedToken is the JSON structure stored in the token file. type savedToken struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` ExpiresIn string `json:"expiresIn"` } // loadTokenData reads the token file and returns parsed token data. // Supports both the legacy plain-text format (access token only) and the // current JSON format. func loadTokenData() *savedToken { var data []byte var err error if t := os.Getenv("MRKTPLAATS_TOKEN"); t != "" { return &savedToken{AccessToken: strings.TrimSpace(t)} } for _, p := range []string{tokenPath(), ".mrktplaats-token"} { if data, err = os.ReadFile(p); err == nil { break } } if data == nil { return nil } // Try JSON first (current format). var tok savedToken if err := json.Unmarshal(bytes.TrimSpace(data), &tok); err == nil && tok.AccessToken != "" { return &tok } // Fall back to legacy plain-text format. if t := strings.TrimSpace(string(data)); t != "" { return &savedToken{AccessToken: t} } return nil } // saveToken persists the full token payload as JSON to the config file. func saveToken(tok savedToken) error { p := tokenPath() if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { return err } b, err := json.Marshal(tok) if err != nil { return err } return os.WriteFile(p, append(b, '\n'), 0600) } // pendingPath returns the path used to persist a pending 2FA session between // the two login calls (first call triggers SMS; second call submits the code). func pendingPath() string { return filepath.Join(filepath.Dir(tokenPath()), "pending-2fa") } // pending2FA stores the requestId AND session parameters so the second CLI // invocation (with --code) uses the exact same session as the first. // The API requires matching session/gaClientId/magic_number/threatmetrix // between the login and verify-code requests. type pending2FA struct { RequestID string `json:"requestId"` Session string `json:"session"` GAClientID string `json:"gaClientId"` MagicNumber string `json:"magicNumber"` ThreatMetrixSessionID string `json:"threatMetrixSessionId"` } func savePending2FA(p pending2FA) error { path := pendingPath() if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } b, err := json.Marshal(p) if err != nil { return err } return os.WriteFile(path, append(b, '\n'), 0600) } func loadPending2FA() *pending2FA { data, err := os.ReadFile(pendingPath()) if err != nil { return nil } data = bytes.TrimSpace(data) if len(data) == 0 { return nil } // Try JSON format (current). var p pending2FA if err := json.Unmarshal(data, &p); err == nil && p.RequestID != "" { return &p } // Fall back to legacy plain-text format (requestId only). if id := strings.TrimSpace(string(data)); id != "" { return &pending2FA{RequestID: id} } return nil } func clearPending2FA() { _ = os.Remove(pendingPath()) } // authedClient returns a client with a valid token, auto-refreshing if expired. func authedClient() *mrktplaats.Client { tok := loadTokenData() if tok == nil || tok.AccessToken == "" { fatalf("no token found — run 'mrktplaats login --email E --password P' first,\n" + "or set MRKTPLAATS_TOKEN in the environment") } // Check expiry and auto-refresh if we have a refresh token. if tok.ExpiresIn != "" { if exp, err := time.Parse(time.RFC3339, tok.ExpiresIn); err == nil && time.Now().After(exp) { if tok.RefreshToken == "" { fatalf("access token expired at %s — please re-login:\n"+ " mrktplaats login --email EMAIL --password PASS", exp.Local().Format("2006-01-02 15:04:05")) } // Attempt silent refresh. tmpClient := mrktplaats.NewClient() newTok, err := tmpClient.Auth.RefreshToken(ctx(), tok.RefreshToken) if err != nil { fatalf("token refresh failed (please re-login): %v", err) } updated := savedToken{ AccessToken: newTok.AccessToken, RefreshToken: newTok.RefreshToken, ExpiresIn: newTok.ExpiresIn, } if err := saveToken(updated); err != nil { fmt.Fprintf(os.Stderr, "warning: could not save refreshed token: %v\n", err) } tok = &updated } } return mrktplaats.NewClient(mrktplaats.WithAccessToken(tok.AccessToken)) } // eurosToC converts a euro string ("20", "20.50") to euro cents. func eurosToC(s string) int { f, err := strconv.ParseFloat(s, 64) if err != nil { fatalf("invalid amount %q: must be a number in euros (e.g. 20 or 20.50)", s) } return int(math.Round(f * 100)) } // ── commands ────────────────────────────────────────────────────────────────── // login — authenticate and save token // // mrktplaats login --email EMAIL --password PASS func cmdLogin(args []string) { fs := flag.NewFlagSet("login", flag.ExitOnError) email := fs.String("email", "", "account e-mail address (required)") pass := fs.String("password", "", "account password (required)") code := fs.String("code", "", "SMS 2FA code (only needed when 2FA is triggered)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats login --email EMAIL --password PASS [--code 2FA_CODE] Authenticate with marktplaats.nl and save the access token. If the account has 2FA enabled the call will return with a "verification_required" status. Re-run the command with --code CODE after receiving the SMS. Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *email == "" || *pass == "" { fs.Usage() os.Exit(1) } client := mrktplaats.NewClient() // If the user provided --code, check whether we saved a pending 2FA session // from the previous login call. If so, create the client with the SAME session // parameters and go straight to VerifyCode — calling Login again would generate // a new requestId, invalidating the SMS code the user just received. if *code != "" { pending := loadPending2FA() if pending == nil || pending.RequestID == "" { fatalf("no pending 2FA session — run login without --code first to trigger the SMS:\n" + " mrktplaats login --email EMAIL --password PASS") } // Restore the session parameters from the login call so the API sees // the same session/gaClientId/magic_number/threatmetrix as before. var opts []mrktplaats.Option if pending.Session != "" { opts = append(opts, mrktplaats.WithSession(pending.Session)) } if pending.GAClientID != "" { opts = append(opts, mrktplaats.WithGAClientID(pending.GAClientID)) } if pending.MagicNumber != "" { opts = append(opts, mrktplaats.WithMagicNumber(pending.MagicNumber)) } if pending.ThreatMetrixSessionID != "" { opts = append(opts, mrktplaats.WithThreatMetrixSessionID(pending.ThreatMetrixSessionID)) } client = mrktplaats.NewClient(opts...) auth, err := client.Auth.VerifyCode(ctx(), pending.RequestID, *code) if err != nil { fatalf("2FA verification failed: %v", err) } clearPending2FA() if err := saveToken(savedToken{AccessToken: auth.Auth.AccessToken, RefreshToken: auth.Auth.RefreshToken, ExpiresIn: auth.Auth.ExpiresIn}); err != nil { fmt.Fprintf(os.Stderr, "warning: could not save token: %v\n", err) } out(map[string]any{ "status": "ok", "token_saved": tokenPath(), "access_token": auth.Auth.AccessToken, "expires_in": auth.Auth.ExpiresIn, "user": auth.User, }) return } login, err := client.Auth.Login(ctx(), *email, *pass) if err != nil { fatalf("login failed: %v", err) } // Direct auth — no 2FA if login.Auth != nil { clearPending2FA() if err := saveToken(savedToken{AccessToken: login.Auth.AccessToken, RefreshToken: login.Auth.RefreshToken, ExpiresIn: login.Auth.ExpiresIn}); err != nil { fmt.Fprintf(os.Stderr, "warning: could not save token: %v\n", err) } out(map[string]any{ "status": "ok", "token_saved": tokenPath(), "access_token": login.Auth.AccessToken, "expires_in": login.Auth.ExpiresIn, }) return } // 2FA required if login.Verification == nil { fatalf("unexpected login response: no auth and no verification") } // Persist the requestId AND session parameters so the next invocation // (with --code) can recreate the client with identical session values. sp := client.SessionParams() if err := savePending2FA(pending2FA{ RequestID: login.Verification.RequestID, Session: sp.Session, GAClientID: sp.GAClientID, MagicNumber: sp.MagicNumber, ThreatMetrixSessionID: sp.ThreatMetrixSessionID, }); err != nil { fmt.Fprintf(os.Stderr, "warning: could not save pending 2FA state: %v\n", err) } out(map[string]any{ "status": "verification_required", "method": login.Verification.Method, "message": login.Verification.Message, "next_step": "re-run with --code CODE after receiving the SMS", }) } // search — search listings // // mrktplaats search --query Q [--category ID] [--page N] [--size N] [--sort FIELD] func cmdSearch(args []string) { fs := flag.NewFlagSet("search", flag.ExitOnError) query := fs.String("query", "", "search query (required)") category := fs.Int("category", 0, "category ID filter (0 = all categories)") page := fs.Int("page", 1, "page number (1-based)") size := fs.Int("size", 20, "results per page (max 100)") sort := fs.String("sort", "SORT_INDEX", "sort order: SORT_INDEX | DATE_DESC | PRICE_ASC | PRICE_DESC") sellerID := fs.String("seller", "", "filter by seller ID") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats search --query Q [options] Search listings. Does not require authentication. Output: {"total": N, "listings": [{urn, title, price_cents, price_label, city, seller_name, url}]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *query == "" { fs.Usage() os.Exit(1) } client := mrktplaats.NewClient() req := &mrktplaats.SearchRequest{ Query: *query, Page: *page, Size: *size, SortBy: *sort, AllowCorrection: true, SearchOnTitleAndDescription: true, ShowListings: true, SupportsReservedFlag: true, } if *category != 0 { req.CategoryID = category } if *sellerID != "" { req.SellerID = *sellerID } resp, err := client.Search.Fetch(ctx(), req, nil) if err != nil { fatalf("search failed: %v", err) } type listingResult struct { URN string `json:"urn"` Title string `json:"title"` PriceCents int `json:"price_cents"` PriceLabel string `json:"price_label"` PriceType string `json:"price_type"` City string `json:"city"` SellerName string `json:"seller_name"` URL string `json:"url"` } seen := map[string]bool{} var listings []listingResult for _, item := range resp.Items { l := item.Listing() if l == nil || seen[l.AdCore.URN] { continue } seen[l.AdCore.URN] = true listings = append(listings, listingResult{ URN: l.AdCore.URN, Title: l.AdCore.Title, PriceCents: l.AdCore.Price.PriceAmount, PriceLabel: l.AdCore.Price.PriceTypeLabel, PriceType: l.AdCore.Price.PriceType, City: l.AdCore.AdAddress.City, SellerName: l.SellerInformation.Name, URL: l.URL, }) } total := 0 if resp.SearchHistograms != nil { total = resp.SearchHistograms.NumFound } out(map[string]any{ "total": total, "page": *page, "size": *size, "listings": listings, }) } // listing — get full listing details // // mrktplaats listing --urn URN func cmdListing(args []string) { fs := flag.NewFlagSet("listing", flag.ExitOnError) urn := fs.String("urn", "", "listing URN, e.g. m2372861012 (required)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats listing --urn URN Fetch full details for a listing: title, description, price, seller, pictures, attributes, bids. Requires authentication (some fields are auth-only). Output: full ListingDetail object Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *urn == "" { fs.Usage() os.Exit(1) } client := authedClient() detail, err := client.Listings.Get(ctx(), *urn) if err != nil { fatalf("listing %s: %v", *urn, err) } out(detail) } // conversations — list all conversations // // mrktplaats conversations [--limit N] func cmdConversations(args []string) { fs := flag.NewFlagSet("conversations", flag.ExitOnError) limit := fs.Int("limit", 50, "max conversations to return") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats conversations [--limit N] List all messaging conversations for the authenticated user. Output: {"total": N, "unread": N, "conversations": [{id, item_urn, title, other_party, unread, latest_message}]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) client := authedClient() resp, err := client.Messaging.Conversations(ctx(), &mrktplaats.ConversationsOptions{Limit: *limit}) if err != nil { fatalf("conversations: %v", err) } type convResult struct { ID string `json:"id"` ItemURN string `json:"item_urn"` Title string `json:"title"` OtherParty string `json:"other_party"` OtherPartyID int `json:"other_party_id"` Unread int `json:"unread"` LatestMessage string `json:"latest_message"` } var convs []convResult for _, c := range resp.Conversations { latest := "" if lm := c.LatestMessage(); lm != nil { latest = lm.Text } convs = append(convs, convResult{ ID: c.ID, ItemURN: c.ItemID, Title: c.Title, OtherParty: c.OtherParticipant.Name, OtherPartyID: c.OtherParticipant.ID, Unread: c.UnreadMessagesCount, LatestMessage: latest, }) } out(map[string]any{ "total": resp.TotalCount, "unread": resp.UnreadMessagesCount, "conversations": convs, }) } // messages — read messages in a conversation // // mrktplaats messages --conv CONV_ID [--limit N] [--offset N] func cmdMessages(args []string) { fs := flag.NewFlagSet("messages", flag.ExitOnError) convID := fs.String("conv", "", "conversation ID, e.g. 1cgx:5qgvx3h:2p1gxt95d (required)") limit := fs.Int("limit", 50, "max messages to return") offset := fs.Int("offset", 0, "skip first N messages") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats messages --conv CONV_ID [--limit N] [--offset N] Read messages in a conversation. Use 'mrktplaats conversations' to find conversation IDs. Output: {"total": N, "messages": [{id, sender_id, text, date, type, read}]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *convID == "" { fs.Usage() os.Exit(1) } client := authedClient() resp, err := client.Messaging.Messages(ctx(), *convID, *offset, *limit) if err != nil { fatalf("messages: %v", err) } type msgResult struct { ID string `json:"id"` SenderID int `json:"sender_id"` Text string `json:"text"` Date time.Time `json:"date"` Type string `json:"type"` Read bool `json:"read"` } var msgs []msgResult for _, m := range resp.Messages { msgs = append(msgs, msgResult{ ID: m.ID, SenderID: m.SenderID, Text: m.Text, Date: m.ReceivedDate, Type: m.MessageType, Read: m.IsRead, }) } out(map[string]any{ "total": resp.TotalCount, "messages": msgs, }) } // send — start a new conversation about a listing // // mrktplaats send --urn URN --text TEXT func cmdSend(args []string) { fs := flag.NewFlagSet("send", flag.ExitOnError) urn := fs.String("urn", "", "listing URN to message the seller about (required)") text := fs.String("text", "", "message text (required)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats send --urn URN --text TEXT Start a new conversation with the seller of a listing. To reply to an existing conversation use 'mrktplaats reply'. Output: {"conversation_id": "...", "status": "sent"} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *urn == "" || *text == "" { fs.Usage() os.Exit(1) } client := authedClient() // Fetch the listing to get the seller ID required by the enquiry endpoint. detail, err := client.Listings.Get(ctx(), *urn) if err != nil { fatalf("send: fetching listing %s: %v", *urn, err) } sellerID := fmt.Sprintf("%d", detail.SellerInformation.ID) // Use the ASQ (Ask Seller a Question) enquiry endpoint. // This works for both first contact and repeat messages — the API always returns 204. if err := client.Enquiry.AskQuestion(ctx(), *urn, sellerID, *text); err != nil { fatalf("send: %v", err) } out(map[string]any{ "status": "sent", "item_urn": *urn, "seller_id": sellerID, "seller_name": detail.SellerInformation.Name, }) } // reply — send a message in an existing conversation // // mrktplaats reply --conv CONV_ID --text TEXT func cmdReply(args []string) { fs := flag.NewFlagSet("reply", flag.ExitOnError) convID := fs.String("conv", "", "conversation ID (required)") text := fs.String("text", "", "message text (required)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats reply --conv CONV_ID --text TEXT Send a message in an existing conversation. Use 'mrktplaats conversations' to find conversation IDs. Output: {"message_id": "...", "conversation_id": "...", "status": "sent"} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *convID == "" || *text == "" { fs.Usage() os.Exit(1) } client := authedClient() msgID, err := client.Messaging.SendMessage(ctx(), *convID, *text) if err != nil { fatalf("reply: %v", err) } out(map[string]any{ "message_id": msgID, "conversation_id": *convID, "status": "sent", }) } // bid — place a bid on a listing // // mrktplaats bid --urn URN --amount EUROS [--message TEXT] func cmdBid(args []string) { fs := flag.NewFlagSet("bid", flag.ExitOnError) urn := fs.String("urn", "", "listing URN (required)") amount := fs.String("amount", "", "bid amount in euros, e.g. 20 or 20.50 (required)") message := fs.String("message", "", "optional personal message to the seller") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats bid --urn URN --amount EUROS [--message TEXT] Place a bid on a listing. The listing must have priceType MIN_BID or FAST_BID. Amount is in euros (e.g. --amount 20 for €20.00). Output: {"bids": [...], "current_minimum_bid_cents": N, "current_minimum_bid_euros": N} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *urn == "" || *amount == "" { fs.Usage() os.Exit(1) } cents := eurosToC(*amount) client := authedClient() resp, err := client.Enquiry.PlaceBid(ctx(), *urn, &mrktplaats.PlaceBidRequest{ Value: cents, PersonalMessage: *message, }) if err != nil { fatalf("bid: %v", err) } out(map[string]any{ "bids": resp.Bids, "current_minimum_bid_cents": resp.CurrentMinimumBid, "current_minimum_bid_euros": float64(resp.CurrentMinimumBid) / 100, }) } // remove-bid — remove an existing bid // // mrktplaats remove-bid --id BID_ID func cmdRemoveBid(args []string) { fs := flag.NewFlagSet("remove-bid", flag.ExitOnError) id := fs.Int("id", 0, "bid ID to remove (required; find it in the 'bids' array from 'bid' or 'listing')") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats remove-bid --id BID_ID Remove a bid you previously placed. Output: {"bids": [...], "current_minimum_bid_euros": N} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *id == 0 { fs.Usage() os.Exit(1) } client := authedClient() resp, err := client.Enquiry.RemoveBid(ctx(), *id) if err != nil { fatalf("remove-bid: %v", err) } out(map[string]any{ "bids": resp.Bids, "current_minimum_bid_cents": resp.CurrentMinimumBid, "current_minimum_bid_euros": float64(resp.CurrentMinimumBid) / 100, }) } // favorites — list saved listings // // mrktplaats favorites [--limit N] func cmdFavorites(args []string) { fs := flag.NewFlagSet("favorites", flag.ExitOnError) limit := fs.Int("limit", 100, "max favorites to return") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats favorites [--limit N] List the authenticated user's saved/favourite listings. Output: {"total": N, "items": [{item_urn, saved_at}]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) client := authedClient() resp, err := client.Favorites.List(ctx(), *limit) if err != nil { fatalf("favorites: %v", err) } type favResult struct { ItemURN string `json:"item_urn"` SavedAt string `json:"saved_at"` } var items []favResult for _, f := range resp.Items { items = append(items, favResult{ItemURN: f.ItemID, SavedAt: f.CreationDate}) } out(map[string]any{ "total": resp.Total, "more": resp.MoreItemsAvailable, "items": items, }) } // add-favorite — save a listing to favourites // // mrktplaats add-favorite --urn URN func cmdAddFavorite(args []string) { fs := flag.NewFlagSet("add-favorite", flag.ExitOnError) urn := fs.String("urn", "", "listing URN (required)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats add-favorite --urn URN Add a listing to the authenticated user's favourites. Output: {"status": "added", "item_urn": "..."} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *urn == "" { fs.Usage() os.Exit(1) } client := authedClient() if _, err := client.Favorites.Add(ctx(), *urn); err != nil { fatalf("add-favorite: %v", err) } out(map[string]any{"status": "added", "item_urn": *urn}) } // remove-favorite — remove a listing from favourites // // mrktplaats remove-favorite --urn URN func cmdRemoveFavorite(args []string) { fs := flag.NewFlagSet("remove-favorite", flag.ExitOnError) urn := fs.String("urn", "", "listing URN (required)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats remove-favorite --urn URN Remove a listing from the authenticated user's favourites. Output: {"status": "removed", "item_urn": "..."} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *urn == "" { fs.Usage() os.Exit(1) } client := authedClient() if err := client.Favorites.Remove(ctx(), *urn); err != nil { fatalf("remove-favorite: %v", err) } out(map[string]any{"status": "removed", "item_urn": *urn}) } // me — show the authenticated user's profile // // mrktplaats me func cmdMe(args []string) { _ = args // no flags client := authedClient() user, err := client.Users.Me(ctx()) if err != nil { fatalf("me: %v", err) } out(user) } // my-ads — list the authenticated user's own listings // // mrktplaats my-ads [--status active|inactive|sold] [--limit N] func cmdMyAds(args []string) { fs := flag.NewFlagSet("my-ads", flag.ExitOnError) status := fs.String("status", "active", "filter by status: active | inactive | sold") limit := fs.Int("limit", 50, "max listings to return") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats my-ads [--status STATUS] [--limit N] List the authenticated user's own listings. Output: {"total": N, "ads": [{item_id, title, status, price_cents, price_euros}]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) client := authedClient() resp, err := client.MyAccount.MyAds(ctx(), &mrktplaats.MyAdsOptions{ Status: *status, Limit: *limit, Counts: true, }) if err != nil { fatalf("my-ads: %v", err) } type adResult struct { ItemID string `json:"item_id"` Title string `json:"title"` Status string `json:"status"` PriceCents int `json:"price_cents"` PriceEuros float64 `json:"price_euros"` } var ads []adResult for _, a := range resp.MyAds { ads = append(ads, adResult{ ItemID: a.ItemID, Title: a.Title, Status: a.Status, PriceCents: a.PriceInCents, PriceEuros: float64(a.PriceInCents) / 100, }) } out(map[string]any{ "total": resp.MyAdsTotalCount, "ads": ads, }) } // delete-ad — delete one of the authenticated user's listings // // mrktplaats delete-ad --urn URN [--reason 1|2|3|4] func cmdDeleteAd(args []string) { fs := flag.NewFlagSet("delete-ad", flag.ExitOnError) urn := fs.String("urn", "", "listing URN to delete (required)") reason := fs.Int("reason", 4, "delete reason: 1=sold on marktplaats, 2=sold elsewhere, 3=no longer selling, 4=other") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats delete-ad --urn URN [--reason N] Delete one of the authenticated user's own listings. Reasons: 1 — sold on marktplaats.nl 2 — sold elsewhere 3 — no longer selling 4 — other (default) Output: {"status": "deleted", "item_urn": "..."} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *urn == "" { fs.Usage() os.Exit(1) } client := authedClient() if err := client.MyAccount.DeleteAds(ctx(), []string{*urn}, mrktplaats.DeleteAdReason(*reason)); err != nil { fatalf("delete-ad: %v", err) } out(map[string]any{"status": "deleted", "item_urn": *urn}) } // seller-listings — all listings by a seller // // mrktplaats seller-listings --seller SELLER_ID [--size N] [--page N] // mrktplaats seller-listings --urn URN [--size N] [--page N] func cmdSellerListings(args []string) { fs := flag.NewFlagSet("seller-listings", flag.ExitOnError) sellerID := fs.String("seller", "", "numeric seller ID (e.g. 57506580)") urn := fs.String("urn", "", "any listing URN from this seller — seller ID is resolved automatically") size := fs.Int("size", 50, "max results per page") page := fs.Int("page", 1, "page number") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats seller-listings --seller ID [--size N] [--page N] mrktplaats seller-listings --urn URN [--size N] [--page N] List all active listings by a seller. Provide either the seller's numeric ID directly (--seller) or any listing URN belonging to that seller (--urn) and the seller ID will be resolved automatically from the listing details. Output: {"seller_id": "...", "seller_name": "...", "total": N, "listings": [...]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *sellerID == "" && *urn == "" { fs.Usage() os.Exit(1) } client := authedClient() resolvedName := "" if *sellerID == "" { detail, err := client.Listings.Get(ctx(), *urn) if err != nil { fatalf("seller-listings: fetching listing %s: %v", *urn, err) } *sellerID = fmt.Sprintf("%d", detail.SellerInformation.ID) resolvedName = detail.SellerInformation.Name } resp, err := client.Search.Fetch(ctx(), &mrktplaats.SearchRequest{ Query: "", SellerID: *sellerID, Page: *page, Size: *size, SortBy: "DATE_DESC", ShowListings: true, SupportsReservedFlag: true, SearchOnTitleAndDescription: true, }, nil) if err != nil { fatalf("seller-listings: %v", err) } type listingResult struct { URN string `json:"urn"` Title string `json:"title"` PriceCents int `json:"price_cents"` PriceLabel string `json:"price_label"` PriceType string `json:"price_type"` City string `json:"city"` } seen := map[string]bool{} var listings []listingResult for _, item := range resp.Items { l := item.Listing() if l == nil || seen[l.AdCore.URN] { continue } seen[l.AdCore.URN] = true if resolvedName == "" { resolvedName = l.SellerInformation.Name } listings = append(listings, listingResult{ URN: l.AdCore.URN, Title: l.AdCore.Title, PriceCents: l.AdCore.Price.PriceAmount, PriceLabel: l.AdCore.Price.PriceTypeLabel, PriceType: l.AdCore.Price.PriceType, City: l.AdCore.AdAddress.City, }) } total := 0 if resp.SearchHistograms != nil { total = resp.SearchHistograms.NumFound } out(map[string]any{ "seller_id": *sellerID, "seller_name": resolvedName, "total": total, "page": *page, "listings": listings, }) } // reviews — view reviews for a seller // // mrktplaats reviews --seller SELLER_ID // mrktplaats reviews --urn URN func cmdReviews(args []string) { fs := flag.NewFlagSet("reviews", flag.ExitOnError) sellerID := fs.Int("seller", 0, "numeric seller ID (e.g. 57506580)") urn := fs.String("urn", "", "any listing URN — seller ID is resolved automatically") role := fs.String("role", "reviewee", "filter: reviewee (received) or all") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats reviews --seller ID [--role reviewee|all] mrktplaats reviews --urn URN [--role reviewee|all] View reviews for a seller. Provide either the seller's numeric ID directly (--seller) or any listing URN belonging to that seller (--urn) and the seller ID will be resolved automatically from the listing details. Output: {"seller_id": N, "summary": {average_score, count}, "reviews": [...]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *sellerID == 0 && *urn == "" { fs.Usage() os.Exit(1) } client := authedClient() if *sellerID == 0 { detail, err := client.Listings.Get(ctx(), *urn) if err != nil { fatalf("reviews: fetching listing %s: %v", *urn, err) } *sellerID = detail.SellerInformation.ID } var reviewRole mrktplaats.ReviewRole switch *role { case "reviewee": reviewRole = mrktplaats.ReviewRoleReviewee case "all", "": reviewRole = mrktplaats.ReviewRoleAll default: fatalf("invalid --role %q: must be 'reviewee' or 'all'", *role) } resp, err := client.Users.Reviews(ctx(), *sellerID, reviewRole) if err != nil { fatalf("reviews: %v", err) } type reviewResult struct { ID int `json:"id"` Score int `json:"score"` ReviewerName string `json:"reviewer_name"` ReviewerID int `json:"reviewer_id"` Subject string `json:"subject"` Direction string `json:"direction"` ItemTitle string `json:"item_title"` ItemID string `json:"item_id"` Date string `json:"date"` Details []detailResult `json:"details,omitempty"` } type summaryResult struct { AverageScore float64 `json:"average_score"` Count int `json:"count"` } var reviews []reviewResult for _, r := range resp.Reviews { var details []detailResult for _, d := range r.Details { var chars []string for _, c := range d.Characteristics { prefix := "+" if !c.IsPositive { prefix = "-" } chars = append(chars, prefix+c.Text) } details = append(details, detailResult{ Category: d.Category, Score: d.Score, Traits: chars, }) } reviews = append(reviews, reviewResult{ ID: r.ID, Score: r.Score, ReviewerName: r.Reviewer.Nickname, ReviewerID: r.Reviewer.ID, Subject: r.Content.Subject, Direction: r.Direction, ItemTitle: r.Advertisement.Title, ItemID: r.Advertisement.ID, Date: r.CreationDate.Time.Format("2006-01-02"), Details: details, }) } out(map[string]any{ "seller_id": *sellerID, "summary": summaryResult{ AverageScore: resp.Summary.AverageScore, Count: resp.Summary.NumberOfReviews, }, "reviews": reviews, }) } type detailResult struct { Category string `json:"category"` Score int `json:"score"` Traits []string `json:"traits,omitempty"` } // categories — list full category tree // // mrktplaats categories func cmdCategories(args []string) { fs := flag.NewFlagSet("categories", flag.ExitOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats categories List the full category tree. Does not require authentication. Output: {"categories": [{category_id, name, children: [...], place_ad_allowed}]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) client := mrktplaats.NewClient() resp, err := client.Categories.All(ctx()) if err != nil { fatalf("categories: %v", err) } out(resp) } // similar — find similar/related listings // // mrktplaats similar --urn URN [--seller SELLER_ID] [--category CATEGORY_ID] func cmdSimilar(args []string) { fs := flag.NewFlagSet("similar", flag.ExitOnError) urn := fs.String("urn", "", "listing URN (required)") sellerID := fs.String("seller", "", "seller ID (resolved from listing if omitted)") categoryID := fs.Int("category", 0, "category ID (resolved from listing if omitted)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats similar --urn URN [--seller ID] [--category ID] Find similar/related listings for a given listing. If --seller and --category are omitted they are resolved automatically from the listing details. Output: {"items": [{id, title, price, page_location}], "result_type": "..."} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *urn == "" { fs.Usage() os.Exit(1) } client := authedClient() // Resolve seller and category from the listing if not provided. if *sellerID == "" || *categoryID == 0 { detail, err := client.Listings.Get(ctx(), *urn) if err != nil { fatalf("similar: fetching listing %s: %v", *urn, err) } if *sellerID == "" { *sellerID = fmt.Sprintf("%d", detail.SellerInformation.ID) } if *categoryID == 0 { *categoryID = detail.AdCore.CategoryID } } // The Relevant.Get API uses the numeric listing ID, not the full URN. // Strip the "m" prefix if present (e.g. "m2372861012" -> "2372861012"). listingID := strings.TrimPrefix(*urn, "m") resp, err := client.Relevant.Get(ctx(), listingID, *sellerID, *categoryID) if err != nil { fatalf("similar: %v", err) } out(resp) } // suggest — search autocomplete suggestions // // mrktplaats suggest --prefix TEXT [--category ID] func cmdSuggest(args []string) { fs := flag.NewFlagSet("suggest", flag.ExitOnError) prefix := fs.String("prefix", "", "search prefix (required)") category := fs.Int("category", 0, "category ID (0 = all)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats suggest --prefix TEXT [--category ID] Get search autocomplete suggestions. Does not require authentication. Output: {"keyword": "...", "category": N, "suggestions": ["...", ...]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *prefix == "" { fs.Usage() os.Exit(1) } client := mrktplaats.NewClient() resp, err := client.Search.KeywordSuggestions(ctx(), *prefix, *category) if err != nil { fatalf("suggest: %v", err) } out(resp) } // notifications — unread notification count // // mrktplaats notifications func cmdNotifications(args []string) { fs := flag.NewFlagSet("notifications", flag.ExitOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats notifications Show unread notification count. Requires authentication. Output: {"unread": N} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) client := authedClient() resp, err := client.Notifications.UnreadCount(ctx()) if err != nil { fatalf("notifications: %v", err) } out(map[string]any{ "unread": resp.UnreadNotificationsCount, }) } // create-ad — publish a new listing // // mrktplaats create-ad --category ID --title T --description D --price EUROS [options] func cmdCreateAd(args []string) { fs := flag.NewFlagSet("create-ad", flag.ExitOnError) categoryID := fs.Int("category", 0, "category ID (required — use 'syi-form' to find)") title := fs.String("title", "", "listing title (required)") description := fs.String("description", "", "listing description (required)") price := fs.String("price", "", "price in euros, e.g. 25 or 25.50 (required)") priceType := fs.String("price-type", "FIXED", "price type: FIXED | NEGOTIABLE | MIN_BID | FAST_BID | FREE | SEE_DESCRIPTION") delivery := fs.String("delivery", "PICKUP", "delivery method: PICKUP | SHIP | PICKUP_AND_SHIP") sellerName := fs.String("seller-name", "", "seller display name (required)") postcode := fs.String("postcode", "", "postcode (required)") pictureIDs := fs.String("pictures", "", "comma-separated picture IDs from 'upload-image'") bidding := fs.Bool("bidding", false, "enable bidding") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats create-ad --category ID --title T --description D --price EUROS [options] Publish a new listing. Requires authentication. Use 'mrktplaats syi-form --category ID' to see required attributes for a category. Use 'mrktplaats upload-image --file PATH' to upload images first. Output: full CreateAdResponse with URN of the new listing Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *categoryID == 0 || *title == "" || *description == "" || *price == "" || *sellerName == "" || *postcode == "" { fs.Usage() os.Exit(1) } var picIDs []string if *pictureIDs != "" { picIDs = strings.Split(*pictureIDs, ",") } req := &mrktplaats.CreateAdRequest{ CategoryID: *categoryID, Translations: []mrktplaats.Translation{{ Locale: "nl", Title: *title, Description: *description, }}, PriceInCents: eurosToC(*price), PriceType: *priceType, BiddingEnabled: *bidding, DeliveryMethod: *delivery, SellerName: *sellerName, Postcode: *postcode, PictureIDs: picIDs, Attributes: []any{}, FeatureTypes: []any{}, ShippingConfig: mrktplaats.ShippingConfig{Carriers: []string{}}, SelectedBundle: "FREE", } client := authedClient() resp, err := client.SYI.Create(ctx(), req) if err != nil { fatalf("create-ad: %v", err) } out(map[string]any{ "status": "created", "urn": resp.URN(), "ad": resp.Ad, }) } // upload-image — upload an image for a listing // // mrktplaats upload-image --file PATH func cmdUploadImage(args []string) { fs := flag.NewFlagSet("upload-image", flag.ExitOnError) filePath := fs.String("file", "", "path to image file (required)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats upload-image --file PATH Upload an image for use in a listing. Returns a picture ID to pass to 'create-ad --pictures'. Requires authentication. Large images are automatically compressed. Output: {"picture_id": "..."} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *filePath == "" { fs.Usage() os.Exit(1) } f, err := os.Open(*filePath) if err != nil { fatalf("upload-image: opening file: %v", err) } defer f.Close() filename := filepath.Base(*filePath) client := authedClient() picID, err := client.SYI.UploadImage(ctx(), f, filename) if err != nil { fatalf("upload-image: %v", err) } out(map[string]any{ "picture_id": picID, }) } // syi-form — show required attributes for a category // // mrktplaats syi-form --category ID func cmdSYIForm(args []string) { fs := flag.NewFlagSet("syi-form", flag.ExitOnError) categoryID := fs.Int("category", 0, "category ID (required)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats syi-form --category ID Show the listing creation form (required/optional attributes) for a category. Requires authentication. Output: {"category_id": N, "attributes": [{id, key, label, type, mandatory, values: [...]}]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *categoryID == 0 { fs.Usage() os.Exit(1) } client := authedClient() resp, err := client.SYI.Form(ctx(), *categoryID) if err != nil { fatalf("syi-form: %v", err) } type attrResult struct { ID string `json:"id"` Key string `json:"key"` Label string `json:"label"` Type string `json:"type"` Mandatory bool `json:"mandatory"` Values []struct { ID string `json:"id"` Key string `json:"key"` Label string `json:"label"` } `json:"values,omitempty"` } var attrs []attrResult for _, a := range resp.SYIAttributes { ar := attrResult{ ID: a.ID, Key: a.Key, Label: a.Label, Type: a.AttributeType, Mandatory: a.Mandatory, } for _, v := range a.SupportedValues { ar.Values = append(ar.Values, struct { ID string `json:"id"` Key string `json:"key"` Label string `json:"label"` }{ID: v.ID, Key: v.Key, Label: v.Label}) } attrs = append(attrs, ar) } out(map[string]any{ "category_id": resp.CategoryID, "attributes": attrs, }) } // price-suggestion — get suggested price ranges // // mrktplaats price-suggestion --category ID --title TEXT func cmdPriceSuggestion(args []string) { fs := flag.NewFlagSet("price-suggestion", flag.ExitOnError) categoryID := fs.Int("category", 0, "category ID (required)") title := fs.String("title", "", "listing title (required)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats price-suggestion --category ID --title TEXT Get suggested price ranges for a listing based on similar items. Does not require authentication. Output: {"segments": [{title, min_price_cents, max_price_cents, min_euros, max_euros, similar_count}]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *categoryID == 0 || *title == "" { fs.Usage() os.Exit(1) } client := mrktplaats.NewClient() resp, err := client.SYI.PriceSuggestion(ctx(), *categoryID, *title) if err != nil { fatalf("price-suggestion: %v", err) } type segResult struct { Title string `json:"title"` MinPriceCents int `json:"min_price_cents"` MaxPriceCents int `json:"max_price_cents"` MinEuros float64 `json:"min_euros"` MaxEuros float64 `json:"max_euros"` SimilarCount int `json:"similar_count"` } var segs []segResult for _, s := range resp.Segments { segs = append(segs, segResult{ Title: s.Title, MinPriceCents: s.MinPrice, MaxPriceCents: s.MaxPrice, MinEuros: float64(s.MinPrice) / 100, MaxEuros: float64(s.MaxPrice) / 100, SimilarCount: s.TotalSimilarAdsCount, }) } out(map[string]any{ "segments": segs, }) } // recognize — auto-detect category from a photo // // mrktplaats recognize --file PATH func cmdRecognize(args []string) { fs := flag.NewFlagSet("recognize", flag.ExitOnError) filePath := fs.String("file", "", "path to image file (required)") fs.Usage = func() { fmt.Fprintln(os.Stderr, `usage: mrktplaats recognize --file PATH Upload an image and get category/attribute predictions via image recognition. Does not require authentication. Large images are automatically compressed. Output: {"predictions": [{category_id, category_name, confidence, attributes}]} Flags:`) fs.PrintDefaults() } _ = fs.Parse(args) if *filePath == "" { fs.Usage() os.Exit(1) } f, err := os.Open(*filePath) if err != nil { fatalf("recognize: opening file: %v", err) } defer f.Close() filename := filepath.Base(*filePath) client := mrktplaats.NewClient() resp, err := client.Saleability.Recognize(ctx(), f, filename) if err != nil { fatalf("recognize: %v", err) } out(resp) } // ── usage ───────────────────────────────────────────────────────────────────── func printUsage() { fmt.Fprint(os.Stderr, `mrktplaats — marktplaats.nl command-line client USAGE mrktplaats [flags] AUTHENTICATION Token resolution order: 1. MRKTPLAATS_TOKEN environment variable 2. ~/.config/mrktplaats/token (written automatically after login) 3. ./.mrktplaats-token in the current directory mrktplaats login --email EMAIL --password PASS [--code 2FA_CODE] COMMANDS Authentication login Authenticate and save access token Browsing (no auth required) search Search listings --query Q [--category ID] [--page N] [--size N] [--sort FIELD] listing Get listing details --urn URN seller-listings All listings by a seller --seller ID or --urn URN reviews View seller reviews --seller ID or --urn URN [--role reviewee|all] categories List full category tree similar Find similar listings --urn URN [--seller ID] [--category ID] suggest Search autocomplete --prefix TEXT [--category ID] Messaging conversations List conversations [--limit N] messages Read conversation messages --conv CONV_ID [--limit N] send Start a new conversation --urn URN --text TEXT reply Reply in a conversation --conv CONV_ID --text TEXT Bidding bid Place a bid --urn URN --amount EUROS [--message TEXT] remove-bid Remove a bid --id BID_ID Favourites favorites List saved listings [--limit N] add-favorite Save a listing --urn URN remove-favorite Unsave a listing --urn URN My Account me Show own profile my-ads List own listings [--status active|inactive|sold] [--limit N] delete-ad Delete a listing --urn URN [--reason 1-4] notifications Unread notification count Selling (SYI) syi-form Show category form attrs --category ID create-ad Publish a new listing --category ID --title T --description D --price EUROS upload-image Upload image for listing --file PATH price-suggestion Suggested price ranges --category ID --title TEXT recognize Auto-detect category --file PATH (image recognition) Run 'mrktplaats --help' for flag details. OUTPUT All commands output pretty-printed JSON on stdout. Errors are printed on stderr with exit code 1. EXAMPLES # Find a watch listing mrktplaats search --query "seiko" --category 1831 --size 5 # Get full details including bid info mrktplaats listing --urn m2372861012 # Message the seller mrktplaats send --urn m2372861012 --text "Is this still available?" # Reply to an existing conversation mrktplaats conversations mrktplaats reply --conv 1cgx:5qgvx3h:2p1gxt95d --text "Thanks, I will pick it up." # Place a €25 bid mrktplaats bid --urn m2372861012 --amount 25 # Save and remove a favourite mrktplaats add-favorite --urn m2372861012 mrktplaats remove-favorite --urn m2372861012 # Sell an item mrktplaats recognize --file photo.jpg mrktplaats syi-form --category 1831 mrktplaats price-suggestion --category 1831 --title "Seiko SKX007" mrktplaats upload-image --file photo.jpg mrktplaats create-ad --category 1831 --title "Seiko SKX007" --description "Great watch" --price 150 --seller-name "Jan" --postcode "1000" `) } // ── main ────────────────────────────────────────────────────────────────────── func main() { if len(os.Args) < 2 { printUsage() os.Exit(1) } cmd := os.Args[1] args := os.Args[2:] switch cmd { case "login": cmdLogin(args) case "search": cmdSearch(args) case "listing": cmdListing(args) case "conversations": cmdConversations(args) case "messages": cmdMessages(args) case "send": cmdSend(args) case "reply": cmdReply(args) case "bid": cmdBid(args) case "remove-bid": cmdRemoveBid(args) case "favorites": cmdFavorites(args) case "add-favorite": cmdAddFavorite(args) case "remove-favorite": cmdRemoveFavorite(args) case "me": cmdMe(args) case "my-ads": cmdMyAds(args) case "delete-ad": cmdDeleteAd(args) case "seller-listings": cmdSellerListings(args) case "reviews": cmdReviews(args) case "categories": cmdCategories(args) case "similar": cmdSimilar(args) case "suggest": cmdSuggest(args) case "notifications": cmdNotifications(args) case "create-ad": cmdCreateAd(args) case "upload-image": cmdUploadImage(args) case "syi-form": cmdSYIForm(args) case "price-suggestion": cmdPriceSuggestion(args) case "recognize": cmdRecognize(args) case "help", "--help", "-h": printUsage() default: fmt.Fprintf(os.Stderr, "error: unknown command %q\n\n", cmd) printUsage() os.Exit(1) } }