1House Global API Documentation

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

ConcernAuth
Public catalogue and request submissionX-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.

FieldTypeDefaultDescription
idstring—Assigned on create
namestring—Required. Card title
descriptionstring""Card body copy
imageUrlstring""Public URL of the card image — see Product images
priceLabelstring""Free text, e.g. "$97/mo" or "Included". Nothing computes on it
featuresstring[][]Bullet list on the card
productTypescanner | miscmiscWhether the product carries an entitlement
scannerIdstring | nullnullWhich indicator an approval grants. Only ever set on scanner products
ctaTypeform | linkformWhat the card's button does
ctaUrlstring""Where a link product's button points. Always empty on a form product
ctaLabelstring""Button wording. Empty falls back to the per-type default on the card
requestFieldsobject[][]The product's own form questions — see Custom request forms
isActivebooleantruefalse hides the card from the public catalogue
sortOrdernumber0Ascending. Ties fall back to insertion order
createdAt / updatedAtstring—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-platformA plain hyperlink out. No form, no request, no entitlement
productType: "scanner"The working combination: an approval creates the access row and pushes the TradingView grantRefused. 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
ParameterTypeDescription
productTypestringFilter 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/all

Same productType filter, same response shape, same sortOrder ordering.

Get Product

GET /v1/streams/marketplace/products/:id

Returns the product whether or not it is active. 404 NOT_FOUND for an unknown id.

Create a Product (Admin)

POST /v1/streams/marketplace/products
X-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": "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/:id

A 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

ChangeWhat happens
form → linkrequestFields is emptied and ctaUrl becomes required. The questions are gone, not parked
link → formctaUrl is cleared to "" so a stale destination cannot resurface on a later switch. The form starts empty
misc → scannerRequires a scannerId — in the body or already on the product — and refuses ctaType: "link"
scanner → miscSend "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/:id

Returns 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" }
ParameterTypeRequiredDescription
contentTypestringYesOne 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.png

Three ways this PUT fails

  • Send no auth headers. The signature is the authorisation; an X-API-Key or Authorization header on this request is not expected by S3.
  • Use the same Content-Type you 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"
    }
  ]
}
FieldTypeRequiredDescription
labelstringYesThe question the member reads
keystringNoStorage key. Derived from label when omitted — see below
typestringNoDefaults to text
placeholderstringNoInput placeholder
helpTextstringNoHint shown under the input
requiredbooleanNoDefaults to false
optionsstring[]For selectIgnored, and stored empty, for every other type

Field types

TypeRenders asValidated on submit
textSingle-line input—
textareaMulti-line input—
emailEmail inputMust look like an email address
phonePhone input—
numberNumber inputMust parse as a number
urlURL input—
dateDate input—
selectDropdownMust be one of options
checkboxCheckboxStored 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 select needs 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"
  }
}
ParameterTypeRequiredDescription
productIdstringYesThe product being requested
fullNamestringYesTrimmed before storing
emailstringYesLower-cased before storing; must look like an email address
tradingviewIdstringFor scanner productsThe member's TradingView handle
responsesobjectIf 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 label copied 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 statusWhat a re-submission does
pendingUpdates in place. Message: Access request updated
deniedUpdates and reopens to pending, clearing reviewedAt. Message: Access request resubmitted
approvedReturned 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
ParameterTypeRequiredDescription
emailstringYesCase-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
ParameterTypeDefaultDescription
statusstring—pending, approved or denied
pagenumber1
limitnumber50

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" }
ParameterTypeRequiredDescription
statusstringYesapproved, 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 denied or pending) 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 misc product.

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.png

Keep 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" }
}
StatusCodeRaised when
400BAD_REQUESTA scanner product must reference a scannerId
400BAD_REQUESTOnly products with productType "scanner" may reference a scannerId
400BAD_REQUESTA scanner product was given ctaType: "link"
400BAD_REQUESTctaType is neither form nor link
400BAD_REQUESTA link product has no ctaUrl, or one that is not a valid http(s) URL
400BAD_REQUESTA request form breaks one of the form rules
400BAD_REQUESTcontentType is required / not one of the four allowed image types
400BAD_REQUESTproductId, fullName, and email are required, or the email is malformed
400BAD_REQUESTtradingviewId is required to request access to a scanner product
400BAD_REQUESTThis product links out and does not take access requests
400BAD_REQUESTA required custom field was left blank, or a select answer is not an option
400BAD_REQUESTemail query parameter is required on the status lookup
400BAD_REQUESTstatus must be one of: pending, approved, denied
401UNAUTHORIZEDMissing or invalid JWT on an admin route
403FORBIDDENAuthenticated, but not admin
404NOT_FOUNDUnknown product, access request, or scannerId
502AWS_SERVICE_ERRORS3 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.

  • 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