Authentication.
Trooply has three login paths — a machine grant for backends, a user login for humans (with multi-store membership and optional 2FA), and Origin-bound public keys for browser widgets. This guide covers all three and how they fit together.
The three login paths
Trooply credentials come in three shapes. Picking the right one depends on who is talking — a backend process, a logged-in human, or a shopper's browser:
| Path | Credential | Endpoint | Use it when… |
|---|---|---|---|
| Machine grant | client_id + client_secret | POST /oauth/token | Your backend talks to Trooply (catalog sync, indexing, jobs). |
| User login | Email + password (+ optional TOTP) | POST /oauth/login | A human logs into the merchant portal — including agency developers who belong to many stores. |
| Public widget key | pk_live_… + matching Origin | POST /v1/widget/search/* | A shopper's browser searches your storefront. No JWT, no round-trip. |
The one absolute rule: never put a client_secret or a user password in code that reaches a shopper's browser. Use a pk_live_ for browser calls. A classic mistake is letting a Next.js page import a non-NEXT_PUBLIC_ env var — works fine until someone moves the fetch to client-side code and the secret leaks.
Server-side: the machine grant
Exchange your client_id + client_secret for a bearer token. The endpoint follows OAuth 2.0 — request is application/x-www-form-urlencoded, not JSON:
curl -X POST https://search.trooply.ai/oauth/token \
-d "grant_type=client_credentials" \
-d "client_id=client_abc123" \
-d "client_secret=sk_live_..."
Response:
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600
}
Two things matter for your code:
access_tokenis a signed JWT. Put it inAuthorization: Bearer <token>on every subsequent call.expires_inis seconds-until-expiry (1 hour by default). Refresh before you hit zero; don't wait for a 401.
Refresh flow
To swap an expiring token for a fresh one, send the old bearer in the Authorization header — there's no separate refresh token to track:
curl -X POST https://search.trooply.ai/oauth/refresh \
-H "Authorization: Bearer <old_token>"
Returns a new access_token with the same shape as the original. The old token is revoked on success, so don't fall back to it after a refresh. There's a 5-minute grace window: a token that just expired can still be refreshed, so a sleepy mobile tab waking up still recovers without a full re-login. Tokens older than that grace window are rejected — fall back to /oauth/token for a brand new grant.
When to refresh (machine)
Don't refresh every request — you'll spend real latency on it. Two patterns that work:
Pattern A: proactive timer (recommended)
On process boot, fetch a token and schedule a refresh at expires_in - 60 seconds. Store the latest token in a shared variable. Every request reads from that variable. Low latency, low complexity, ~one refresh per hour.
Pattern B: lazy 401 recovery
Fetch a token on first use. On any request that returns 401 with error=token_expired, refresh once and retry exactly once. Use single-flight (one refresh in progress at a time) or you'll get a thundering herd when many in-flight requests 401 simultaneously.
Human-side: user login
Humans use a different endpoint — email and password, not client_secret. The response includes everything the merchant portal needs to render a multi-store picker:
curl -X POST https://search.trooply.ai/oauth/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "..."}'
Response (single-membership case):
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"user_type": "user",
"user_id": "f97db327-...",
"user_name": "Alice",
"is_super_admin": false,
"memberships": [
{
"client_id": "fc5b6639-...",
"slug": "acme-store",
"name": "Acme Store",
"role": "owner",
"plan": "premium",
"last_used_at": null
}
],
"redirect": "/acme-store/portal"
}
The frontend uses redirect to decide where to send the user:
/{slug}/portal— exactly one membership, deep-link them straight in./portal/select-store— multiple memberships, show a profile picker. The same JWT works against any of them./admin—is_super_adminwas true; route them to the platform-admin shell instead of any individual store.
Multi-store: the X-Trooply-Active-Tenant header
One human can be a member of many stores (an agency developer at five clients, for example). The JWT carries the full memberships[] snapshot, so the backend doesn't have to round-trip the DB on every request. To act as a specific store, every /v1/* call carries an X-Trooply-Active-Tenant header naming the store's slug:
curl https://search.trooply.ai/v1/products?limit=10 \
-H "Authorization: Bearer <jwt>" \
-H "X-Trooply-Active-Tenant: acme-store"
The portal frontend sets this header automatically based on the URL — when the user is on /acme-store/portal/..., the header is acme-store. Switching stores is a normal navigation; no token reissue. There are three header behaviours worth knowing:
- If the header is missing and the user has exactly one membership, the backend falls through to that one — keeps single-store integrations zero-config.
- If the header is missing and the user has two or more memberships, the call returns 401 with a "pass
X-Trooply-Active-Tenant" message — ambiguous which store you mean. - If the header names a slug the user has no membership for, the call returns 403. (Super-admins are exempt — they can act as any store.)
Two-factor authentication
2FA is per-user, not per-store. Once Alice enables 2FA, every login regardless of which store she ends up acting as requires the code. Set it up at /portal/settings or via the API:
POST /v1/security/2fa/setup # returns secret + QR code
POST /v1/security/2fa/enable # body: {"totp_code": "123456"}
POST /v1/security/2fa/disable
GET /v1/security/2fa/status
When 2FA is enabled, /oauth/login takes two round-trips. First call (no code):
POST /oauth/login
{"email": "[email protected]", "password": "..."}
→ 401 Unauthorized
{
"error": "requires_2fa",
"message": "Two-factor authentication code required.",
"details": {"requires_2fa": true}
}
The frontend prompts for the code and resends with totp_code populated:
POST /oauth/login
{"email": "[email protected]", "password": "...", "totp_code": "123456"}
→ 200 OK with the normal access_token response.
A wrong code returns error: invalid_2fa — re-prompt with a fresh code (authenticator codes refresh every 30 seconds). Wrong codes count toward the per-identity login throttle, so don't retry indefinitely.
Checking TOTP only after password verification means a wrong password returns a generic 401 — never requires_2fa or invalid_2fa. That prevents user-enumeration via response shape ("does this email exist + have 2FA?"). The cost is one extra network round-trip on the 2FA path, paid only by users who've actually opted in.
Login throttle: brute-force protection
The login endpoints have two layers of throttling:
- Per-IP — 10 login requests / minute, 30 refresh / minute. Returns standard
rate_limit_exceeded. - Per-identity — 5 failed attempts per email (or 10 per
client_id) within a 15-minute window. Returnstoo_many_requestswithdetails.retry_afterin seconds. Resets on a successful auth.
The per-identity layer is the one that matters under attack — distributed credential-stuffing from many IPs would defeat the per-IP layer alone, but the per-identity bucket holds regardless of source. So don't write retry loops that just keep firing; honour retry_after or your account is locked out for the rest of the window.
Inviting team members
Adding someone to your store is an email-based invite — you don't issue them a password directly. The owner (or super-admin) calls:
POST /v1/team/invite
Authorization: Bearer <owner_jwt>
X-Trooply-Active-Tenant: acme-store
Content-Type: application/json
{"email": "[email protected]", "role": "developer"}
Roles are owner, admin, developer, or viewer — independent per store, so Bob being developer at Acme has no implication for any other store he might also belong to. The response tells you whether the email was already known to Trooply:
{
"invite_id": "21a6...",
"email": "[email protected]",
"role": "developer",
"is_existing_user": true,
"expires_at": "2026-05-13T12:00:00Z",
"email_sent": true
}
Bob receives a magic-link email. Two paths:
- New user (
is_existing_user: false) — the link takes Bob to a set-password page. Accepting creates his globalusersrow and theuser_membershipslink in one transaction. - Existing user (
is_existing_user: true) — Bob already has a Trooply account. The link tells him to sign in with his existing password; the membership for Acme is added on accept.
Tokens are SHA-256 hashed at rest, single-use, 7-day expiry. Cancel pending invites with DELETE /v1/team/invites/{invite_id}. There's no native "resend" — cancel and re-invite to mint a fresh token.
What the JWT contains
The JWT payload (decoded) carries:
sub— the user's UUID for user logins, the store's UUID for machine grants.user_type—"user"(human login) or"client"(machine grant).user_id— set on user logins; the globalusers.id.memberships— array of{client_id, slug, role}, one per store the user can act as. Snapshot at issue time.is_super_admin— platform-admin scope; allows acting as any store regardless of memberships.plan,rate_limit— the active store's plan tier and per-minute rate limit (legacy compat).exp,iat,jti— expiry, issued-at, unique ID for revocation.
Two things the JWT doesn't carry: which store is currently active (that comes from the URL or the X-Trooply-Active-Tenant header — switching is a normal navigation, no reissue) and shopper identity (Trooply doesn't model end-users — correlate via your own session).
Storage: where do credentials go?
For machine credentials (client_secret), in order of preference:
- A secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager, Doppler). Loaded into env at boot, never written to disk.
- Deployment-platform env vars (Vercel, Fly, Render). Fine for small teams; make sure you know who can see them.
- A
.envfile on the server — gitignored, root-readable. Acceptable for a one-server stack; harder to rotate. - Source control — never. Not even a private repo.
For browser-side use a public key (pk_live_…); see the next section.
Browser-side: the public-key path
Mint a public key at /portal/widget with an allow-list of the exact origins it'll be served from. The storefront calls one of the widget endpoints — /v1/widget/search/text, /v1/widget/search/upload, or /v1/widget/search/url:
POST /v1/widget/search/text
Host: search.trooply.ai
X-Trooply-Key: pk_live_54f9...
Origin: https://shop.example.com
Content-Type: application/json
{"query": "red tote", "limit": 10}
No JWT, no token refresh, no server round-trip. The middleware validates the key, checks the browser-set Origin against the key's allow-list, and rate-limits per-key. A leaked key pasted on a scraper's domain fails on the first call — the Origin doesn't match.
See the deeper guide: Drop-in widget without leaking secrets.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
401 unauthorized | Wrong password, wrong client_secret, expired token, or revoked JWT. | Refresh if expired. Re-issue the secret if rotated. Don't try to distinguish wrong-email-vs-wrong-password — the response is intentionally identical to prevent enumeration. |
401 requires_2fa | Email + password were correct but the user has 2FA enabled. | Prompt for the 6-digit authenticator code, retry the same login with totp_code populated. |
401 invalid_2fa | The TOTP code didn't verify. | Re-prompt with a fresh code — authenticator codes refresh every 30 seconds. |
401 "Cannot resolve active store..." | User has 2+ memberships and no X-Trooply-Active-Tenant header on a /v1/* call. | Add the header naming the active store's slug. |
403 on /v1/* | The active-tenant header names a slug the user has no membership for. | Use a slug from the JWT's memberships[]. Super-admins are exempt. |
| 403 on widget calls | Public key missing from Origin allow-list, or Origin header missing. | Add the origin to the key. Widgets served from file:// or about:blank have no Origin and will be rejected — serve from a real domain. |
410 on /v1/users/* | Pre-§0.6 endpoint that's been replaced. | Use /v1/team/* for member management. The 410 response includes a replacement field pointing at the new path. |
429 rate_limit_exceeded | Plan rate budget exhausted or per-key throttle hit. | Back off per Retry-After. See Errors & rate limits. |
429 too_many_requests | Per-identity login throttle: 5 failed login attempts (or 10 wrong client_secrets) in 15 minutes. | Honour details.retry_after. Don't retry without backing off — a botnet that just keeps trying gets nowhere either. |
Conversion attribution: search_id and trackPurchase
Every successful search response includes a search_id field — the UUID of the row Trooply just persisted to search_history. Capture it on the storefront and pass it back as search_id on the corresponding POST /v1/search/feedback (or /v1/widget/search/feedback) call when the shopper clicks, adds-to-cart, or buys. That single link is what lets the /portal/analytics → Conversion by search mode panel attribute revenue to the retrieval surface that surfaced the product (text vs image vs upload vs widget variants). Feedback rows without a search_id still count toward total revenue but collapse into an Unattributed bucket on the dashboard.
// 1. Run the search and stash search_id from the response.
const r = await fetch('/v1/widget/search/text', {
method: 'POST',
headers: { 'X-Trooply-Key': cfg.publicKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'red sneakers' }),
}).then(r => r.json());
const searchId = r.search_id; // ← the linkage
renderResults(r.results);
// 2. When the shopper clicks / adds-to-cart / purchases, send feedback.
await fetch('/v1/widget/search/feedback', {
method: 'POST',
headers: { 'X-Trooply-Key': cfg.publicKey, 'Content-Type': 'application/json' },
body: JSON.stringify({
search_id: searchId,
result_product_id: 'sku-123',
action: 'purchase',
order_value: 89.99,
currency: 'USD',
quantity: 1,
}),
});
If you embed the drop-in widget, this is wired for you. The widget tracks clicks and add-to-cart events automatically. For purchases — which usually fire from a server-rendered checkout-success page — call the public global from your storefront JS:
// On the order-confirmation page, once per purchased SKU.
window.TrooplyWidget.trackPurchase('sku-123', {
order_value: 89.99,
currency: 'USD',
quantity: 1,
});
The widget reads its session_id from localStorage (key trooply-sid), so even if the search page and the checkout-success page are different routes, the events get correlated as long as the shopper stays in the same browser session. order_value is the line total in the chosen ISO-4217 currency; quantity defaults to 1 when omitted.
The schema enforces one rule: order_value + currency are only valid on action="purchase". Sending them on a click event returns 422.
Next
With auth sorted, the next job is getting your catalog into Trooply — Indexing your catalog.