Marketplace
Product catalogue, custom request forms, images, and the access-request funnel
Build and run the 1hstream marketplace — the cards members browse, the forms they fill in, and the requests an admin acts on.
Two halves, one funnel
The catalogue is what members see: one product per card, authored entirely through this API. Access requests are what comes back: one row per member per product, which an admin approves or denies. For a scanner product the approval is the entitlement — approving grants the indicator on TradingView.
Overview
The Marketplace API provides:
- Product catalogue CRUD (admin)
- A per-product custom request-form builder
- Presigned direct-to-S3 product image uploads
- Member access-request submission and status lookup
- Admin request review, which drives the indicator grant
Base Path: /v1/streams/marketplace
Service: stream-service
| Concern | Auth |
|---|---|
| Public catalogue and request submission | X-API-Key only |
| Everything marked (Admin) | X-API-Key + Authorization: Bearer with the admin role |
Admin reads live on their own paths, not on a flag
The public routes are mounted with optional auth, which only reads a Bearer JWT — it does not honour the gateway's internal service key. An admin flag on a public route would therefore fail for the dashboard even though its writes succeed. That is why the admin catalogue is GET /products/all and not GET /products?includeInactive=true. The flag form looks like it works: it returns 200 with the active products and silently drops every deactivated one.
Anatomy of a product
Every card is one document. Two fields decide what kind of card it is, and everything else is presentation.
| Field | Type | Default | Description |
|---|---|---|---|
| id | string | — | Assigned on create |
| name | string | — | Required. Card title |
| description | string | "" | Card body copy |
| imageUrl | string | "" | Public URL of the card image — see Product images |
| priceLabel | string | "" | Free text, e.g. "$97/mo" or "Included". Nothing computes on it |
| features | string[] | [] | Bullet list on the card |
| productType | scanner | misc | misc | Whether the product carries an entitlement |
| scannerId | string | null | null | Which indicator an approval grants. Only ever set on scanner products |
| ctaType | form | link | form | What the card's button does |
| ctaUrl | string | "" | Where a link product's button points. Always empty on a form product |
| ctaLabel | string | "" | Button wording. Empty falls back to the per-type default on the card |
| requestFields | object[] | [] | The product's own form questions — see Custom request forms |
| isActive | boolean | true | false hides the card from the public catalogue |
| sortOrder | number | 0 | Ascending. Ties fall back to insertion order |
| createdAt / updatedAt | string | — | ISO 8601 |
The two axes
productType says what an approval is worth. ctaType says how a member starts.
ctaType: "form" | ctaType: "link" | |
|---|---|---|
productType: "misc" | Takes requests, grants nothing automatically. An approval is a record an admin acts on off-platform | A plain hyperlink out. No form, no request, no entitlement |
productType: "scanner" | The working combination: an approval creates the access row and pushes the TradingView grant | Refused. See below |
A scanner product cannot use a link button
Its indicator is granted by approving a request, and a link takes none — so the card would look configured and grant nothing, forever. POST and PUT both reject the combination with 400.
Products that predate these fields
productType defaults to misc and ctaType reads back as form on rows created before either field existed, which is exactly what those products are. Read both defensively on the client — older rows may omit ctaType, ctaUrl and requestFields entirely.
List Products
The public catalogue. Active products only, always — there is no parameter that widens it.
GET /v1/streams/marketplace/products| Parameter | Type | Description |
|---|---|---|
| productType | string | Filter to scanner or misc |
{
"success": true,
"status": 200,
"message": "Products retrieved",
"data": [
{
"id": "68a1f0c2d3b45e0011bc3301",
"name": "Momentum Scanner",
"description": "Intraday momentum alerts on the 1House watchlist.",
"imageUrl": "https://1house-public.s3.us-east-1.amazonaws.com/marketplace/products/1757520000000-a1b2c3.png",
"priceLabel": "Included with membership",
"features": ["Real-time alerts", "Pre-market scan", "TradingView overlay"],
"productType": "scanner",
"scannerId": "68a1e9f4c1a7b20011aa2210",
"ctaType": "form",
"ctaUrl": "",
"ctaLabel": "Request access",
"requestFields": [
{
"key": "how_long_have_you_been_trading",
"label": "How long have you been trading?",
"type": "select",
"placeholder": "",
"helpText": "",
"required": true,
"options": ["Under a year", "1-3 years", "3+ years"]
}
],
"isActive": true,
"sortOrder": 10,
"createdAt": "2026-08-14T09:12:44.201Z",
"updatedAt": "2026-09-02T16:40:10.883Z"
}
]
}Try it out:
List All Products (Admin)
Every product, active or not. A dashboard that cannot see a deactivated product cannot reactivate one, which is why this exists as its own route.
GET /v1/streams/marketplace/products/allSame productType filter, same response shape, same sortOrder ordering.
Get Product
GET /v1/streams/marketplace/products/:idReturns the product whether or not it is active. 404 NOT_FOUND for an unknown id.
Create a Product (Admin)
POST /v1/streams/marketplace/productsX-API-Key: your-api-key
Authorization: Bearer your-jwt-token
Content-Type: application/json{
"name": "Momentum Scanner",
"description": "Intraday momentum alerts on the 1House watchlist.",
"imageUrl": "https://1house-public.s3.us-east-1.amazonaws.com/marketplace/products/1757520000000-a1b2c3.png",
"priceLabel": "Included with membership",
"features": ["Real-time alerts", "Pre-market scan"],
"productType": "scanner",
"scannerId": "68a1e9f4c1a7b20011aa2210",
"ctaType": "form",
"ctaLabel": "Request access",
"requestFields": [
{
"label": "How long have you been trading?",
"type": "select",
"required": true,
"options": ["Under a year", "1-3 years", "3+ years"]
}
],
"isActive": true,
"sortOrder": 10
}scannerId must name an indicator that exists — get one from GET /v1/streams/scanners. An unknown id is 404 NOT_FOUND, a missing one is 400.
{
"name": "1House Discord",
"description": "Join the members-only server.",
"productType": "misc",
"ctaType": "link",
"ctaUrl": "https://discord.gg/example",
"ctaLabel": "Open Discord",
"sortOrder": 40
}ctaUrl is required and must parse as an http:// or https:// URL. Any requestFields sent alongside are stored as an empty list — a link card has no form to ask them on.
{
"name": "1-on-1 Strategy Session",
"description": "Sixty minutes with a 1House educator.",
"priceLabel": "$250",
"productType": "misc",
"ctaType": "form",
"requestFields": [
{ "label": "What do you want to cover?", "type": "textarea", "required": true },
{ "label": "Preferred date", "type": "date", "required": false }
],
"sortOrder": 20
}A misc product takes requests but carries no entitlement — approving one is a record for an admin to act on, not a grant. Sending a scannerId on a non-scanner product is 400.
201 on success, with the created product as data.
Try it out:
Update a Product (Admin)
PUT /v1/streams/marketplace/products/:idA partial update: send only what changes. The same validation the create path runs applies here, resolved against the stored product — so omitting productType on an existing scanner product still requires it to have a scannerId.
{
"priceLabel": "$149/mo",
"sortOrder": 5
}requestFields: omitted and empty mean different things
Omit requestFields and the product's form is left exactly as it was — a rename or a sort-order nudge must not wipe a form. Send [] and the form is cleared. Send an array and it replaces the form wholesale; there is no per-field patch.
Switching a product's type
| Change | What happens |
|---|---|
form → link | requestFields is emptied and ctaUrl becomes required. The questions are gone, not parked |
link → form | ctaUrl is cleared to "" so a stale destination cannot resurface on a later switch. The form starts empty |
misc → scanner | Requires a scannerId — in the body or already on the product — and refuses ctaType: "link" |
scanner → misc | Send "scannerId": null in the same request. A misc product must not carry a dangling reference |
Editing a form does not rewrite history
Each submitted answer snapshots the question's label at the time it was asked. Renaming a field later changes what new members are asked; it does not change what an existing request shows a reviewer.
Try it out:
Delete a Product (Admin)
DELETE /v1/streams/marketplace/products/:idReturns 200 with data: null. 404 NOT_FOUND if it was already gone.
Prefer deactivating
Deleting removes the card outright. Access requests that pointed at it stay in the queue with a productId that no longer resolves, and a reviewer loses the context for a decision they still have to make. Setting isActive: false takes the card off the marketplace and keeps every request readable.
An indicator that a marketplace product still points at cannot itself be deleted — see Delete Indicator.
Product images
Images go straight to S3 on a presigned PUT. The file never crosses the gateway, so it is not subject to the gateway's body limit and never lands in a request log.
1. Ask for an upload URL (Admin)
POST /v1/streams/marketplace/products/uploads/presign{ "contentType": "image/png" }| Parameter | Type | Required | Description |
|---|---|---|---|
| contentType | string | Yes | One of image/jpeg, image/png, image/webp, image/gif |
{
"success": true,
"status": 200,
"message": "Upload URL created",
"data": {
"uploadUrl": "https://1house-public.s3.us-east-1.amazonaws.com/marketplace/products/1757520000000-a1b2c3.png?X-Amz-Algorithm=...",
"publicUrl": "https://1house-public.s3.us-east-1.amazonaws.com/marketplace/products/1757520000000-a1b2c3.png",
"key": "marketplace/products/1757520000000-a1b2c3.png",
"expiresIn": 900
}
}2. PUT the file to uploadUrl
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/png" \
--data-binary @product-card.pngThree ways this PUT fails
- Send no auth headers. The signature is the authorisation; an
X-API-KeyorAuthorizationheader on this request is not expected by S3. - Use the same
Content-Typeyou asked for. A different one does not match what was signed and S3 rejects it. - The URL expires in 15 minutes (
expiresIn, in seconds). Ask for a fresh one rather than retrying an expired signature.
A failure at this step is usually the bucket's CORS policy not allowing PUT from the calling origin.
3. Save publicUrl on the product
Nothing is written to the product by the presign call. The image only becomes the card's once you send it:
{ "imageUrl": "https://1house-public.s3.us-east-1.amazonaws.com/marketplace/products/1757520000000-a1b2c3.png" }Every upload gets a fresh key
The bucket is written with a one-year Cache-Control, so reusing a key per product would leave the old picture on the card long after it was replaced. Replacing an image always produces a new URL — update imageUrl to match.
Custom request forms
Every request form collects full name, email and TradingView handle. Those three are built in and are not expressed as fields. requestFields is the product's own questions, asked underneath them, in array order.
{
"requestFields": [
{
"label": "How long have you been trading?",
"type": "select",
"required": true,
"options": ["Under a year", "1-3 years", "3+ years"],
"helpText": "Used to pick a starting preset for you."
},
{
"label": "Broker",
"type": "text",
"required": false,
"placeholder": "e.g. Interactive Brokers"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| label | string | Yes | The question the member reads |
| key | string | No | Storage key. Derived from label when omitted — see below |
| type | string | No | Defaults to text |
| placeholder | string | No | Input placeholder |
| helpText | string | No | Hint shown under the input |
| required | boolean | No | Defaults to false |
| options | string[] | For select | Ignored, and stored empty, for every other type |
Field types
| Type | Renders as | Validated on submit |
|---|---|---|
text | Single-line input | — |
textarea | Multi-line input | — |
email | Email input | Must look like an email address |
phone | Phone input | — |
number | Number input | Must parse as a number |
url | URL input | — |
date | Date input | — |
select | Dropdown | Must be one of options |
checkbox | Checkbox | Stored as the string "true" or "false" |
Keys are derived, not authored
A key is what an answer is filed under, so it must not depend on the wording staying identical. Send a label and the server derives the key: lower-cased, non-alphanumerics collapsed to _, leading and trailing underscores trimmed, capped at 60 characters. "How long have you been trading?" becomes how_long_have_you_been_trading. A key you supply is normalised the same way, never stored raw — so read the key back off the created product rather than assuming it survived verbatim.
Form rules, all enforced at 400
- At most 25 custom fields per product.
- Every field needs a non-empty
label. - A key must contain at least one letter or digit after normalising.
- Two fields must not normalise to the same key — rename one.
- A
selectneeds at least one option. - These keys are already collected on every request and are refused:
productid,fullname,full_name,email,tradingviewid,tradingview_id,status,responses.
A malformed form is refused when it is built rather than repaired — otherwise the failure surfaces as a rejected submission from a member who did nothing wrong.
Submit an Access Request
What the 1hstream marketplace form posts. No admin auth — members are not signed in to the API here.
POST /v1/streams/marketplace/access-requests{
"productId": "68a1f0c2d3b45e0011bc3301",
"fullName": "Jane Ruiz",
"email": "jane@example.com",
"tradingviewId": "trader_jane",
"responses": {
"how_long_have_you_been_trading": "3+ years",
"broker": "Interactive Brokers"
}
}| Parameter | Type | Required | Description |
|---|---|---|---|
| productId | string | Yes | The product being requested |
| fullName | string | Yes | Trimmed before storing |
| string | Yes | Lower-cased before storing; must look like an email address | |
| tradingviewId | string | For scanner products | The member's TradingView handle |
| responses | object | If the form has required fields | { key: value } against the product's requestFields |
A scanner request without a handle is refused
tradingviewId is what an approval is eventually delivered with, so a scanner request is rejected with 400 without one. Accepting it would move the failure to the moment an admin clicks Approve, where it reads as the approval breaking rather than the form being incomplete. misc products carry no entitlement and leave the field optional.
How responses is read
Driven by the product's form, not by what was sent:
- An answer to a question the form does not ask is dropped, not stored.
- A required question left blank is 400, naming the label.
- Answers are stored in the order the form asks them, each with the question's
labelcopied in. - An unticked required checkbox counts as unanswered; an unticked optional one is stored as
"false".
Re-submitting updates, it does not duplicate
A second request for the same product and email updates the existing one — that is how somebody corrects a mistyped TradingView handle. The new answers replace the old wholesale rather than merging, so a field the member deliberately cleared does not keep its old value.
| Existing status | What a re-submission does |
|---|---|
pending | Updates in place. Message: Access request updated |
denied | Updates and reopens to pending, clearing reviewedAt. Message: Access request resubmitted |
approved | Returned untouched. Message: Access request already approved |
An approved request has been acted on; rewriting it would desync the grant from the record that produced it.
Link products take no requests
POST for a product whose ctaType is link returns 400 — 1hstream renders a hyperlink for those and never reaches here. A request that arrived anyway would sit in the queue as something an admin can approve to no effect.
Try it out:
Legacy fields
Requests taken before the form settled may carry a phone or message. Both are still returned so a reviewer can read them, but neither is collected or accepted any more.
Check Request Status
What the marketplace uses to decide whether a card shows "Request access" or "Approved".
GET /v1/streams/marketplace/access-requests/status?email=jane@example.com| Parameter | Type | Required | Description |
|---|---|---|---|
| string | Yes | Case-insensitive. 400 without it |
{
"data": {
"68a1f0c2d3b45e0011bc3301": "approved",
"68a1f0c2d3b45e0011bc3307": "pending"
}
}A { productId: status } map. When a member has more than one request for the same product, approved wins over anything else.
Review Requests (Admin)
List
GET /v1/streams/marketplace/access-requests| Parameter | Type | Default | Description |
|---|---|---|---|
| status | string | — | pending, approved or denied |
| page | number | 1 | |
| limit | number | 50 |
Newest first. productId comes back populated with name, productType and scannerId, so a reviewer sees what they are deciding on without a second call.
{
"data": {
"requests": [
{
"id": "68b3c1a2e4f5a60011cd4402",
"productId": {
"id": "68a1f0c2d3b45e0011bc3301",
"name": "Momentum Scanner",
"productType": "scanner",
"scannerId": "68a1e9f4c1a7b20011aa2210"
},
"fullName": "Jane Ruiz",
"email": "jane@example.com",
"tradingviewId": "trader_jane",
"responses": [
{ "key": "how_long_have_you_been_trading", "label": "How long have you been trading?", "value": "3+ years" }
],
"status": "pending",
"reviewedAt": null,
"createdAt": "2026-09-05T11:03:19.442Z"
}
],
"total": 27,
"page": 1
}
}Approve or deny
PATCH /v1/streams/marketplace/access-requests/:id{ "status": "approved" }| Parameter | Type | Required | Description |
|---|---|---|---|
| status | string | Yes | approved, denied or pending |
reviewedAt is stamped for approved and denied, and cleared back to null for pending.
For a scanner product, the decision is the entitlement
- Approving creates — or revives — the access row and queues the TradingView grant, with
source: "marketplace"and the request id recorded on it. - Withdrawing an approval (to
deniedorpending) queues the revoke, and only for the row this request created. A separate manual grant to the same person survives. - Nothing happens when the status is unchanged, and nothing happens at all for a
miscproduct.
See Per-Indicator Access for what the grant does next.
A decision is never lost to a third party
If the TradingView call cannot be made, the decision is still saved and the response carries an entitlementWarning alongside the request. The grant lands in failed and appears on the failures worklist. Surface the warning rather than treating the call as a plain success — the HTTP status is still 200.
An approval for a request with no tradingviewId on record produces the same warning and no grant: "Approved, but this request has no TradingView username — grant it manually from the Scanners page once you have one."
{
"success": true,
"status": 200,
"message": "Decision saved, but the scanner grant did not go through: TradingView timed out",
"data": {
"id": "68b3c1a2e4f5a60011cd4402",
"status": "approved",
"reviewedAt": "2026-09-11T14:22:08.115Z",
"entitlementWarning": "Decision saved, but the scanner grant did not go through: TradingView timed out"
}
}Try it out:
End-to-end: publish a scanner product
curl "$BASE/v1/streams/scanners" \
-H "X-API-Key: $API_KEY"Take the id of the indicator this card should grant. Creating the product with an id that does not exist is 404.
PRESIGN=$(curl -s -X POST "$BASE/v1/streams/marketplace/products/uploads/presign" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"contentType": "image/png"}')
curl -X PUT "$(echo "$PRESIGN" | jq -r .data.uploadUrl)" \
-H "Content-Type: image/png" \
--data-binary @card.pngKeep data.publicUrl — that is what goes on the product.
curl -X POST "$BASE/v1/streams/marketplace/products" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "Momentum Scanner",
"imageUrl": "'"$PUBLIC_URL"'",
"productType": "scanner",
"scannerId": "68a1e9f4c1a7b20011aa2210",
"ctaType": "form",
"ctaLabel": "Request access",
"requestFields": [
{ "label": "Broker", "type": "text", "required": false }
],
"isActive": true,
"sortOrder": 10
}'Set "isActive": false to stage the card and flip it on with a PUT when you are ready.
curl "$BASE/v1/streams/marketplace/access-requests?status=pending" \
-H "X-API-Key: $API_KEY" -H "Authorization: Bearer $JWT"
curl -X PATCH "$BASE/v1/streams/marketplace/access-requests/$REQUEST_ID" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"status": "approved"}'Check the response for entitlementWarning before reporting success.
Errors
Every failure uses the standard envelope:
{
"success": false,
"status": 400,
"message": "A scanner product must reference a scannerId",
"errors": { "code": "BAD_REQUEST" },
"meta": { "path": "/api/marketplace/products", "method": "POST", "timestamp": "2026-09-11T14:22:08.115Z", "version": "v1" }
}| Status | Code | Raised when |
|---|---|---|
| 400 | BAD_REQUEST | A scanner product must reference a scannerId |
| 400 | BAD_REQUEST | Only products with productType "scanner" may reference a scannerId |
| 400 | BAD_REQUEST | A scanner product was given ctaType: "link" |
| 400 | BAD_REQUEST | ctaType is neither form nor link |
| 400 | BAD_REQUEST | A link product has no ctaUrl, or one that is not a valid http(s) URL |
| 400 | BAD_REQUEST | A request form breaks one of the form rules |
| 400 | BAD_REQUEST | contentType is required / not one of the four allowed image types |
| 400 | BAD_REQUEST | productId, fullName, and email are required, or the email is malformed |
| 400 | BAD_REQUEST | tradingviewId is required to request access to a scanner product |
| 400 | BAD_REQUEST | This product links out and does not take access requests |
| 400 | BAD_REQUEST | A required custom field was left blank, or a select answer is not an option |
| 400 | BAD_REQUEST | email query parameter is required on the status lookup |
| 400 | BAD_REQUEST | status must be one of: pending, approved, denied |
| 401 | UNAUTHORIZED | Missing or invalid JWT on an admin route |
| 403 | FORBIDDEN | Authenticated, but not admin |
| 404 | NOT_FOUND | Unknown product, access request, or scannerId |
| 502 | AWS_SERVICE_ERROR | S3 could not issue the presigned URL |
Auth failures use a different shape
401 and 403 are raised by the role middleware before the handler runs, and come back as { "error": { "code": "FORBIDDEN", "message": "Insufficient permissions" } } — no success, no meta. Parse defensively rather than assuming the envelope above.
Related Documentation
- Indicators — the scanner catalogue, access lifecycle, and what an approval triggers
- Live Streams — schedules and streaming sessions from the same service
- Authentication — obtaining the API key and JWT these endpoints require
- Errors — the platform-wide error envelope and codes