{"openapi":"3.1.0","info":{"title":"Perko API","version":"1.0.0","description":"The Perko REST API — referrals, circles, search, and the leaderboard, programmatically.\n\n## Authentication\nMost endpoints require an API key sent as a Bearer token:\n\n```\nAuthorization: Bearer perko_live_...\n```\n\nRequest a key at [perko.io/developers](https://perko.io/developers) — an admin approves every key before it activates.\n\nA key acts as your account's PUBLIC capabilities only. It can read and write your own content and anything you can already see, but it can NEVER perform admin actions (even if your account is an admin), manage API keys, or delete an account — those require signing in on the website and return `403`. Admin-only request forms and response fields documented below are session-only for the same reason.\n\n## Rate limits\nEvery key has a requests-per-minute limit (default 60). Responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. Exceeding the limit returns `429` with a `Retry-After` header (seconds).","contact":{"name":"Perko","url":"https://perko.io/feedback","email":"hello@perko.io"}},"servers":[{"url":"https://perko.io","description":"Production"}],"tags":[{"name":"Referrals","description":"The referral-code catalog: browse, post, vote, validate, comment."},{"name":"Search & Discovery","description":"Search, tags, companies, the leaderboard, and public profiles."},{"name":"Circles","description":"Friend circles: membership, feeds, joining, and invites."},{"name":"Account & Activity","description":"Your account, earnings, activity feed, and notifications."}],"paths":{"/api/referrals":{"get":{"tags":["Referrals"],"summary":"List referrals (homepage feed)","description":"Returns referrals visible to the caller. For sort=new|hot|rising results are grouped one card per product (website_url) with a representative code plus owner list; sort=az|price or the user/tag filters return a flat filtered listing with only core fields plus username/display_name. Works without authentication; a bearer key personalizes user_vote, viewer_has_code, and private-referral visibility.","parameters":[{"name":"page","in":"query","required":false,"description":"Page number. Clamped to 1..10000.","schema":{"type":"integer","default":1,"minimum":1,"maximum":10000}},{"name":"limit","in":"query","required":false,"description":"Items per page. Clamped to 1..50.","schema":{"type":"integer","default":20,"minimum":1,"maximum":50}},{"name":"sort","in":"query","required":false,"description":"Sort order: new (newest product group), hot (most clicks), rising (groups with codes added in the last 7 days), az (title A-Z), price (customer benefit amount descending).","schema":{"type":"string","enum":["new","hot","rising","az","price"],"default":"new"}},{"name":"user","in":"query","required":false,"description":"Filter by poster username (case-insensitive substring match). Switches to the flat filtered listing.","schema":{"type":"string"}},{"name":"tag","in":"query","required":false,"description":"Filter by tag (case-insensitive substring match). Switches to the flat filtered listing.","schema":{"type":"string"}}],"responses":{"200":{"description":"Paginated referral feed.","content":{"application/json":{"schema":{"type":"object","properties":{"referrals":{"type":"array","items":{"$ref":"#/components/schemas/ReferralFeedItem"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}}}},"post":{"tags":["Referrals"],"summary":"Create a referral","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReferralInput"}}}},"responses":{"201":{"description":"Referral created.","content":{"application/json":{"schema":{"type":"object","properties":{"referral":{"$ref":"#/components/schemas/Referral"}}}}}},"400":{"description":"Missing required fields, or visibility=private with shareScope=selected and no circleIds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/referrals/{id}":{"parameters":[{"name":"id","in":"path","required":true,"description":"Referral ID.","schema":{"type":"integer"}}],"get":{"tags":["Referrals"],"summary":"Get a referral by ID","description":"Returns the referral with vote/comment/validation stats, other referrals for the same company (sameCompany), and other users' codes for the same product URL (sameReferral). Private referrals return 404 unless the caller may view them. Works without authentication.","responses":{"200":{"description":"Referral detail.","content":{"application/json":{"schema":{"type":"object","properties":{"referral":{"$ref":"#/components/schemas/ReferralDetail"},"sameCompany":{"type":"array","description":"Up to 10 other visible referrals from the same company.","items":{"$ref":"#/components/schemas/RelatedReferral"}},"sameReferral":{"type":"array","description":"Up to 10 other users' codes for the same website_url, enriched with lifetime_points, works_count, fails_count, and shared_circle.","items":{"$ref":"#/components/schemas/RelatedReferral"}}}}}}},"400":{"description":"Invalid referral ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Referral not found (also returned for private referrals the caller cannot view).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"tags":["Referrals"],"summary":"Update a referral (owner or admin)","description":"The owner may update everything. Admins may update any referral's content, but the visibility, shareScope, and circleIds fields are ignored for non-owner edits — the owner's privacy and circle-sharing settings are preserved.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReferralInput"}}}},"responses":{"200":{"description":"Updated referral.","content":{"application/json":{"schema":{"type":"object","properties":{"referral":{"$ref":"#/components/schemas/Referral"}}}}}},"400":{"description":"Invalid referral ID, missing required fields, or shareScope=selected with no circleIds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller is not the referral owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Referral not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Referrals"],"summary":"Delete a referral (owner only)","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Referral deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","example":"Referral deleted"}}}}}},"400":{"description":"Invalid referral ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller is not the referral owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Referral not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/referrals/{id}/click":{"post":{"tags":["Referrals"],"summary":"Record a click on a referral link","description":"Records a click. The public click counter only moves for real, distinct people: each viewer counts once per referral (ever), the owner's own clicks never count, and requests with bot-like or missing User-Agent headers are ignored. Works without authentication. An unauthenticated call is issued a `perko_vid` cookie (random, httpOnly, one year) on first use and identified by it thereafter, so the same person is not counted twice after changing network; send it back to be recognised. Such a call also records a pending \"did you use it?\" follow-up, answerable anonymously via PATCH /api/follow-ups, which awards nothing. With a bearer key, a click on someone else's referral records a pending follow-up against the key's owner and awards points once per referral.","parameters":[{"name":"id","in":"path","required":true,"description":"Referral ID.","schema":{"type":"integer"}}],"responses":{"200":{"description":"Current click count.","content":{"application/json":{"schema":{"type":"object","properties":{"clickCount":{"type":"integer","description":"Total click count for the referral after this request."},"counted":{"type":"boolean","description":"Whether THIS click moved the counter (false for repeat clicks by the same viewer, the owner's own clicks, and bot traffic)."}}}}}},"400":{"description":"Invalid referral ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Referral not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/referrals/{id}/vote":{"post":{"tags":["Referrals"],"summary":"Vote on a referral","description":"Casts or changes the caller's vote. Votes are sticky: sending the value you already have is idempotent (it does NOT remove the vote). Switch direction by sending the other value (1 <-> -1); send 0 to remove the vote entirely.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Referral ID.","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["value"],"properties":{"value":{"type":"integer","enum":[1,-1,0],"description":"1 upvote, -1 downvote, 0 remove existing vote."}}}}}},"responses":{"200":{"description":"New score and the caller's effective vote.","content":{"application/json":{"schema":{"type":"object","properties":{"score":{"type":"integer","description":"Sum of all votes on the referral."},"userVote":{"type":"integer","enum":[1,-1,0],"description":"The caller's vote after this request (0 if removed via value 0)."}}}}}},"400":{"description":"Invalid referral ID or vote value not in {1, -1, 0}.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Referral not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/referrals/{id}/validate":{"post":{"tags":["Referrals"],"summary":"Report whether a referral code still works","description":"One report per user per referral; reporting again updates it, and null clears the caller's report.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Referral ID.","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["works"],"properties":{"works":{"type":["boolean","null"],"description":"true = code works, false = code failed, null = clear your report."}}}}}},"responses":{"200":{"description":"Updated validation tallies.","content":{"application/json":{"schema":{"type":"object","properties":{"worksCount":{"type":"integer","description":"Number of works reports."},"failsCount":{"type":"integer","description":"Number of does-not-work reports."},"userValidation":{"type":["boolean","null"],"description":"The caller's report after this request."}}}}}},"400":{"description":"Invalid referral ID, or works is not true/false/null.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Referral not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/referrals/{id}/spin":{"post":{"tags":["Referrals"],"summary":"Pick whose code to use for a product","description":"Selects a winning code among all visible referrals sharing the same website_url as the given referral, excluding the caller's own codes. If the caller shares a circle with any code owners, the least-recently-clicked circle-mate wins (mode=rotation); otherwise a weighted random pick favors codes with more works reports (mode=lucky). Works without authentication.","parameters":[{"name":"id","in":"path","required":true,"description":"Any referral ID in the product group.","schema":{"type":"integer"}}],"responses":{"200":{"description":"Selected code.","content":{"application/json":{"schema":{"type":"object","properties":{"mode":{"type":"string","enum":["rotation","lucky"],"description":"rotation = fair rotation among circle-mates; lucky = weighted random."},"winner":{"type":"object","properties":{"id":{"type":"integer","description":"Winning referral ID."},"referral_link":{"type":"string","description":"The winning referral link."},"username":{"type":"string"},"display_name":{"type":"string"},"shared_circle":{"type":"boolean","description":"Whether the caller shares a circle with the winner."}}}}}}}},"400":{"description":"Invalid referral ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Referral not found, or no one else has a code for this product.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/referrals/{id}/comments":{"parameters":[{"name":"id","in":"path","required":true,"description":"Referral ID.","schema":{"type":"integer"}}],"get":{"tags":["Referrals"],"summary":"List comments on a referral","description":"Returns all comments for the referral, oldest first. Public.","responses":{"200":{"description":"Comment list.","content":{"application/json":{"schema":{"type":"object","properties":{"comments":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}},"avatars":{"type":"object","description":"Profile pictures deduped per commenter: map of user_id to data URL. Only users with a photo appear. Comment rows do not repeat the avatar.","additionalProperties":{"type":"string"}}}}}}},"400":{"description":"Invalid referral ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Referrals"],"summary":"Add a comment to a referral","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["body"],"properties":{"body":{"type":"string","maxLength":2000,"description":"Comment text; trimmed and truncated to 2000 characters."},"parentId":{"type":"integer","description":"Optional ID of the parent comment to reply to; must belong to the same referral."}}}}}},"responses":{"201":{"description":"Comment created.","content":{"application/json":{"schema":{"type":"object","properties":{"comment":{"$ref":"#/components/schemas/Comment"}}}}}},"400":{"description":"Invalid referral ID, empty body, or parent comment invalid / on a different referral.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Referral not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/referrals/{id}/comments/{commentId}":{"delete":{"tags":["Referrals"],"summary":"Delete a comment","description":"Deletable by the comment author, the referral owner, or an admin. Deleting a comment cascades to its replies.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Referral ID.","schema":{"type":"integer"}},{"name":"commentId","in":"path","required":true,"description":"Comment ID.","schema":{"type":"integer"}}],"responses":{"200":{"description":"Comment deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true}}}}}},"400":{"description":"Invalid referral or comment ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller is not the comment author, the referral owner, or an admin.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Comment not found on this referral.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/search/suggest":{"get":{"tags":["Search & Discovery"],"summary":"Search autocomplete suggestions","description":"Lightweight typeahead for the search box: given a partial query, returns up to 6 matching companies (with code counts), up to 6 matching codes, and up to 3 program-only companies (companies with a known referral program but no visible codes; their company pages are at /company/{slug}), prefix matches first. Visibility-filtered exactly like the main search (private codes never appear for callers outside their circles; a bearer key widens visibility to the caller's circles). Never errors — returns empty arrays on failure.","operationId":"searchSuggest","security":[{"bearerAuth":[]},{}],"parameters":[{"name":"q","in":"query","required":true,"description":"Partial query (1–60 chars). Case-insensitive substring match against company and title; LIKE wildcards are escaped.","schema":{"type":"string"}}],"responses":{"200":{"description":"Matching companies and codes.","content":{"application/json":{"schema":{"type":"object","properties":{"companies":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string","description":"Company page slug (/company/{slug})."},"count":{"type":"integer","description":"Number of visible codes for the company."}},"required":["name","slug","count"]}},"codes":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer","description":"Referral id (/referrals/{id})."},"title":{"type":"string"},"company":{"type":"string"}},"required":["id","title","company"]}},"programs":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string","description":"Company page slug (/company/{slug})."}},"required":["name","slug"]},"description":"Companies with a known referral program but no visible codes, deduped against companies."}},"required":["companies","codes","programs"]}}}}}}},"/api/search":{"get":{"tags":["Search & Discovery"],"summary":"Search referrals","description":"Full-text search over visible referrals with tag, company, circle, and Trusted Radius filters. Works without authentication; sending a bearer key additionally surfaces private referrals shared with the caller's circles, ranks self/direct-circle/opted-in second-degree results before the wider community, and populates the circles filter list. Welcome/theme circles never create Trusted Radius relationships, and second-degree results never expose the bridge user or circle name.","operationId":"searchReferrals","parameters":[{"name":"q","in":"query","required":false,"description":"Case-insensitive substring match against referral title, description, tags, company, and the poster's username or display name.","schema":{"type":"string"}},{"name":"tag","in":"query","required":false,"description":"Case-insensitive substring match against the referral's comma-separated tags.","schema":{"type":"string"}},{"name":"company","in":"query","required":false,"description":"Exact company name match.","schema":{"type":"string"}},{"name":"category","in":"query","required":false,"description":"Company category (one controlled bucket per company, auto-classified from the company's website — e.g. Technology, Finance, Fashion). Restricts results to referrals whose company is in that category. See categoryFacets in the response for the available values with counts.","schema":{"type":"string"}},{"name":"circle","in":"query","required":false,"description":"Circle ID; restricts results to referrals posted by members of that circle. A circle with no members yields an empty result set.","schema":{"type":"integer"}},{"name":"radius","in":"query","required":false,"description":"Relationship scope. all returns the normal visible catalog, ranked self to direct circle to opted-in second degree to community. circles keeps self plus direct-circle codes. extended also includes public codes from second-degree owners who opted in. Trusted scopes normalize to all when unauthenticated and are ignored when an explicit circle filter is active.","schema":{"type":"string","enum":["all","circles","extended"],"default":"all"}},{"name":"page","in":"query","required":false,"description":"1-based page number. Clamped to 1..10000.","schema":{"type":"integer","default":1,"minimum":1,"maximum":10000}},{"name":"limit","in":"query","required":false,"description":"Results per page. Clamped to 1..50.","schema":{"type":"integer","default":20,"minimum":1,"maximum":50}}],"responses":{"200":{"description":"Matching referrals ranked with failed codes last, then by relationship distance and freshness before pagination, plus filter metadata.","content":{"application/json":{"schema":{"type":"object","properties":{"referrals":{"type":"array","items":{"$ref":"#/components/schemas/SearchReferral"}},"tags":{"type":"array","items":{"type":"string"},"description":"All distinct tags across referrals visible to the caller, lowercased and sorted. Query-independent."},"companies":{"type":"array","items":{"type":"string"},"description":"All distinct non-empty company names across referrals visible to the caller, sorted ascending. Query-independent."},"tagFacets":{"type":"array","items":{"$ref":"#/components/schemas/Facet"},"description":"Tags with referral counts, sorted by count descending then name. Contextual: counts respect q, company, and circle filters (but not the tag filter itself, so they show what switching tags would yield)."},"companyFacets":{"type":"array","items":{"$ref":"#/components/schemas/Facet"},"description":"Companies with referral counts, sorted by count descending then name. Contextual: counts respect q, tag, category, and circle filters (but not the company filter itself)."},"categoryFacets":{"type":"array","items":{"$ref":"#/components/schemas/Facet"},"description":"Company categories with referral counts, sorted by count descending then name. Only companies that have been classified are counted. Contextual: counts respect q, tag, company, and circle filters (but not the category filter itself)."},"circles":{"type":"array","items":{"$ref":"#/components/schemas/CircleSummary"},"description":"Circles the authenticated caller belongs to; empty when unauthenticated."},"trustedRadius":{"type":"object","properties":{"available":{"type":"boolean","description":"True for an authenticated caller."},"optedIn":{"type":"boolean","description":"Whether the caller lets their public codes appear in other users' second-degree results."},"scope":{"type":"string","enum":["all","circles","extended"],"description":"The normalized scope applied to this response."}}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}}}}},"/api/thanks":{"post":{"tags":["Circles"],"summary":"Thank a friend for a real event","description":"One-tap thank-you for a confirmed use of your code, or for someone fulfilling your request. The recipient is derived server-side from a verified row (you can only thank someone who actually did the thing for you), so an arbitrary user cannot be targeted. Idempotent per event — the recipient is pinged at most once. Sends an in-app notification.","operationId":"sendThanks","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string","enum":["confirmed_use","request_fulfilled"],"description":"confirmed_use = you (the code owner) thank the person who used your code; request_fulfilled = you (the requester) thank the friend who answered your ask."},"referralId":{"type":"integer","description":"Required for kind=confirmed_use: the referral that was used."},"usedBy":{"type":"integer","description":"Required for kind=confirmed_use: the user ID who used the code (one code can be used by several friends)."},"requestId":{"type":"integer","description":"Required for kind=request_fulfilled: the fulfilled request."}},"required":["kind"]}}}},"responses":{"200":{"description":"Thanks recorded (idempotent — always 200 whether or not it was the first time).","content":{"application/json":{"schema":{"type":"object","properties":{"thanked":{"type":"boolean"}}}}}},"400":{"description":"Unknown kind or missing required ids.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No verified event to thank for (you weren't the owner/requester, or the row doesn't exist).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/tags":{"get":{"tags":["Search & Discovery"],"summary":"List all tags","description":"Returns every distinct tag used across referrals visible to the caller, lowercased, deduplicated, sorted alphabetically, and capped at 500. Works without authentication; a bearer key additionally surfaces tags from private referrals shared with the caller's circles.","operationId":"listTags","responses":{"200":{"description":"Sorted list of distinct tags.","content":{"application/json":{"schema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}}}}}}}}}},"/api/companies":{"get":{"tags":["Search & Discovery"],"summary":"List all companies","description":"Returns every distinct non-empty company name from referrals VISIBLE TO THE CALLER, sorted ascending, capped at 500. Works without authentication; a bearer key additionally surfaces companies from private referrals shared with the caller's circles.","operationId":"listCompanies","responses":{"200":{"description":"Sorted list of distinct company names.","content":{"application/json":{"schema":{"type":"object","properties":{"companies":{"type":"array","items":{"type":"string"}}}}}}}}}},"/api/leaderboard":{"get":{"tags":["Search & Discovery"],"summary":"Get points leaderboard","description":"Points leaderboard (top 25; members-only). week and month rank by positive points earned in the last 7 or 30 days; all ranks by lifetime_points. Users who opted out of the leaderboard are excluded. The caller also receives their own rank and points. With circleId, returns a members-only slice (top 10) ranked among that circle's members — the caller must belong to the circle, and viewer/viewerHidden are null/false in that mode.","operationId":"getLeaderboard","parameters":[{"name":"period","in":"query","required":false,"description":"Ranking window. Any value other than month or all is treated as week.","schema":{"type":"string","enum":["week","month","all"],"default":"week"}},{"name":"circleId","in":"query","required":false,"description":"Restrict the board to one circle's members (caller must be a member; 403 otherwise).","schema":{"type":"integer"}}],"responses":{"200":{"description":"Top 25 leaders for the period plus the caller's private rank when authenticated.","content":{"application/json":{"schema":{"type":"object","properties":{"period":{"type":"string","description":"Echo of the requested period query value (returned verbatim, not normalized); any value other than month or all was ranked as week."},"leaders":{"type":"array","items":{"$ref":"#/components/schemas/LeaderboardEntry"}},"viewer":{"oneOf":[{"$ref":"#/components/schemas/LeaderboardViewer"},{"type":"null"}],"description":"Authenticated caller's own rank and points. For period=week or month it is null when the caller earned no positive points in the window; for period=all it is always present for an authenticated caller (points equals lifetime_points, possibly 0). Always null when unauthenticated."},"viewerHidden":{"type":"boolean","description":"True when the authenticated caller has opted out of appearing on the public board."},"viewerId":{"type":["integer","null"],"description":"Authenticated caller's user ID, or null."}}}}}}}}},"/api/announcements":{"get":{"tags":["Search & Discovery"],"summary":"Published announcements","description":"Big feature launches and product announcements, newest first (public; max 50). body_html is sanitized rich text; show_banner marks ones surfaced as the logged-in home banner and is_featured the logged-out landing spotlight.","operationId":"getAnnouncements","responses":{"200":{"description":"Published announcements, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"announcements":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"title":{"type":"string"},"emoji":{"type":"string"},"summary":{"type":"string"},"body_html":{"type":"string","description":"Sanitized (allowlist) HTML body."},"cta_label":{"type":"string","description":"Optional button label ('' when none)."},"cta_url":{"type":"string","description":"Optional button link — absolute http(s) URL or in-app path ('' when none)."},"show_banner":{"type":"boolean"},"is_featured":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"}}}}}}}}}}}},"/api/trending":{"get":{"tags":["Search & Discovery"],"summary":"Trending companies","description":"Companies with the most referral activity in the last 7 days — clicks weighted 1, confirmed uses weighted 3. Public, active codes from unbanned owners only. Cached for up to 10 minutes.","operationId":"getTrendingCompanies","responses":{"200":{"description":"Up to 8 trending companies.","content":{"application/json":{"schema":{"type":"object","properties":{"companies":{"type":"array","items":{"type":"object","properties":{"company":{"type":"string"},"clicks":{"type":"integer","description":"Clicks in the last 7 days."},"confirms":{"type":"integer","description":"Confirmed uses in the last 7 days."},"codes":{"type":"integer","description":"Distinct live public codes involved."}}}}}}}}}}}},"/api/flairs":{"get":{"tags":["Search & Discovery"],"summary":"Flair catalog","description":"Public catalog of buyable flair plus the system flair definitions (Certified Plug tiers, Day One). Cosmetic badges worn next to a user's name. The full enabled catalog (~500 items) is returned so any owned flair can be resolved; only the keys in stock.stockedToday are purchasable right now — the shop shelf rotates once per UTC day, weighted by rarity.","operationId":"getFlairs","responses":{"200":{"description":"The enabled shop catalog, today's stock, and system flair defs.","content":{"application/json":{"schema":{"type":"object","properties":{"shop":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"emoji":{"type":"string"},"name":{"type":"string"},"color":{"type":"string","description":"CSS color for the pill tint."},"price":{"type":"integer","description":"Cost in points."},"minLevel":{"type":"integer","description":"Level required to buy (1 = everyone)."},"enabled":{"type":"boolean"}}}},"stock":{"type":"object","description":"The daily rotation: which catalog keys are on the shelf today.","properties":{"date":{"type":"string","description":"UTC day the stock belongs to (YYYY-MM-DD)."},"restockAt":{"type":"string","description":"ISO timestamp of the next rotation (UTC midnight)."},"stockedToday":{"type":"array","items":{"type":"string"},"description":"Flair keys purchasable today."},"flashNow":{"type":"string","nullable":true,"description":"The current hour's flash-restock key (buyable this hour only even though off-shelf), or null. Future flash hours are not exposed."}}},"plug":{"type":"object","description":"Certified Plug tiers (key, color, tiers[])."},"dayOne":{"type":"object","description":"Day One flair def (key, emoji, label, color)."},"cutoff":{"type":"string","description":"Day One cutoff date (YYYY-MM-DD) or empty."}}}}}}}}},"/api/flairs/mine":{"get":{"tags":["Search & Discovery"],"summary":"The caller's flair state","description":"Authenticated: the caller's spendable balance, level, display name, equipped flair, their owned items plus today's shelf (the shop rotates daily — `shop` holds owned items and today's stocked, unowned items only), a `stock` object (date, restockAt, backroomCount), today's Mystery Crate offer (`crate`), the day's haggle state (`haggle`), their `wishlist` keys, and their earned (Certified Plug) and aged (Day One) flair.","operationId":"getMyFlairs","responses":{"200":{"description":"The caller's full flair state."},"401":{"description":"Not authenticated."}}}},"/api/flairs/buy":{"post":{"tags":["Search & Discovery"],"summary":"Buy a shop flair","description":"Spend points on an enabled shop flair (idempotent; auto-equips). Pass {key} for a shelf item — it must be on today's rotating shelf (see /api/flairs stock.stockedToday) or be the current hour's flash-restock find (stock.flashNow) — or {crate: true} to open today's Mystery Crate (a server-picked wrapped ware at 20% off; the response reveals it). A haggle won today on the same key is applied automatically. Prices are re-derived server-side from seeded rolls: what was shown is what's charged. Completing an entire rarity tier is acknowledged in the response (shelfCompleted, and possibly a hidden flair). Fails if not stocked/flash/crate-eligible, the level gate isn't met, or the balance is insufficient. Already-owned flairs are re-equipped without charge.","operationId":"buyFlair","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"key":{"type":"string","description":"Shop flair key (omit when opening the crate)."},"crate":{"type":"boolean","description":"true to open today's Mystery Crate instead of naming a key."}}}}}},"responses":{"200":{"description":"Purchased (or already owned) and equipped; returns the new balance."},"400":{"description":"Unknown/disabled flair, not on today's shelf, level not met, or not enough points."},"401":{"description":"Not authenticated."}}}},"/api/flairs/equip":{"post":{"tags":["Search & Discovery"],"summary":"Equip a flair","description":"Wear a flair the caller owns; pass an empty key to take it off. Ownership is verified per flair source (shop purchase, Certified Plug tier, or Day One).","operationId":"equipFlair","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"key":{"type":"string","description":"Flair key to wear, or empty string to unequip."}}}}}},"responses":{"200":{"description":"Equipped (or unequipped)."},"400":{"description":"Caller doesn't own that flair."},"401":{"description":"Not authenticated."}}}},"/api/flairs/haggle":{"post":{"tags":["Search & Discovery"],"summary":"Haggle over a stocked flair","description":"Authenticated: spend the day's ONE haggle attempt on an item from today's shelf. The outcome is a seeded roll fixed per (day, user, item) — roughly a third succeed for 5–15% off; failure never raises the price. A won discount is applied automatically when buying that item the same day.","operationId":"haggleFlair","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["key"],"properties":{"key":{"type":"string","description":"A flair key on today's shelf."}}}}}},"responses":{"200":{"description":"The merchant's answer: {success, line} plus {discountPct, price} on a win."},"400":{"description":"Not on today's shelf, or the daily attempt is already spent."},"401":{"description":"Not authenticated."}}}},"/api/flairs/wishlist":{"post":{"tags":["Search & Discovery"],"summary":"Watch a shop ware","description":"Authenticated: heart ({wished: true}) or un-heart ({wished: false}) a catalog ware. Wished items trigger a one-time in-app notification on the day they rotate onto the shelf. Capped at 50 per user; already-owned wares can't be wished.","operationId":"wishFlair","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["key"],"properties":{"key":{"type":"string","description":"Catalog flair key."},"wished":{"type":"boolean","description":"false to remove from the wishlist (default true)."}}}}}},"responses":{"200":{"description":"{ok, wished}."},"400":{"description":"Unknown ware, already owned, or wishlist cap reached."},"401":{"description":"Not authenticated."}}}},"/api/flairs/cat":{"post":{"tags":["Search & Discovery"],"summary":"Pet the shop cat","description":"Authenticated: pet the Flairsmith's cat. One pet counts per UTC day. She warms up to regulars — persistence may be rewarded.","operationId":"petShopCat","responses":{"200":{"description":"{line} from the encounter (and occasionally more)."},"401":{"description":"Not authenticated."}}}},"/api/requests":{"post":{"tags":["Circles"],"summary":"Ask your circle(s) for a code","description":"Posts a demand-side request (\"anyone got a Chase code?\") to one or more specific circles the caller belongs to. You MUST choose at least one circle (via circleIds); there are no blanket asks. One request is created per selected circle, and its members get an in-app notification. The response includes instant matches: codes already visible to the caller (per referral_visible_to) whose title or company matches the ask. Requests auto-expire 48 hours after posting.","operationId":"createCircleRequest","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"company":{"type":"string","minLength":2,"maxLength":100,"description":"What the caller needs a code for."},"note":{"type":"string","maxLength":280,"description":"Optional detail, e.g. \"before Friday\"."},"circleIds":{"type":"array","items":{"type":"integer"},"minItems":1,"description":"Target circles the caller belongs to. At least one is required — an ask cannot be blanket."},"circleId":{"type":"integer","description":"Deprecated single-circle form, still accepted; equivalent to circleIds: [circleId]."}},"required":["company","circleIds"]}}}},"responses":{"201":{"description":"Request created; matches lists codes already visible to the caller.","content":{"application/json":{"schema":{"type":"object","properties":{"request":{"$ref":"#/components/schemas/CircleRequest","description":"The first created request (one per targeted circle)."},"requests":{"type":"array","items":{"$ref":"#/components/schemas/CircleRequest"},"description":"One request per targeted circle."},"matches":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"title":{"type":"string"},"company":{"type":"string"},"owner_name":{"type":"string"},"owner_username":{"type":"string"}}}}}}}}},"400":{"description":"Missing/too-short company, or the caller belongs to no circles.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"circleId given but the caller is not a member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Circles"],"summary":"List a circle's asks","description":"Open requests visible in a circle (including all-circles asks from its members), plus recently fulfilled ones (3 days) and expired-unfulfilled ones (7 days — these render as invite cards). Members/owner only. Reading lazily expires requests older than 48 hours.","operationId":"listCircleRequests","security":[{"bearerAuth":[]}],"parameters":[{"name":"circleId","in":"query","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Requests, open first then newest.","content":{"application/json":{"schema":{"type":"object","properties":{"requests":{"type":"array","items":{"$ref":"#/components/schemas/CircleRequest"}}},"required":["requests"]}}}},"400":{"description":"circleId missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller is not a member of the circle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/requests/{id}":{"post":{"tags":["Circles"],"summary":"Fulfill an ask with one of your codes","description":"Marks an open request fulfilled with a referral the CALLER owns (first fulfiller wins, atomically). The requester is notified with a link to the code. The fulfiller earns points via the fulfill_request rule unless they are the requester or the two users are an invite-attributed pair (fake-account guard).","operationId":"fulfillCircleRequest","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"referralId":{"type":"integer","description":"A referral owned by the caller."}},"required":["referralId"]}}}},"responses":{"200":{"description":"Fulfilled.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"pointsAwarded":{"type":"integer","description":"Points granted to the fulfiller (0 when excluded or capped)."}}}}}},"400":{"description":"Invalid ids.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The referral isn't the caller's, or the ask isn't in the caller's circles.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Request not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Already fulfilled or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Circles"],"summary":"Cancel your ask","description":"The requester cancels their own open (or expired) request.","operationId":"cancelCircleRequest","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Cancelled.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found, not the caller's, or not cancellable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/users/{username}":{"get":{"tags":["Search & Discovery"],"summary":"Get a user profile","description":"Returns a user's profile and their referrals. Unauthenticated callers and other users see only public referrals; when the bearer key belongs to the profile owner, private referrals are included too.","operationId":"getUserProfile","parameters":[{"name":"username","in":"path","required":true,"description":"The user's username.","schema":{"type":"string"}}],"responses":{"200":{"description":"Profile, referrals (newest first), and total click count across the returned referrals.","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/UserProfile"},"referrals":{"type":"array","items":{"$ref":"#/components/schemas/ProfileReferral"}},"totalClicks":{"type":"integer","description":"Sum of click_count across the returned referrals."}}}}}},"404":{"description":"No user with that username.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Search & Discovery"],"summary":"Update own profile","description":"Updates the caller's display name, leaderboard visibility, and/or Trusted Radius opt-in. The username in the path must match the authenticated caller.","operationId":"updateUserProfile","security":[{"bearerAuth":[]}],"parameters":[{"name":"username","in":"path","required":true,"description":"The caller's own username.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"displayName":{"type":"string","maxLength":100,"description":"New display name; if present it must be non-blank and at most 100 characters."},"username":{"type":"string","pattern":"^[a-zA-Z0-9_]{3,50}$","description":"New public handle: 3–50 characters, letters/numbers/underscores only. Changing it changes the profile URL. Omit to leave unchanged."},"showOnLeaderboard":{"type":"boolean","description":"Whether the user appears on the public leaderboard; only applied when a boolean is sent."},"trustedRadiusEnabled":{"type":"boolean","description":"Opt in or out of having this user's PUBLIC codes appear in second-degree Trusted Radius results. Private codes and relationship paths are never exposed."},"avatarUrl":{"type":"string","description":"Profile picture UPLOAD as a data:image/... URL (client-resized square; max 100,000 chars encoded, ~75KB). The server re-hosts it and stores a CDN URL. Empty string removes the photo. Omit to leave unchanged."}}}}}},"responses":{"200":{"description":"The updated user profile.","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/UserProfile"}}}}}},"400":{"description":"displayName was sent but is blank or longer than 100 characters, or username was sent but doesn't match ^[a-zA-Z0-9_]{3,50}$, or username is a reserved handle (brand, authority and role names such as perko, admin, support and security; compared case-insensitively and ignoring underscores).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Authenticated caller does not own this profile.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The requested username is already taken.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/users/{username}/follow":{"post":{"tags":["Search & Discovery"],"summary":"Follow a user","description":"Follow a user to get an in-app notification whenever they post a new PUBLIC referral (private codes never trigger follow notifications; followers who share a circle with the poster are notified through the circle instead). The followed user is notified of the new follower. Idempotent.","operationId":"followUser","security":[{"bearerAuth":[]}],"parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Now following.","content":{"application/json":{"schema":{"type":"object","properties":{"following":{"type":"boolean"},"follower_count":{"type":"integer"}}}}}},"400":{"description":"Attempted to follow yourself.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"User not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Search & Discovery"],"summary":"Unfollow a user","operationId":"unfollowUser","security":[{"bearerAuth":[]}],"parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"No longer following.","content":{"application/json":{"schema":{"type":"object","properties":{"following":{"type":"boolean"},"follower_count":{"type":"integer"}}}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"User not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/users/search":{"get":{"tags":["Search & Discovery"],"summary":"Search users","description":"Finds up to 20 users by username or display name (case-insensitive substring), excluding the caller. Queries shorter than 2 characters return an empty list.","operationId":"searchUsers","security":[{"bearerAuth":[]}],"parameters":[{"name":"q","in":"query","required":false,"description":"Search text; trimmed, and must be at least 2 characters to return matches.","schema":{"type":"string","minLength":2}}],"responses":{"200":{"description":"Matching users sorted by username ascending (max 20).","content":{"application/json":{"schema":{"type":"object","properties":{"users":{"type":"array","items":{"$ref":"#/components/schemas/UserSummary"}}}}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles":{"get":{"tags":["Circles"],"summary":"List my circles","description":"Returns every circle the authenticated user owns or is a member of, newest first.","operationId":"listCircles","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Circles owned by or joined by the user.","content":{"application/json":{"schema":{"type":"object","properties":{"circles":{"type":"array","items":{"$ref":"#/components/schemas/CircleWithCounts"}}},"required":["circles"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Circles"],"summary":"Create a circle","description":"Creates a circle owned by the authenticated user; the owner is auto-added as a member with role \"owner\" and an invite code is generated.","operationId":"createCircle","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":100,"description":"Circle name (required, 1-100 characters after trimming)."},"description":{"type":"string","description":"Optional description."},"type":{"type":"string","enum":["public","private"],"default":"private","description":"Any value other than \"public\" is treated as \"private\"."},"visibility":{"type":"string","enum":["visible","invite_only"],"default":"visible","description":"Any value other than \"invite_only\" is treated as \"visible\"."},"category":{"type":"string","enum":["friends","products","following","tags"],"default":"friends","description":"Invalid values fall back to \"friends\"."},"contentMode":{"type":"string","enum":["all_members","selected_members","pinned_only"],"default":"all_members","description":"What appears in the circle feed; invalid values fall back to \"all_members\"."},"membersCanAdd":{"type":"boolean","default":false,"description":"Whether regular members can invite people to the circle (only exactly true enables it)."},"tags":{"type":"string","description":"Comma-separated tags; normalized to unique lowercase values."}},"required":["name"]}}}},"responses":{"201":{"description":"Circle created.","content":{"application/json":{"schema":{"type":"object","properties":{"circle":{"$ref":"#/components/schemas/CreatedCircle"}},"required":["circle"]}}}},"400":{"description":"Name missing or longer than 100 characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles/{id}":{"get":{"tags":["Circles"],"summary":"Get circle details","description":"Returns circle info, its member list, and a paginated feed of referrals (member codes, pinned codes, and, for tag-space circles, public referrals matching the circle tags). Private circles are viewable only by the owner; public invite-only circles only by members.","operationId":"getCircle","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}},{"name":"page","in":"query","required":false,"description":"Referral feed page (20 items per page).","schema":{"type":"integer","minimum":1,"default":1}}],"responses":{"200":{"description":"Circle details, members, and referral feed.","content":{"application/json":{"schema":{"type":"object","properties":{"circle":{"$ref":"#/components/schemas/CircleDetail"},"members":{"type":"array","items":{"$ref":"#/components/schemas/CircleMember"}},"referrals":{"type":"array","items":{"$ref":"#/components/schemas/CircleFeedReferral"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["circle","members","referrals","pagination"]}}}},"400":{"description":"Invalid circle ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Access denied (private circle and not owner, or public invite-only circle and not a member).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Circles"],"summary":"Update a circle","description":"Updates circle settings. Owner only. Omitted fields keep their current values.","operationId":"updateCircle","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"New name; must be non-empty if provided."},"description":{"type":"string"},"visibility":{"type":"string","enum":["visible","invite_only"],"description":"Other values leave visibility unchanged."},"notify_member_joined":{"type":"boolean","description":"Notify members when someone joins."},"notify_item_added":{"type":"boolean","description":"Notify members when a referral is added."},"members_can_add":{"type":"boolean","description":"Allow non-owner members to add users by username."},"content_mode":{"type":"string","enum":["all_members","selected_members","pinned_only"],"description":"Invalid values leave the mode unchanged."},"tags":{"type":"string","description":"Comma-separated tags; normalized to unique lowercase values."},"image_url":{"type":"string","description":"Circle photo as a data:image/... URL (max 400000 chars) or \"\" to remove it."},"shared_member_ids":{"type":"array","items":{"type":"integer"},"description":"User IDs whose codes appear when content_mode is selected_members; replaces the existing set, non-members are ignored."}}}}}},"responses":{"200":{"description":"Updated circle record.","content":{"application/json":{"schema":{"type":"object","properties":{"circle":{"$ref":"#/components/schemas/Circle"}},"required":["circle"]}}}},"400":{"description":"Invalid circle ID, empty name, or photo not a data-image URL under ~300KB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found or requester is not the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Circles"],"summary":"Delete a circle","description":"Deletes the circle. Owner only.","operationId":"deleteCircle","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}}],"responses":{"200":{"description":"Circle deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Success"}}}},"400":{"description":"Invalid circle ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found or requester is not the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles/{id}/rotations":{"get":{"tags":["Circles"],"summary":"The circle's rotations","description":"Members only. Companies where 2+ members hold active codes, each with its rotation state (enabled + whose turn). Turn order is members-with-codes by circle join date; the pointer advances only on a confirmed member-to-member use.","operationId":"getCircleRotations","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Rotation-eligible companies with state ({ rotations, isOwner })."},"403":{"description":"Not a member of this circle."}}},"post":{"tags":["Circles"],"summary":"Toggle a company rotation","description":"Circle owner only. Body: { companyKey, company?, enabled }. Enabling requires at least two members with active codes for the company.","operationId":"setCircleRotation","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Rotation state saved."},"400":{"description":"Fewer than two members hold codes for this company."},"403":{"description":"Only the circle owner can manage rotations."}}}},"/api/circles/weekly-recap":{"get":{"tags":["Circles"],"summary":"This week in your circles","description":"Per-circle activity counts for the caller's circles over the last 7 days (new codes, joins, member-to-member uses, open asks). Circles with nothing to report are omitted.","operationId":"getWeeklyRecap","responses":{"200":{"description":"{ recap: [{ id, name, new_codes, new_members, open_asks, uses_this_week }] }"}}}},"/api/referrals/{id}/report":{"post":{"tags":["Referrals"],"summary":"Report a referral listing","description":"Flag a code for admin review. Body: { reason: scam|expired|wrong_company|spam|other, note? }. One report per user per referral (re-reporting updates it); owners can't report their own code.","operationId":"reportReferral","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Report recorded."},"400":{"description":"Missing/invalid reason, or reporting your own code."},"401":{"description":"Not authenticated."}}}},"/api/referrals/{id}/status":{"post":{"tags":["Referrals"],"summary":"Pause or reactivate your code","description":"Owner (or admin) marks a code paused (program ended — leaves every public surface but stays visible to the owner) or active again. Body: { status: active|paused }. Returns 409 with usedUp:true when asked to activate a code that retired itself at its maxUses cap; raise or clear the limit via PATCH /api/referrals/{id} instead, which reactivates it.","operationId":"setReferralStatus","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Status saved."},"403":{"description":"Not your code."}}}},"/api/circles/{id}/activity":{"get":{"tags":["Circles"],"summary":"Get a circle's activity log","description":"The circle's group activity, newest first (max 25 items): members joining, codes added to the feed (honoring the circle's content_mode and each referral's visibility), codes pinned, confirmed friend-to-friend uses (the user who used the code is anonymized — only the code owner is named), and Circle Pot milestones. Access rules match the circle detail endpoint.","operationId":"getCircleActivity","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}}],"responses":{"200":{"description":"Activity items, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"activity":{"type":"array","items":{"$ref":"#/components/schemas/CircleActivityLogItem"}}},"required":["activity"]}}}},"400":{"description":"Invalid circle ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Access denied (private circle and not owner, or public invite-only circle and not a member).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles/{id}/members":{"get":{"tags":["Circles"],"summary":"List circle members","description":"Lists members of a circle. Private circles: owner only. Public circles: members only.","operationId":"listCircleMembers","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}}],"responses":{"200":{"description":"Member list ordered by role then join date.","content":{"application/json":{"schema":{"type":"object","properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/CircleMember"}}},"required":["members"]}}}},"400":{"description":"Invalid circle ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Access denied.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Circles"],"summary":"Invite a member by username, or self-join a public circle","description":"With a username, sends a pending circle invite to that user (owner always; members when members_can_add is on) — they must accept before joining. Without a username on a public circle, the caller self-joins immediately.","operationId":"addCircleMember","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"username":{"type":"string","description":"Username of the user to add; omit to self-join a public circle."}}}}}},"responses":{"200":{"description":"Invite created (username path) or joined (self-join). The invite is always created on the username path even when its notification is withheld — notifySkipped says which limit applied and the person can still accept from their pending invites.","content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"invited":{"type":"boolean"},"inviteId":{"type":"integer","description":"ID of the pending invite."},"notifySkipped":{"type":"string","enum":["cooldown","daily_limit"],"description":"Present only when the invite notification was withheld: cooldown means this invite was notified moments ago; daily_limit means the caller has spent their day's invitation allowance."},"message":{"type":"string","description":"Human-readable explanation, present only when notifySkipped is daily_limit."}},"required":["invited","inviteId"]},{"type":"object","properties":{"success":{"type":"boolean"},"userId":{"type":"integer","description":"ID of the user who was added."}},"required":["success","userId"]}]}}}},"400":{"description":"Invalid circle ID, or username missing for a private circle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Only the owner (or members when members_can_add is enabled) can add members; or the caller has not confirmed their own email address, which is required before inviting anyone by username (the response carries needsVerification: true).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found, or user with that username not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"User is already a member of this circle, or already has a pending invite to it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles/{id}/members/{userId}":{"delete":{"tags":["Circles"],"summary":"Remove a member or leave a circle","description":"The owner can remove anyone; members can remove themselves (leave) from public circles. Owners cannot leave their own circle, and the owner membership row can never be deleted.","operationId":"removeCircleMember","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}},{"name":"userId","in":"path","required":true,"description":"User ID of the member to remove.","schema":{"type":"integer"}}],"responses":{"200":{"description":"Member removed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Success"}}}},"400":{"description":"Invalid IDs, or the owner attempted to leave their own circle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the owner and not removing yourself, or non-owner acting on a private circle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found, or member not found / cannot be removed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles/{id}/referrals":{"post":{"tags":["Circles"],"summary":"Add a referral to a circle","description":"Adds an individual referral code to the circle feed. When the caller is the circle OWNER this is a pin (curated, shown with a pinned badge); when a member of a public circle adds one it is a plain share (no badge). Private circles: owner only. Public circles: any member.","operationId":"pinCircleReferral","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"referralId":{"type":"integer","description":"ID of the referral to pin."}},"required":["referralId"]}}}},"responses":{"200":{"description":"Referral pinned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Success"}}}},"400":{"description":"Invalid circle ID or invalid referral ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the owner (private circle) or not a member (public circle).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle or referral not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Referral is already pinned to this circle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Circles"],"summary":"Unpin a referral from a circle","description":"Removes a pinned referral. The owner can remove any pin; other users can only remove pins they added themselves.","operationId":"unpinCircleReferral","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}},{"name":"referralId","in":"query","required":true,"description":"ID of the pinned referral to remove.","schema":{"type":"integer"}}],"responses":{"200":{"description":"Referral unpinned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Success"}}}},"400":{"description":"Invalid circle ID or referral ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found, or pin not found / not added by the requester.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles/discover":{"get":{"tags":["Circles"],"summary":"Discover public circles","description":"Browses public, visible circles ordered by member count then creation date. Works without authentication. Non-theme circles carry why-join texture (strong categories and code freshness) for every caller; when authenticated, is_joined reflects membership and new_category_count reports how many categories the circle adds over the caller's own circles (null when unauthenticated). On the unfiltered first page, the response also carries the activity-scored circle of the week (or null).","operationId":"discoverCircles","security":[{},{"bearerAuth":[]}],"parameters":[{"name":"q","in":"query","required":false,"description":"Case-insensitive search over name, description, and tags.","schema":{"type":"string"}},{"name":"page","in":"query","required":false,"description":"Page number (20 items per page).","schema":{"type":"integer","minimum":1,"default":1}}],"responses":{"200":{"description":"Paginated public circles.","content":{"application/json":{"schema":{"type":"object","properties":{"circles":{"type":"array","items":{"$ref":"#/components/schemas/DiscoverCircle"}},"spotlight":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/DiscoverCircle"},{"type":"object","properties":{"joins7":{"type":"integer","description":"Members who joined in the last 7 days."},"codes7":{"type":"integer","description":"Public codes members posted in the last 7 days."},"uses7":{"type":"integer","description":"Confirmed uses of members' codes in the last 7 days."}}}],"description":"The circle of the week — the most-active public circle (min 3 members) over the trailing 7 days. Null when searching, paging past page 1, or when no circle qualifies."},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["circles","pagination"]}}}}}}},"/api/discover/people":{"get":{"tags":["Circles"],"summary":"People to follow","description":"The Discover people rail: members ranked by confirmed uses of their codes (the same signal behind Certified Plug tiers). Honors the leaderboard privacy opt-out and excludes the caller. Members-only.","operationId":"discoverPeople","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Up to 6 people, most-confirmed first.","content":{"application/json":{"schema":{"type":"object","properties":{"people":{"type":"array","items":{"$ref":"#/components/schemas/DiscoverPerson"}}},"required":["people"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles/{id}/invite-code":{"post":{"tags":["Circles"],"summary":"Reset a circle's invite link","description":"Rotates the circle's invite_code, invalidating every existing /circles/join/<code> link and printed QR sheet at once. Current members are unaffected. Owner only; blocked on official theme circles.","operationId":"resetCircleInviteCode","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}}],"responses":{"200":{"description":"A new invite code.","content":{"application/json":{"schema":{"type":"object","properties":{"inviteCode":{"type":"string","description":"The new 12-character invite code."}},"required":["inviteCode"]}}}},"400":{"description":"Invalid circle ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the circle owner, or the circle is a theme circle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles/join/{code}":{"get":{"tags":["Circles"],"summary":"Preview a circle by invite code","description":"Returns a circle's preview for an invite code. Works for PRIVATE circles too — the invite code is the credential, and the owner can invalidate every copy of it via POST /api/circles/{id}/invite-code. Official theme circles are excluded (join those from Discover). Works without authentication; when authenticated, is_joined reflects the caller's membership.","operationId":"previewCircleByCode","security":[{},{"bearerAuth":[]}],"parameters":[{"name":"code","in":"path","required":true,"description":"Circle invite code.","schema":{"type":"string"}}],"responses":{"200":{"description":"Circle preview.","content":{"application/json":{"schema":{"type":"object","properties":{"circle":{"$ref":"#/components/schemas/JoinCirclePreview"}},"required":["circle"]}}}},"404":{"description":"Unknown invite code, or an official theme circle (join those from Discover).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Circles"],"summary":"Join a circle by invite code","description":"Joins the authenticated user to the circle matching the invite code and awards join points. Works for PRIVATE circles — holding the invite code is the authorization, which is what makes a group-chat share link work. Official theme circles are excluded.","operationId":"joinCircleByCode","security":[{"bearerAuth":[]}],"parameters":[{"name":"code","in":"path","required":true,"description":"Circle invite code.","schema":{"type":"string"}}],"responses":{"200":{"description":"Joined the circle.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"circleId":{"type":"integer","description":"ID of the joined circle."}},"required":["success","circleId"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown invite code, or an official theme circle (join those from Discover).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Already a member; circleId identifies the circle.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"circleId":{"type":"integer"}},"required":["error","circleId"]}}}}}}},"/api/invites":{"post":{"tags":["Circles"],"summary":"Create an invite","description":"Creates an invite link. With circleId it invites to that circle (requester must be owner or member); without circleId it invites to the platform. If email is provided an invite email is sent (or queued). Pass referralId to attach a \"codes-carrying\" invite: the referral is featured on the public landing page and share unfurl, and the accepter is landed on it — but only if the requester can see that referral (referral_visible_to) and, on the public landing, only if it is a public referral; otherwise it is silently ignored.","operationId":"createInvite","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"circleId":{"type":"integer","description":"Circle to invite to; omit for a platform invite."},"email":{"type":"string","description":"Recipient email; when set, an invite email is sent."},"message":{"type":"string","maxLength":500,"description":"Personal note; truncated to 500 characters."},"referralId":{"type":"integer","description":"Referral to feature on the invite (codes-carrying invite). Stored only if visible to the requester; ignored otherwise."}}}}}},"responses":{"201":{"description":"Invite created with a shareable URL.","content":{"application/json":{"schema":{"type":"object","properties":{"invite":{"$ref":"#/components/schemas/CreatedInvite"}},"required":["invite"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Requester is not a member of the circle being invited to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Circles"],"summary":"List invites I sent","description":"Returns up to 50 of the authenticated user's sent invites, newest first, with acceptance status.","operationId":"listInvites","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Sent invites.","content":{"application/json":{"schema":{"type":"object","properties":{"invites":{"type":"array","items":{"$ref":"#/components/schemas/SentInvite"}}},"required":["invites"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/invites/incoming":{"get":{"tags":["Circles"],"summary":"List invites waiting on you","description":"Pending circle invites addressed to the authenticated caller (username invites). Each row includes the token used to accept (POST /api/invites/{token}) or decline (DELETE /api/invites/{token}).","operationId":"listIncomingInvites","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Pending invites, newest first (max 10).","content":{"application/json":{"schema":{"type":"object","properties":{"invites":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"token":{"type":"string"},"message":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"circle_id":{"type":"integer"},"circle_name":{"type":"string"},"category":{"type":"string"},"image_url":{"type":"string"},"inviter_name":{"type":"string"},"inviter_username":{"type":"string"},"inviter_avatar":{"type":["string","null"]},"member_count":{"type":"integer"}}}}}}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/invites/{token}":{"get":{"tags":["Circles"],"summary":"Preview an invite","description":"Returns invite details by token. Works without authentication; when authenticated, is_logged_in and is_member reflect the caller.","operationId":"previewInvite","security":[{},{"bearerAuth":[]}],"parameters":[{"name":"token","in":"path","required":true,"description":"Invite token.","schema":{"type":"string"}}],"responses":{"200":{"description":"Invite preview.","content":{"application/json":{"schema":{"type":"object","properties":{"invite":{"$ref":"#/components/schemas/InvitePreview"}},"required":["invite"]}}}},"404":{"description":"Invalid or expired invite.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Circles"],"summary":"Accept an invite","description":"Accepts the invite: auto-joins the linked circle (if any), awards points, and marks the invite accepted on first acceptance (the link remains reusable).","operationId":"acceptInvite","security":[{"bearerAuth":[]}],"parameters":[{"name":"token","in":"path","required":true,"description":"Invite token.","schema":{"type":"string"}}],"responses":{"200":{"description":"Invite accepted.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"circleId":{"type":["integer","null"],"description":"Joined circle ID, or null for a platform invite."},"referralId":{"type":["integer","null"],"description":"Featured referral to land the new member on (codes-carrying invite), or null if the invite carried no code."}},"required":["success","circleId","referralId"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Invalid or expired invite.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Circles"],"summary":"Decline an invite","description":"The INVITED user declines a pending invite addressed to them (status becomes declined). Senders cancel their own invites via /api/circles/{id}/invites/{inviteId}.","operationId":"declineInvite","security":[{"bearerAuth":[]}],"parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Declined.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found, not addressed to the caller, or already handled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/auth/me":{"get":{"tags":["Account & Activity"],"summary":"Get the current user with onboarding stats","description":"Returns the authenticated user's profile plus referral count, circle count, and points balance. Without valid credentials it returns 200 with `user: null` instead of an error.","operationId":"getCurrentUser","security":[{"bearerAuth":[]},{}],"responses":{"200":{"description":"Current user, or `user: null` when unauthenticated.","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"oneOf":[{"$ref":"#/components/schemas/CurrentUser"},{"type":"null"}]}},"required":["user"]}}}}}}},"/api/earnings":{"get":{"tags":["Account & Activity"],"summary":"List my referral earnings","description":"Every confirmed use of one of the caller's referrals (latest 50), plus totals grouped by benefit unit (units are heterogeneous, e.g. \"$\", \"Points\", \"Months\").","operationId":"listEarnings","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Earnings totals and events.","content":{"application/json":{"schema":{"type":"object","properties":{"totals":{"type":"array","description":"Per-unit totals, ordered by number of uses descending.","items":{"$ref":"#/components/schemas/EarningsTotal"}},"events":{"type":"array","description":"Most recent 50 earning events, newest first.","items":{"$ref":"#/components/schemas/EarningsEvent"}},"totalUses":{"type":"integer","description":"Total confirmed uses across all units."}},"required":["totals","events","totalUses"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/activity":{"get":{"tags":["Account & Activity"],"summary":"Get my personalized activity feed","description":"Two streams: `circleActivity` (new referrals, joins, and comments in circles the caller belongs to, up to 25 items, excluding items at or before the caller's last clear) and `latestReferrals` (the 10 newest referrals VISIBLE TO THE CALLER).","operationId":"getActivityFeed","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Activity streams.","content":{"application/json":{"schema":{"type":"object","properties":{"circleActivity":{"type":"array","description":"Circle events sorted newest first, max 25; empty if the caller is in no circles.","items":{"$ref":"#/components/schemas/CircleActivityItem"}},"latestReferrals":{"type":"array","description":"The 10 newest referrals visible to the caller. Private codes outside the caller's circles, paused codes, and banned owners' codes are excluded.","items":{"$ref":"#/components/schemas/ActivityReferral"}}},"required":["circleActivity","latestReferrals"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/activity/clear":{"post":{"tags":["Account & Activity"],"summary":"Clear my circle activity feed","description":"Marks circle activity as read up to a timestamp; the feed then only shows newer items. The stored clear time never moves backwards.","operationId":"clearActivity","security":[{"bearerAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"at":{"type":"string","format":"date-time","description":"Optional cutoff timestamp; defaults to now. Invalid values also fall back to now."}}}}}},"responses":{"200":{"description":"Feed cleared.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"clearedAt":{"type":"string","format":"date-time","description":"ISO timestamp the clear was recorded at."}},"required":["success","clearedAt"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/notifications":{"get":{"tags":["Account & Activity"],"summary":"List my notifications","description":"Returns the caller's notifications, newest first, plus an unread count.","operationId":"listNotifications","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"description":"Max notifications to return.","schema":{"type":"integer","default":20,"maximum":50}}],"responses":{"200":{"description":"Notifications and unread count.","content":{"application/json":{"schema":{"type":"object","properties":{"notifications":{"type":"array","items":{"$ref":"#/components/schemas/Notification"}},"unread":{"type":"integer","description":"Number of unread notifications."}},"required":["notifications","unread"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Account & Activity"],"summary":"Mark notifications as read","description":"Marks specific notifications (by id) or all of the caller's notifications as read. If neither `ids` nor `all` is provided, nothing is updated and success is still returned.","operationId":"markNotificationsRead","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"all":{"type":"boolean","description":"If true, marks all unread notifications as read (takes precedence over `ids`)."},"ids":{"type":"array","items":{"type":"integer"},"description":"Notification ids to mark read; ids not owned by the caller are ignored."}}}}}},"responses":{"200":{"description":"Update applied.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/notifications/prefs":{"get":{"tags":["Account & Activity"],"summary":"Get my notification preferences","description":"Returns the caller's stored preferences, or the defaults if none have been saved yet.","operationId":"getNotificationPrefs","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Notification preferences.","content":{"application/json":{"schema":{"type":"object","properties":{"prefs":{"$ref":"#/components/schemas/NotificationPrefs"}},"required":["prefs"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Account & Activity"],"summary":"Update my notification preferences","description":"Partial update: any preference fields present in the body are merged onto the current values (or defaults) and the full merged set is saved.","operationId":"updateNotificationPrefs","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPrefsInput"}}}},"responses":{"200":{"description":"Updated preferences.","content":{"application/json":{"schema":{"type":"object","properties":{"prefs":{"$ref":"#/components/schemas/NotificationPrefs"}},"required":["prefs"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/feedback":{"post":{"tags":["Account & Activity"],"summary":"Submit feedback","description":"Submits user feedback. Authentication is optional; when authenticated, missing name/email default to the caller's profile values.","operationId":"submitFeedback","security":[{"bearerAuth":[]},{}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","maxLength":5000,"description":"Feedback text; required and must be 5000 characters or less after trimming."},"category":{"type":"string","enum":["general","bug","feature","other"],"default":"general","description":"Feedback category; unrecognized values fall back to \"general\"."},"name":{"type":"string","maxLength":100,"description":"Optional sender name (truncated to 100 chars)."},"email":{"type":"string","maxLength":255,"description":"Optional sender email (truncated to 255 chars)."}},"required":["message"]}}}},"responses":{"200":{"description":"Feedback stored.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"400":{"description":"Message missing or longer than 5000 characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/wishlist":{"get":{"tags":["Account & Activity"],"summary":"The public wishlist demand board","description":"Returns the aggregated public board (companies ranked by how many members are waiting on a code, with a has_code flag) plus the caller's own wishlisted company slugs. Pass ?mine=1 to skip the board and return only the caller's slugs (cheap). Browser/session only.","operationId":"getWishlist","security":[{}],"parameters":[{"name":"mine","in":"query","required":false,"schema":{"type":"string"},"description":"Any truthy value returns only the caller's slugs (no board)."}],"responses":{"200":{"description":"Board + the caller's slugs.","content":{"application/json":{"schema":{"type":"object","properties":{"board":{"type":"array","items":{"type":"object","properties":{"slug":{"type":"string"},"company":{"type":"string"},"count":{"type":"integer"},"has_code":{"type":"boolean"},"has_program":{"type":"boolean","description":"True when /company/<slug> resolves even with zero codes (extracted program facts exist)."}}}},"mine":{"type":"array","items":{"type":"string"}}}}}}}}},"post":{"tags":["Account & Activity"],"summary":"Add a company to your wishlist","description":"Adds a standing wish for a company (session members only; 100 max). Idempotent per company.","operationId":"addWishlist","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"company":{"type":"string","maxLength":80},"note":{"type":"string","maxLength":200}},"required":["company"]}}}},"responses":{"200":{"description":"Added.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"slug":{"type":"string"}}}}}},"401":{"description":"Not logged in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Account & Activity"],"summary":"Remove a company from your wishlist","description":"Removes the CALLER's wish for the company. With all=1 (admins only) it instead removes EVERY member's wish for that company — pulling it off the public board entirely.","operationId":"removeWishlist","security":[{"bearerAuth":[]}],"parameters":[{"name":"company","in":"query","required":true,"schema":{"type":"string"}},{"name":"all","in":"query","required":false,"description":"Admins only. Pass exactly \"1\" to remove the company from the board for everyone (deletes every member's wish). Any other value is ignored.","schema":{"type":"string","enum":["1"]}}],"responses":{"200":{"description":"Removed. `removed` (count of deleted wishes) is present only for the admin all=1 form.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"removed":{"type":"integer","description":"Number of wishes deleted (all=1 only)."}}}}}},"403":{"description":"all=1 requires an admin account."}}}},"/api/alerts":{"post":{"tags":["Account & Activity"],"summary":"Set a code alert for a company","description":"Requests a one-shot email alert for the next public referral code posted for a company. Session members with a verified email are armed instantly on their account email (the email field is ignored) and get status \"confirmed\"; everyone else supplies an email and confirms via the emailed link (double opt-in) and gets status \"pending\". Alerts fire once, then disarm. Browser/session only: this endpoint is guarded by invisible bot detection (Vercel BotID), so developer API-key (bearer) callers are rejected — not part of the public API.","operationId":"setCodeAlert","security":[{}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"company":{"type":"string","maxLength":100,"description":"Company name to watch (matched case-insensitively)."},"email":{"type":"string","maxLength":320,"description":"Required for anonymous callers; ignored when authenticated with a verified email."}},"required":["company"]}}}},"responses":{"200":{"description":"Alert stored.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"status":{"type":"string","enum":["confirmed","pending"],"description":"confirmed = armed now; pending = a confirmation email was sent first."}},"required":["ok","status"]}}}},"400":{"description":"Missing/invalid company or email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Request flagged as automated by bot protection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/alerts/confirm":{"get":{"tags":["Account & Activity"],"summary":"Code-alert confirmation page (emailed link)","description":"Renders an HTML page with a Confirm button. Confirmation happens on the POST (below), not this GET, so mail link-scanners can't silently auto-confirm — preserving double opt-in.","operationId":"codeAlertConfirmPage","security":[{}],"parameters":[{"name":"token","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"HTML confirmation page.","content":{"text/html":{"schema":{"type":"string"}}}}}},"post":{"tags":["Account & Activity"],"summary":"Confirm + arm a code alert","description":"Confirms and arms the alert for the supplied token (form field or JSON), then renders a branded confirmation page. Idempotent: an already-used token still returns a 200 page.","operationId":"codeAlertConfirm","security":[{}],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"token":{"type":"string"}},"required":["token"]}}}},"responses":{"200":{"description":"HTML result page.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/api/alerts/unsubscribe":{"get":{"tags":["Account & Activity"],"summary":"Remove a code alert (emailed link)","description":"Deletes the alert belonging to the token and renders a small confirmation page. Idempotent: an already-used token still returns 200.","operationId":"unsubscribeCodeAlert","security":[{}],"parameters":[{"name":"token","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"HTML confirmation page.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/api/notifications/unsubscribe":{"get":{"tags":["Account & Activity"],"summary":"Confirm turning off one email (emailed link)","description":"Login-free link from an email footer. Renders a confirmation page with an Unsubscribe button and changes nothing, so mail scanners that follow links can't unsubscribe anyone.","operationId":"confirmEmailUnsubscribe","security":[{}],"parameters":[{"name":"u","in":"query","required":true,"description":"User id the link was issued to.","schema":{"type":"integer"}},{"name":"p","in":"query","required":true,"description":"The email preference to turn off.","schema":{"type":"string","enum":["email_code_activity"]}},{"name":"t","in":"query","required":true,"description":"Signature for this user and preference, from the email.","schema":{"type":"string"}}],"responses":{"200":{"description":"HTML confirmation page with a POST form.","content":{"text/html":{"schema":{"type":"string"}}}},"400":{"description":"HTML page saying the link is not valid.","content":{"text/html":{"schema":{"type":"string"}}}}}},"post":{"tags":["Account & Activity"],"summary":"Turn off one email (one-click unsubscribe)","description":"Sets the named notification preference to false for the signed user. Mail clients call this directly for RFC 8058 one-click unsubscribe (List-Unsubscribe-Post). Idempotent.","operationId":"emailUnsubscribe","security":[{}],"parameters":[{"name":"u","in":"query","required":true,"description":"User id the link was issued to.","schema":{"type":"integer"}},{"name":"p","in":"query","required":true,"description":"The email preference to turn off.","schema":{"type":"string","enum":["email_code_activity"]}},{"name":"t","in":"query","required":true,"description":"Signature for this user and preference, from the email.","schema":{"type":"string"}}],"responses":{"200":{"description":"HTML page confirming the email is off.","content":{"text/html":{"schema":{"type":"string"}}}},"400":{"description":"HTML page saying the link is not valid.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/api/share-links":{"get":{"tags":["Account & Activity"],"summary":"List my share links with stats","description":"Returns all trackable share links created by the caller, newest first, with click, re-share, and signup stats.","operationId":"listShareLinks","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The caller's share links.","content":{"application/json":{"schema":{"type":"object","properties":{"shareLinks":{"type":"array","items":{"$ref":"#/components/schemas/ShareLinkWithStats"}}},"required":["shareLinks"]}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Account & Activity"],"summary":"Create a trackable share link for a referral","description":"Creates a share link for a referral, or returns the caller's existing link for that referral. `parentCode` links the new share into a re-share attribution chain.","operationId":"createShareLink","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"referralId":{"type":"integer","description":"Id of the referral to share; required and must parse as an integer."},"parentCode":{"type":"string","description":"Optional code of the parent share link for re-share attribution; unknown codes are silently ignored."}},"required":["referralId"]}}}},"responses":{"200":{"description":"The caller already had a share link for this referral; the existing link is returned.","content":{"application/json":{"schema":{"type":"object","properties":{"shareLink":{"type":"object","description":"Existing link (only id and code are returned in this case).","properties":{"id":{"type":"integer"},"code":{"type":"string"}},"required":["id","code"]},"existing":{"type":"boolean","const":true}},"required":["shareLink","existing"]}}}},"201":{"description":"Share link created.","content":{"application/json":{"schema":{"type":"object","properties":{"shareLink":{"$ref":"#/components/schemas/ShareLink"},"existing":{"type":"boolean","const":false}},"required":["shareLink","existing"]}}}},"400":{"description":"`referralId` is missing or not an integer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Referral not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles/{id}/invites":{"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}}],"get":{"tags":["Circles"],"summary":"List pending invites for a circle","description":"Pending (not-yet-accepted) invites for the circle. The owner sees all of them; a member who can invite sees only the ones they sent.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Pending invites.","content":{"application/json":{"schema":{"type":"object","properties":{"invites":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"email":{"type":"string","description":"Target email for link/email invites; empty for username invites."},"status":{"type":"string"},"created_at":{"type":"string"},"invited_username":{"type":["string","null"]},"invited_display_name":{"type":["string","null"]},"avatar_url":{"type":["string","null"]},"inviter_display_name":{"type":["string","null"]}}}},"canManage":{"type":"boolean","description":"True when the caller is the circle owner."}}}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Circle not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/circles/{id}/invites/{inviteId}":{"parameters":[{"name":"id","in":"path","required":true,"description":"Circle ID.","schema":{"type":"integer"}},{"name":"inviteId","in":"path","required":true,"description":"Invite ID.","schema":{"type":"integer"}}],"post":{"tags":["Circles"],"summary":"Resend a pending invite","description":"Re-delivers the invite notification and/or email. Allowed for the circle owner or the invite's sender.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Resent.","content":{"application/json":{"schema":{"type":"object","properties":{"resent":{"type":"boolean"}}}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the owner or the invite sender.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Invite not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Invite is no longer pending.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Circles"],"summary":"Cancel a pending invite","description":"Cancels the invite so it can no longer be accepted. Allowed for the circle owner or the invite's sender.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Cancelled.","content":{"application/json":{"schema":{"type":"object","properties":{"cancelled":{"type":"boolean"}}}}}},"401":{"description":"Not authenticated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the owner or the invite sender.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Invite not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Invite is no longer pending.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"An approved Perko API key (perko_live_...). Request one at https://perko.io/developers"}},"schemas":{"Referral":{"type":"object","description":"A referral code posting.","properties":{"id":{"type":"integer","description":"Referral ID."},"user_id":{"type":"integer","description":"ID of the posting user."},"title":{"type":"string","description":"Listing title."},"company":{"type":"string","description":"Company name."},"website_url":{"type":"string","description":"Product website URL; also the grouping key for codes of the same product."},"logo_url":{"type":["string","null"],"description":"Resolved company logo URL."},"referral_link":{"type":"string","description":"The actual referral link or code URL. EMPTY STRING when the code's status is not 'active' (paused by its owner, or used_up after reaching its maxUses cap) and the caller is not its owner — such a code keeps its page, which explains why, but never hands out the working link. Same rule applies to code_text."},"tags":{"type":"string","description":"Comma-separated lowercase tags."},"description":{"type":"string","description":"Free-text description."},"customer_benefit":{"type":"string","description":"Human-readable customer benefit (\"Exclusive access\" for invite-only referrals)."},"referrer_benefit":{"type":"string","description":"Human-readable referrer benefit; may be empty."},"customer_benefit_amount":{"type":"string","description":"Customer benefit amount; empty for exclusive-access referrals."},"customer_benefit_unit":{"type":"string","description":"Customer benefit unit; empty for exclusive-access referrals. May carry an \"up to \" prefix (e.g. \"up to $ off\") meaning the amount is a spend-based maximum."},"referrer_benefit_amount":{"type":"string","description":"Referrer benefit amount; may be empty."},"referrer_benefit_unit":{"type":"string","description":"Referrer benefit unit; may be empty. May carry an \"up to \" prefix (e.g. \"up to $ off\") meaning the amount is a spend-based maximum."},"click_count":{"type":"integer","description":"Total clicks recorded on this referral."},"created_at":{"type":"string","format":"date-time"},"exclusive_access":{"type":"boolean","description":"True for invite-only referrals where the code unlocks access."},"visibility":{"type":"string","enum":["public","private"],"description":"private referrals are visible only within the poster's circles."},"share_scope":{"type":"string","enum":["all_circles","private_circles","selected"],"description":"Which circles a private referral is shared into. Not included in feed list rows."}}},"ReferralFeedItem":{"description":"A feed row from GET /api/referrals. For sort=new|hot|rising it is the representative code of a product group with group stats; for sort=az|price or user/tag filters only the core fields plus username/display_name are present.","allOf":[{"$ref":"#/components/schemas/Referral"},{"type":"object","properties":{"username":{"type":"string","description":"Poster's username."},"display_name":{"type":"string","description":"Poster's display name."},"score":{"type":"integer","description":"Vote sum. Grouped sorts only."},"comment_count":{"type":"integer","description":"Number of comments. Grouped sorts only."},"user_vote":{"type":["integer","null"],"enum":[1,-1,null],"description":"The caller's vote on this referral, if any. Grouped sorts only."},"works_count":{"type":"integer","description":"Number of works validation reports. Grouped sorts only."},"fails_count":{"type":"integer","description":"Number of does-not-work reports. Grouped sorts only."},"recent_worked":{"type":"integer","description":"Distinct people (members and signed-out visitors) who said in the last 30 days that this code worked, from the \"did you use it?\" follow-up. Unverified and display only: it never affects points, rankings or the use cap. Excludes the owner's own answers and every answer recorded before 2026-10-12. Grouped sorts only."},"looks_dead":{"type":"boolean","description":"True when fails_count exceeds works_count. sort=hot only."},"code_count":{"type":"integer","description":"Number of codes in this product group. Grouped sorts only."},"group_clicks":{"type":"integer","description":"Total clicks across the product group. sort=hot|rising only."},"group_newest":{"type":"string","format":"date-time","description":"Newest code timestamp in the group. Grouped sorts only."},"rn":{"type":"integer","description":"Internal row number within the group (always 1). Grouped sorts only."},"owners":{"type":"array","description":"All users with a visible code in this product group, most-clicked first. Grouped sorts only.","items":{"$ref":"#/components/schemas/OwnerRef"}},"viewer_has_code":{"type":"boolean","description":"Whether the caller already has a code for this product. Grouped sorts only."}}}]},"ReferralDetail":{"description":"Full referral detail from GET /api/referrals/{id}.","allOf":[{"$ref":"#/components/schemas/Referral"},{"type":"object","properties":{"username":{"type":"string","description":"Poster's username."},"display_name":{"type":"string","description":"Poster's display name."},"lifetime_points":{"type":"integer","description":"Poster's lifetime gamification points."},"score":{"type":"integer","description":"Vote sum."},"comment_count":{"type":"integer","description":"Number of comments."},"user_vote":{"type":["integer","null"],"enum":[1,-1,null],"description":"The caller's vote, if any."},"works_count":{"type":"integer","description":"Number of works validation reports."},"fails_count":{"type":"integer","description":"Number of does-not-work reports."},"recent_worked":{"type":"integer","description":"Distinct people (members and signed-out visitors) who said in the last 30 days that this code worked, from the \"did you use it?\" follow-up. Unverified and display only: it never affects points, rankings or the use cap. Excludes the owner's own answers and every answer recorded before 2026-10-12."},"last_worked_at":{"type":["string","null"],"format":"date-time","description":"Most recent works report timestamp."},"user_validation":{"type":["boolean","null"],"description":"The caller's own validation report, if any."},"shared_circle":{"type":"boolean","description":"Whether the caller shares a circle with the poster."},"shared_circle_ids":{"type":"array","items":{"type":"integer"},"description":"Circle IDs the referral is pinned into; populated only when the caller is the owner, otherwise empty."},"avatar_url":{"type":"string","description":"Profile picture URL (CDN-hosted), or empty string if none."}}}]},"RelatedReferral":{"description":"A related referral in sameCompany / sameReferral lists. lifetime_points, works_count, fails_count, recent_worked, and shared_circle are present only in sameReferral.","allOf":[{"$ref":"#/components/schemas/Referral"},{"type":"object","properties":{"username":{"type":"string","description":"Poster's username."},"display_name":{"type":"string","description":"Poster's display name."},"lifetime_points":{"type":"integer","description":"Poster's lifetime points. sameReferral only."},"works_count":{"type":"integer","description":"Works report count. sameReferral only."},"fails_count":{"type":"integer","description":"Does-not-work report count. sameReferral only."},"recent_worked":{"type":"integer","description":"Distinct people (members and signed-out visitors) who said in the last 30 days that this code worked, from the \"did you use it?\" follow-up. Unverified and display only: it never affects points, rankings or the use cap. Excludes the owner's own answers and every answer recorded before 2026-10-12. sameReferral only."},"shared_circle":{"type":"boolean","description":"Whether the caller shares a circle with the poster. sameReferral only."},"avatar_url":{"type":"string","description":"Profile picture URL (CDN-hosted), or empty string if none."}}}]},"ReferralInput":{"type":"object","description":"Request body for creating or updating a referral. customerBenefitAmount and customerBenefitUnit are required unless exclusiveAccess is true; circleIds is required when visibility=private and shareScope=selected.","required":["title","company","websiteUrl","referralLink","tags","description"],"properties":{"title":{"type":"string","description":"Listing title."},"company":{"type":"string","description":"Company name; whitespace is trimmed."},"websiteUrl":{"type":"string","description":"Product website URL; also used to resolve the company logo. Must be a full https:// URL (http:// is rejected on create; an unchanged pre-policy http URL is grandfathered on edit)."},"referralLink":{"type":"string","description":"The referral link or code URL. Must be a full https:// URL (http:// is rejected on create; an unchanged pre-policy http URL is grandfathered on edit)."},"tags":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Comma-separated string or array of tags; normalized to lowercase and deduplicated."},"description":{"type":"string","description":"Free-text description."},"customerBenefitAmount":{"type":"string","description":"Customer benefit amount (e.g. \"10\"). Required unless exclusiveAccess is true."},"customerBenefitUnit":{"type":"string","description":"Customer benefit unit (e.g. \"% off\"). Required unless exclusiveAccess is true. May carry an \"up to \" prefix (e.g. \"up to $ off\") meaning the amount is a spend-based maximum."},"referrerBenefitAmount":{"type":"string","description":"Optional referrer benefit amount."},"referrerBenefitUnit":{"type":"string","description":"Optional referrer benefit unit. May carry an \"up to \" prefix (e.g. \"up to $ off\") meaning the amount is a spend-based maximum."},"exclusiveAccess":{"type":"boolean","default":false,"description":"True for invite-only referrals; benefit amounts are then ignored."},"maxUses":{"type":"integer","nullable":true,"minimum":1,"maximum":10000,"description":"Retire the code automatically once this many uses are confirmed. Null, absent or empty string means no limit. A \"use\" is a referral_earnings row: a DIFFERENT user answering the follow-up with \"yes I used it\" AND the code working, counted at most once per person. Clicks and failed uses never count. On reaching the cap the referral's status becomes used_up, which hides it from every surface except its owner's. On PATCH, omitting the field leaves the stored value alone; raising or clearing it reactivates a used_up code, and lowering it below the confirmed count retires a live one."},"visibility":{"type":"string","enum":["public","private"],"default":"public","description":"Any value other than \"private\" is treated as public."},"shareScope":{"type":"string","enum":["all_circles","private_circles","selected"],"default":"all_circles","description":"Only used when visibility=private; invalid values fall back to all_circles."},"circleIds":{"type":"array","items":{"type":"integer"},"description":"Circle IDs to pin the referral into when shareScope=selected; the caller must be a member of each circle."}}},"OwnerRef":{"type":"object","description":"A user who owns a code in a product group.","properties":{"username":{"type":"string"},"display_name":{"type":"string"}}},"Comment":{"type":"object","description":"A comment on a referral.","properties":{"id":{"type":"integer","description":"Comment ID."},"referral_id":{"type":"integer","description":"ID of the referral commented on."},"user_id":{"type":"integer","description":"Author's user ID."},"parent_id":{"type":["integer","null"],"description":"Parent comment ID when the comment is a reply."},"body":{"type":"string","description":"Comment text (max 2000 characters)."},"created_at":{"type":"string","format":"date-time"},"username":{"type":"string","description":"Author's username."},"display_name":{"type":"string","description":"Author's display name."},"avatar_url":{"type":"string","description":"Profile picture data URL. Present on the POST response only; the GET list dedupes avatars into the top-level avatars map instead."}}},"Pagination":{"type":"object","description":"Pagination metadata.","properties":{"page":{"type":"integer","description":"Current page number."},"limit":{"type":"integer","description":"Items per page."},"total":{"type":"integer","description":"Total matching items (product groups for grouped sorts)."},"totalPages":{"type":"integer","description":"Total number of pages."}}},"Error":{"type":"object","description":"Standard error envelope.","properties":{"error":{"type":"string","description":"Human-readable error message."}}},"SearchReferral":{"type":"object","description":"A referral as returned by search results, including poster identity.","properties":{"recent_worked":{"type":"integer","description":"Distinct people (members and signed-out visitors) who said in the last 30 days that this code worked. Unverified and display only: it never affects points, rankings or the use cap. Excludes the owner's own answers and every answer recorded before 2026-10-12."},"id":{"type":"integer","description":"Referral ID."},"title":{"type":["string","null"],"description":"Referral title. NULL when visible is false."},"company":{"type":"string","description":"Company the referral is for."},"category":{"type":["string","null"],"description":"The company's auto-classified category (one controlled bucket per company), or null if the company hasn't been classified yet."},"website_url":{"type":["string","null"],"description":"Company or product website URL."},"logo_url":{"type":["string","null"],"description":"Logo image URL."},"referral_link":{"type":["string","null"],"description":"The referral link or code URL."},"tags":{"type":"string","description":"Comma-separated tags."},"description":{"type":["string","null"],"description":"Free-text description."},"customer_benefit":{"type":["string","null"],"description":"What the new customer gets."},"referrer_benefit":{"type":["string","null"],"description":"What the referrer gets."},"customer_benefit_amount":{"type":"string","description":"Value of the customer benefit. (string; may be empty)."},"customer_benefit_unit":{"type":["string","null"],"description":"Unit for the customer benefit amount."},"referrer_benefit_amount":{"type":"string","description":"Value of the referrer benefit. (string; may be empty)."},"referrer_benefit_unit":{"type":["string","null"],"description":"Unit for the referrer benefit amount."},"created_at":{"type":"string","format":"date-time","description":"Creation timestamp."},"user_id":{"type":"integer","description":"ID of the user who posted the referral."},"exclusive_access":{"type":"boolean","description":"True for invite-only (exclusive access) referrals."},"username":{"type":"string","description":"Poster's username."},"display_name":{"type":"string","description":"Poster's display name."},"in_circle":{"type":"boolean","description":"True when the poster directly shares a non-system circle with the caller."},"relationship":{"type":"string","enum":["self","circle","extended","community"],"description":"Privacy-safe relationship provenance. extended means a public code from an opted-in owner two degrees away; the bridge user and circle are never returned."}}},"ProfileReferral":{"type":"object","description":"A referral as returned on a user's profile.","properties":{"id":{"type":"integer","description":"Referral ID."},"title":{"type":"string","description":"Referral title."},"website_url":{"type":["string","null"],"description":"Company or product website URL."},"logo_url":{"type":["string","null"],"description":"Logo image URL."},"referral_link":{"type":["string","null"],"description":"The referral link or code URL."},"tags":{"type":"string","description":"Comma-separated tags."},"description":{"type":["string","null"],"description":"Free-text description."},"customer_benefit":{"type":["string","null"],"description":"What the new customer gets."},"referrer_benefit":{"type":["string","null"],"description":"What the referrer gets."},"customer_benefit_amount":{"type":"string","description":"Value of the customer benefit. (string; may be empty)."},"customer_benefit_unit":{"type":["string","null"],"description":"Unit for the customer benefit amount."},"referrer_benefit_amount":{"type":"string","description":"Value of the referrer benefit. (string; may be empty)."},"referrer_benefit_unit":{"type":["string","null"],"description":"Unit for the referrer benefit amount."},"click_count":{"type":"integer","description":"Number of clicks on the referral link (0 when never clicked)."},"created_at":{"type":"string","format":"date-time","description":"Creation timestamp."},"exclusive_access":{"type":"boolean","description":"True for invite-only (exclusive access) referrals."},"visibility":{"type":"string","description":"Referral visibility, e.g. public or private."}}},"UserProfile":{"type":"object","description":"A user account as returned by profile endpoints.","properties":{"id":{"type":"integer","description":"User ID."},"username":{"type":"string","description":"Unique username."},"display_name":{"type":"string","description":"Display name."},"email":{"type":"string","description":"Email address. Only present when the caller is the profile owner (or an admin) — never exposed to other users."},"created_at":{"type":"string","format":"date-time","description":"Account creation timestamp."},"points_balance":{"type":"integer","description":"Current spendable points balance."},"lifetime_points":{"type":"integer","description":"Total points earned over the account's lifetime."},"show_on_leaderboard":{"type":"boolean","description":"Whether the user appears on the public leaderboard."},"trusted_radius_enabled":{"type":"boolean","description":"Whether public codes may appear in second-degree Trusted Radius results. Present only for the profile owner or an admin."},"avatar_url":{"type":"string","description":"Profile picture URL (CDN-hosted), or empty string if none."},"follower_count":{"type":"integer","description":"How many users follow this profile."},"following_count":{"type":"integer","description":"How many users this profile follows."},"is_following":{"type":"boolean","description":"Whether the authenticated caller follows this profile (false when unauthenticated)."}}},"LeaderboardEntry":{"type":"object","description":"One row on the points leaderboard.","properties":{"id":{"type":"integer","description":"User ID."},"username":{"type":"string","description":"Username."},"display_name":{"type":"string","description":"Display name."},"points":{"type":"integer","description":"Points for the selected period (lifetime_points when period is all)."},"lifetime_points":{"type":"integer","description":"Total points earned over the account's lifetime."},"avatar_url":{"type":"string","description":"Profile picture URL (CDN-hosted), or empty string if none."},"flair":{"type":["object","null"],"description":"The flair pill this user is wearing ({ key, emoji, label, color }), or null.","properties":{"key":{"type":"string"},"emoji":{"type":"string"},"label":{"type":"string"},"color":{"type":"string"}}}}},"LeaderboardViewer":{"type":"object","description":"The authenticated caller's own standing on the leaderboard.","properties":{"rank":{"type":"integer","description":"1-based rank among leaderboard-visible users."},"points":{"type":"integer","description":"The caller's points for the selected period."}}},"UserSummary":{"type":"object","description":"Minimal user identity returned by user search.","properties":{"id":{"type":"integer","description":"User ID."},"username":{"type":"string","description":"Username."},"display_name":{"type":"string","description":"Display name."}}},"CircleSummary":{"type":"object","description":"A circle the caller belongs to, for the search filter sidebar.","properties":{"id":{"type":"integer","description":"Circle ID."},"name":{"type":"string","description":"Circle name."},"type":{"type":"string","description":"Circle type."}}},"Facet":{"type":"object","description":"A filterable value with the number of visible referrals carrying it.","properties":{"name":{"type":"string","description":"The tag (lowercased) or exact company name — pass it back as the tag / company query parameter."},"count":{"type":"integer","description":"Number of matching referrals visible to the caller."}}},"Success":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]},"Circle":{"type":"object","description":"A circle record joined with its owner's public profile fields.","properties":{"id":{"type":"integer"},"name":{"type":"string","maxLength":100},"description":{"type":"string"},"owner_id":{"type":"integer","description":"User ID of the circle owner."},"type":{"type":"string","enum":["private","public"]},"visibility":{"type":"string","enum":["visible","invite_only"]},"invite_code":{"type":["string","null"],"description":"Shareable code used with /api/circles/join/{code}."},"category":{"type":"string","enum":["friends","products","following","tags"]},"tags":{"type":"string","description":"Comma-separated lowercase tags."},"share_all_items":{"type":"boolean","description":"True when content_mode is all_members."},"content_mode":{"type":"string","enum":["all_members","selected_members","pinned_only"],"description":"Which referrals appear in the circle feed."},"notify_member_joined":{"type":"boolean"},"notify_item_added":{"type":"boolean"},"members_can_add":{"type":"boolean","description":"Whether non-owner members may add users by username."},"image_url":{"type":"string","description":"Circle photo as a data URL, or empty string."},"created_at":{"type":"string","format":"date-time"},"owner_username":{"type":"string"},"owner_display_name":{"type":"string"}}},"CircleWithCounts":{"allOf":[{"$ref":"#/components/schemas/Circle"},{"type":"object","properties":{"member_count":{"type":"string","description":"Number of members in the circle (bigint count, serialized as a string)."},"referral_count":{"type":"string","description":"Number of referrals in the circle feed per its content_mode (bigint count, serialized as a string)."},"unread":{"type":"boolean","description":"True when a code the caller can see was added to the circle feed by someone else since the caller last opened the circle."},"pot":{"$ref":"#/components/schemas/CirclePot"}}}]},"CirclePot":{"type":"object","description":"What the circle's current members have earned through each other's codes (friend-to-friend confirmed uses only). Every use is classified into exactly one bucket: exclusive-access referrals count as invites, percentage benefits count as discounts, parseable numeric amounts are summed per unit into lines, and any other benefit counts as a perk.","properties":{"lines":{"type":"array","description":"Numeric totals grouped by benefit unit, largest first.","items":{"type":"object","properties":{"unit":{"type":"string","description":"Benefit unit, e.g. \"$\", \"free months\", \"Points\"."},"amount":{"type":"number"}},"required":["unit","amount"]}},"discounts":{"type":"integer","description":"Percentage benefits, counted rather than summed."},"invites":{"type":"integer","description":"Exclusive-access invites unlocked through a member's referral."},"perks":{"type":"integer","description":"Benefits with no parseable numeric amount (free-text or unlisted)."},"uses":{"type":"integer","description":"Total confirmed friend-to-friend uses."},"usdTotal":{"type":"number","description":"Total of the \"$\"/\"usd\" lines; drives dollar milestone celebrations."}},"required":["lines","discounts","invites","perks","uses","usdTotal"]},"CreatedCircle":{"type":"object","description":"Circle fields returned by the create endpoint.","properties":{"id":{"type":"integer"},"name":{"type":"string"},"description":{"type":"string"},"owner_id":{"type":"integer"},"type":{"type":"string","enum":["private","public"]},"visibility":{"type":"string","enum":["visible","invite_only"]},"invite_code":{"type":"string"},"category":{"type":"string","enum":["friends","products","following","tags"]},"tags":{"type":"string","description":"Comma-separated lowercase tags."},"content_mode":{"type":"string","enum":["all_members","selected_members","pinned_only"]},"members_can_add":{"type":"boolean","description":"Whether regular members can invite people."},"created_at":{"type":"string","format":"date-time"}}},"CircleDetail":{"allOf":[{"$ref":"#/components/schemas/Circle"},{"type":"object","properties":{"is_member":{"type":"boolean","description":"Whether the caller is a member."},"is_owner":{"type":"boolean","description":"Whether the caller owns the circle."},"user_role":{"type":["string","null"],"enum":["owner","member",null],"description":"Caller's membership role, or null if not a member."},"shared_member_ids":{"type":"array","items":{"type":"integer"},"description":"User IDs whose codes appear when content_mode is selected_members."},"pot":{"$ref":"#/components/schemas/CirclePot"}}}]},"CircleRequest":{"type":"object","description":"A demand-side ask posted to a circle (or all the requester's circles).","properties":{"id":{"type":"integer"},"user_id":{"type":"integer","description":"The requester."},"circle_id":{"type":["integer","null"],"description":"Target circle, or null for all the requester's circles."},"company":{"type":"string","description":"What they need a code for."},"note":{"type":"string"},"status":{"type":"string","enum":["open","fulfilled","expired","cancelled"]},"created_at":{"type":"string","format":"date-time"},"fulfilled_at":{"type":["string","null"],"format":"date-time"},"fulfilled_referral_id":{"type":["integer","null"]},"fulfilled_by":{"type":["integer","null"],"description":"User ID of the fulfiller (list endpoint only)."},"username":{"type":"string","description":"Requester's username (list endpoint only)."},"display_name":{"type":"string","description":"Requester's display name (list endpoint only)."},"avatar_url":{"type":["string","null"],"description":"Requester's avatar (list endpoint only)."},"fulfilled_by_name":{"type":["string","null"],"description":"Fulfiller's display name (list endpoint only)."},"thanked":{"type":"boolean","description":"Whether the requester has already thanked the fulfiller (list endpoint only)."}},"required":["id","user_id","company","status","created_at"]},"CircleActivityLogItem":{"type":"object","description":"One entry on a circle's activity log.","properties":{"kind":{"type":"string","enum":["member_joined","referral_posted","referral_pinned","referral_shared","code_used","milestone"],"description":"referral_pinned = the circle owner pinned a code; referral_shared = a code was shared into the circle (e.g. a private code scoped to it, or a member adding one)."},"at":{"type":"string","format":"date-time","description":"When it happened."},"actor_username":{"type":["string","null"],"description":"The named actor (for code_used this is the code OWNER — the user who used it is anonymized). Null for milestones."},"actor_name":{"type":["string","null"]},"avatar_url":{"type":["string","null"],"description":"Actor's profile picture data URL, empty string, or null."},"referral_id":{"type":["integer","null"]},"referral_title":{"type":["string","null"]},"detail":{"type":["string","null"],"description":"member_joined: the membership role ('owner' means they created the circle). milestone: '<kind>:<threshold>' e.g. 'usd:100' or 'uses:10'. Otherwise null."}},"required":["kind","at"]},"CircleMember":{"type":"object","properties":{"membership_id":{"type":"integer","description":"ID of the membership row."},"user_id":{"type":"integer"},"role":{"type":"string","enum":["owner","member"]},"joined_at":{"type":"string","format":"date-time"},"username":{"type":"string"},"display_name":{"type":"string"},"avatar_url":{"type":"string","description":"Profile picture URL (CDN-hosted), or empty string if none."}}},"CircleFeedReferral":{"allOf":[{"$ref":"#/components/schemas/Referral"},{"type":"object","properties":{"recent_worked":{"type":"integer","description":"Distinct people (members and signed-out visitors) who said in the last 30 days that this code worked. Unverified and display only: it never affects points, rankings or the use cap. Excludes the owner's own answers and every answer recorded before 2026-10-12."},"username":{"type":"string","description":"Username of the referral's owner."},"display_name":{"type":"string","description":"Display name of the referral's owner."},"is_pinned":{"type":"boolean","description":"True only when the circle owner pinned this code (curation). Codes shared into the circle — e.g. private codes scoped to it — appear in the feed but are not pinned."}}}]},"DiscoverCircle":{"type":"object","description":"Public circle card shown in Discover.","properties":{"id":{"type":"integer"},"name":{"type":"string"},"description":{"type":"string"},"owner_id":{"type":"integer"},"type":{"type":"string","enum":["public"]},"visibility":{"type":"string","enum":["visible"]},"created_at":{"type":"string","format":"date-time"},"image_url":{"type":"string"},"tags":{"type":"string","description":"Comma-separated lowercase tags."},"category":{"type":"string","enum":["friends","products","following","tags","theme"]},"owner_username":{"type":"string"},"owner_display_name":{"type":"string"},"member_count":{"type":"string","description":"Count (bigint count, serialized as a string)."},"is_joined":{"type":"boolean","description":"Whether the caller is a member; always false when unauthenticated."},"strong_categories":{"type":"array","items":{"type":"string"},"description":"Up to two company categories this circle's codes cover most (by distinct companies). Empty when the circle has codes but none are categorized yet; absent on theme circles and circles with no visible codes."},"last_code_at":{"type":"string","format":"date-time","nullable":true,"description":"When the newest public code behind this circle was posted. Absent when the circle has no codes."},"new_category_count":{"type":"integer","nullable":true,"description":"How many of this circle's categories none of the caller's circles cover yet. Null when unauthenticated; absent on theme circles."}}},"DiscoverPerson":{"type":"object","description":"A member on the Discover people-to-follow rail, ranked by confirmed uses of their codes.","properties":{"id":{"type":"integer"},"username":{"type":"string"},"display_name":{"type":"string"},"avatar_url":{"type":"string","nullable":true},"confirmed_uses":{"type":"integer","description":"Times someone confirmed using one of their codes."},"codes":{"type":"integer","description":"Their active public codes."},"is_following":{"type":"boolean","description":"Whether the caller already follows them."}}},"JoinCirclePreview":{"type":"object","description":"Circle preview returned when looking up an invite code.","properties":{"id":{"type":"integer"},"name":{"type":"string"},"description":{"type":"string"},"type":{"type":"string","enum":["public","private"],"description":"Private circles are previewable and joinable by invite code — the code is the credential."},"visibility":{"type":"string","enum":["visible","invite_only"]},"category":{"type":"string","description":"Circle category (friends, products, following, tags). Never 'theme' — official theme circles are excluded from join-by-code."},"created_at":{"type":"string","format":"date-time"},"owner_username":{"type":"string"},"owner_display_name":{"type":"string"},"member_count":{"type":"string","description":"Count (bigint count, serialized as a string)."},"is_joined":{"type":"boolean","description":"Whether the caller is already a member; false when unauthenticated."}}},"Invite":{"type":"object","description":"An invite record.","properties":{"id":{"type":"integer"},"token":{"type":"string","description":"Token used in the invite URL."},"circle_id":{"type":["integer","null"],"description":"Circle the invite targets, or null for a platform invite."},"email":{"type":"string","description":"Recipient email, or empty string for link-only invites."},"message":{"type":"string","description":"Personal note attached to the invite."},"status":{"type":"string","enum":["pending","accepted"]},"created_at":{"type":"string","format":"date-time"}}},"CreatedInvite":{"allOf":[{"$ref":"#/components/schemas/Invite"},{"type":"object","properties":{"url":{"type":"string","description":"Shareable invite URL that auto-joins the circle on signup/login."}}}]},"SentInvite":{"allOf":[{"$ref":"#/components/schemas/Invite"},{"type":"object","properties":{"accepted_at":{"type":["string","null"],"format":"date-time"},"circle_name":{"type":["string","null"],"description":"Name of the target circle, or null for platform invites."},"accepted_username":{"type":["string","null"],"description":"Username of the accepter, if accepted."},"accepted_display_name":{"type":["string","null"],"description":"Display name of the accepter, if accepted."}}}]},"InvitePreview":{"type":"object","description":"Public preview of an invite by token.","properties":{"id":{"type":"integer"},"token":{"type":"string"},"circle_id":{"type":["integer","null"]},"message":{"type":"string"},"status":{"type":"string","enum":["pending","accepted"]},"created_at":{"type":"string","format":"date-time"},"inviter_username":{"type":"string"},"inviter_display_name":{"type":"string"},"circle_name":{"type":["string","null"]},"circle_description":{"type":["string","null"]},"circle_category":{"type":["string","null"]},"member_count":{"type":"string","description":"Member count of the target circle (bigint count, serialized as a string)."},"referral_count":{"type":"string","description":"Referral count across circle members (bigint count, serialized as a string)."},"is_logged_in":{"type":"boolean","description":"Whether the caller is authenticated."},"is_member":{"type":"boolean","description":"Whether the caller is already a member of the target circle."}}},"CurrentUser":{"type":"object","description":"The authenticated user's profile plus lightweight onboarding stats.","properties":{"id":{"type":"integer","description":"User id."},"username":{"type":"string","description":"Unique username."},"display_name":{"type":["string","null"],"description":"Display name."},"email":{"type":["string","null"],"description":"Email address."},"is_admin":{"type":"boolean","description":"Whether the user is an admin."},"email_verified":{"type":"boolean","description":"Whether the email address is verified."},"created_at":{"type":"string","format":"date-time","description":"Account creation time."},"referral_count":{"type":"integer","description":"Number of referral codes the user has added."},"circle_count":{"type":"integer","description":"Number of circles the user owns or belongs to."},"points_balance":{"type":"integer","description":"Current gamification points balance."},"avatar_url":{"type":"string","description":"Profile picture URL (CDN-hosted), or empty string if none."},"lifetime_points":{"type":"integer","description":"Total points ever earned (never decremented by spending)."},"has_password":{"type":"boolean","description":"Whether the account has a login password set (false for Google-only accounts). Drives the delete-account confirmation flow."}},"required":["id","username","referral_count","circle_count","points_balance"]},"EarningsTotal":{"type":"object","description":"Aggregated earnings for one benefit unit.","properties":{"unit":{"type":["string","null"],"description":"Benefit unit, e.g. \"$\", \"Points\", \"Months\"."},"uses":{"type":"integer","description":"Number of confirmed uses in this unit."},"total":{"type":["string","null"],"description":"Sum of numeric amounts in this unit (numeric, serialized as a string); null if no amounts were parseable."}},"required":["unit","uses","total"]},"EarningsEvent":{"type":"object","description":"One use of the caller's referral: either confirmed by the person who used it, or recorded on the caller's behalf by the Perko team (see `source`).","properties":{"id":{"type":"integer","description":"Earning event id."},"referral_id":{"type":["integer","null"],"description":"Id of the referral that was used."},"referral_title":{"type":["string","null"],"description":"Referral title at the time of use."},"company":{"type":["string","null"],"description":"Company the referral is for."},"amount":{"type":["string","null"],"description":"Benefit amount as stored (free-form string)."},"unit":{"type":["string","null"],"description":"Benefit unit."},"created_at":{"type":"string","format":"date-time","description":"When the earning was recorded."},"source":{"type":"string","enum":["confirmation","admin"],"description":"How the use was recorded: `confirmation` when the person who used the code answered the follow-up themselves, `admin` when the Perko team recorded it on the owner's behalf. Admin-recorded uses may have a null `used_by` (the person had no account)."},"used_by":{"type":["integer","null"],"description":"User ID of the person who used the referral (for thanking them)."},"used_by_username":{"type":["string","null"],"description":"Username of the user who used the referral."},"used_by_name":{"type":["string","null"],"description":"Display name of the user who used the referral."},"logo_url":{"type":["string","null"],"description":"Referral's company logo URL."},"thanked":{"type":"boolean","description":"Whether the caller has already thanked this user for this use."}},"required":["id","referral_id","referral_title","company","amount","unit","created_at"]},"CircleActivityItem":{"type":"object","description":"One event from circles the caller belongs to.","properties":{"kind":{"type":"string","enum":["referral_posted","member_joined","comment_added"],"description":"Type of event."},"referral_id":{"type":["integer","null"],"description":"Referral id; null for member_joined events."},"referral_title":{"type":["string","null"],"description":"Referral title; null for member_joined events."},"referral_company":{"type":["string","null"],"description":"Referral company; null for member_joined events."},"logo_url":{"type":["string","null"],"description":"Referral logo URL; null for member_joined events."},"at":{"type":"string","format":"date-time","description":"When the event happened."},"actor_id":{"type":"integer","description":"Id of the user who acted."},"actor_username":{"type":"string","description":"Username of the acting user."},"actor_name":{"type":["string","null"],"description":"Display name of the acting user."},"circle_id":{"type":["integer","null"],"description":"Circle id; null for comment_added events."},"circle_name":{"type":["string","null"],"description":"Circle name; null for comment_added events."},"comment_body":{"type":"string","description":"Comment text; present only on comment_added events."}},"required":["kind","at","actor_id","actor_username"]},"ActivityReferral":{"type":"object","description":"A referral in the latest-referrals stream (scoped to what the caller may see).","properties":{"id":{"type":"integer","description":"Referral id."},"title":{"type":"string","description":"Referral title."},"company":{"type":["string","null"],"description":"Company name."},"website_url":{"type":["string","null"],"description":"Company website URL."},"logo_url":{"type":["string","null"],"description":"Company logo URL."},"referral_link":{"type":["string","null"],"description":"The shared referral link or code."},"tags":{"type":"string","description":"Comma-separated tag list, e.g. \"vpn,streaming\"."},"description":{"type":["string","null"],"description":"Referral description."},"customer_benefit":{"type":["string","null"],"description":"What the new customer gets."},"referrer_benefit":{"type":["string","null"],"description":"What the referrer gets."},"customer_benefit_amount":{"type":["string","null"],"description":"Customer benefit amount."},"customer_benefit_unit":{"type":["string","null"],"description":"Customer benefit unit. May carry an \"up to \" prefix (e.g. \"up to $ off\") meaning the amount is a spend-based maximum."},"referrer_benefit_amount":{"type":["string","null"],"description":"Referrer benefit amount."},"referrer_benefit_unit":{"type":["string","null"],"description":"Referrer benefit unit. May carry an \"up to \" prefix (e.g. \"up to $ off\") meaning the amount is a spend-based maximum."},"click_count":{"type":"integer","description":"Number of clicks on the referral."},"created_at":{"type":"string","format":"date-time","description":"When the referral was posted."},"user_id":{"type":"integer","description":"Id of the referral owner."},"username":{"type":"string","description":"Owner's username."},"display_name":{"type":["string","null"],"description":"Owner's display name."},"score":{"type":"integer","description":"Sum of votes on the referral."},"comment_count":{"type":"integer","description":"Number of comments on the referral."},"user_vote":{"type":["integer","null"],"description":"The caller's own vote value, or null if they haven't voted."}},"required":["id","title","created_at","user_id","username","score","comment_count"]},"Notification":{"type":"object","description":"An in-app notification.","properties":{"id":{"type":"integer","description":"Notification id."},"type":{"type":"string","description":"Notification type key."},"title":{"type":"string","description":"Short notification title."},"body":{"type":["string","null"],"description":"Notification body text."},"link":{"type":["string","null"],"description":"In-app link the notification points to."},"is_read":{"type":"boolean","description":"Whether the notification has been read."},"created_at":{"type":"string","format":"date-time","description":"When the notification was created."}},"required":["id","type","title","is_read","created_at"]},"NotificationPrefs":{"type":"object","description":"Per-user notification preferences. Rows loaded from the database may include extra columns such as user_id and updated_at.","properties":{"notify_invites":{"type":"boolean","default":true,"description":"In-app: circle invites."},"notify_member_joined":{"type":"boolean","default":true,"description":"In-app: someone joined your circle."},"notify_item_added":{"type":"boolean","default":true,"description":"In-app: a referral was added in your circles."},"notify_comments":{"type":"boolean","default":true,"description":"In-app: comments on your referrals."},"notify_referral_used":{"type":"boolean","default":true,"description":"In-app: your referral was used."},"email_invites":{"type":"boolean","default":true,"description":"Email: circle invites."},"email_member_joined":{"type":"boolean","default":false,"description":"Email: someone joined your circle."},"email_item_added":{"type":"boolean","default":false,"description":"Email: a referral was added in your circles."},"email_comments":{"type":"boolean","default":false,"description":"Email: comments on your referrals."},"email_referral_used":{"type":"boolean","default":true,"description":"Email: your referral was used."},"email_request_fulfilled":{"type":"boolean","default":true,"description":"Email: a friend answered your circle ask."},"email_newsletter":{"type":"boolean","default":true,"description":"Email: product newsletter."},"email_weekly_digest":{"type":"boolean","default":true,"description":"Email: your circles' weekly digest."},"email_code_activity":{"type":"boolean","default":true,"description":"Email: weekly activity on your own codes (clicks, \"it worked\" reports, confirmed uses). At most once a week, and only when something happened."}},"additionalProperties":true},"NotificationPrefsInput":{"type":"object","description":"Partial preferences update; omitted fields keep their current value.","properties":{"notify_invites":{"type":"boolean"},"notify_member_joined":{"type":"boolean"},"notify_item_added":{"type":"boolean"},"notify_comments":{"type":"boolean"},"notify_referral_used":{"type":"boolean"},"email_invites":{"type":"boolean"},"email_member_joined":{"type":"boolean"},"email_item_added":{"type":"boolean"},"email_comments":{"type":"boolean"},"email_referral_used":{"type":"boolean"},"email_request_fulfilled":{"type":"boolean"},"email_newsletter":{"type":"boolean"},"email_weekly_digest":{"type":"boolean"},"email_code_activity":{"type":"boolean","description":"Weekly email about clicks and \"it worked\" reports on your own codes. Sent at most once a week, and only when something happened."}}},"ShareLink":{"type":"object","description":"A trackable share link.","properties":{"id":{"type":"integer","description":"Share link id."},"code":{"type":"string","description":"Unique 10-character share code."},"referral_id":{"type":"integer","description":"Id of the referral being shared."},"parent_id":{"type":["integer","null"],"description":"Id of the parent share link in a re-share chain, or null."},"clicks":{"type":"integer","description":"Number of clicks recorded on the link."},"created_at":{"type":"string","format":"date-time","description":"When the link was created."}},"required":["id","code","referral_id","parent_id","clicks","created_at"]},"ShareLinkWithStats":{"type":"object","description":"A share link joined with its referral and performance stats.","properties":{"id":{"type":"integer","description":"Share link id."},"code":{"type":"string","description":"Unique share code."},"clicks":{"type":"integer","description":"Number of clicks recorded on the link."},"created_at":{"type":"string","format":"date-time","description":"When the link was created."},"parent_id":{"type":["integer","null"],"description":"Parent share link id in a re-share chain, or null."},"referral_id":{"type":"integer","description":"Id of the shared referral."},"title":{"type":"string","description":"Referral title."},"company":{"type":["string","null"],"description":"Referral company."},"logo_url":{"type":["string","null"],"description":"Referral logo URL."},"referral_owner_username":{"type":["string","null"],"description":"Username of the referral's owner. NULL when visible is false."},"visible":{"type":"boolean","description":"False when the caller may no longer see the target referral (it was paused, made private, or its owner was banned). The link row and its own stats are still returned; every referral field above is NULL."},"referral_owner_display_name":{"type":["string","null"],"description":"Display name of the referral's owner."},"reshare_count":{"type":"string","description":"Number of child share links (bigint count, serialized as a string)."},"signups_driven":{"type":"string","description":"Number of signup events attributed to this link (bigint count, serialized as a string)."}},"required":["id","code","clicks","created_at","parent_id","referral_id","title","referral_owner_username","visible","reshare_count","signups_driven"]}}}}