HubStream API — Developer Reference
The HubStream API lets you submit RevOpsQL queries against your HubSpot portal, track execution progress, and retrieve results — all programmatically.
OpenAPI 3.0 spec — Download YAML · View interactive reference (Redoc)
Table of contents
Authentication
Every request requires an API key issued from the HubStream dashboard (Portals → API Keys → New API Key).
Authorization: Bearer hs_your_api_key_here
The key is tied to a specific portal. All requests are automatically scoped to that portal — there is no need to pass a portal ID.
Plan requirement — API access is available on Growth, Scale, and Enterprise plans only. Starter plan accounts receive a
403 plan_restrictionon every API call. The API Keys menu is also hidden in the dashboard for Starter accounts.
| Condition | HTTP | Error |
|---|---|---|
| Header absent | 401 | Authorization: Bearer <key> required |
| Key invalid / revoked / expired | 401 | Invalid or expired API key |
| Portal inactive | 403 | Portal is inactive |
| Account inactive | 403 | Account is inactive |
| Starter plan | 403 | plan_restriction + lien upgrade |
Rate limits
Limits are enforced per portal (shared across all keys of the same portal).
| Limit | Default |
|---|---|
| Full-run submissions / hour | 20 |
| Dry-run submissions / hour | 60 |
| Concurrent executions | 3 |
Successful submission responses (202) include rate-limit headers:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Window maximum |
X-RateLimit-Remaining |
Submissions left in this window |
X-RateLimit-Reset |
Unix timestamp of window reset |
429 — Burst limit reached:
{
"error": "rate_limit_exceeded",
"message": "Submission rate limit reached for this portal. Try again later.",
"retry_after": 1847
}
429 — Too many concurrent executions:
{
"error": "concurrent_limit_exceeded",
"message": "Too many concurrent executions for this portal. Wait for a running query to complete.",
"active_count": 3,
"limit": 3
}
Endpoints
POST /api/queries — Submit a query
POST https://app.hubstream.io/api/queries
Authorization: Bearer hs_...
Content-Type: application/json
{
"sql": "SELECT firstname, lastname, email FROM contact WHERE jobtitle = 'CTO' LIMIT 100 RECENTLY_CREATED",
"dry_run": false
}
| Field | Type | Required | Description |
|---|---|---|---|
sql |
string | ✅ | RevOpsQL query. Max 10 000 characters. |
dry_run |
boolean | — | Estimates matching objects without writing results. Default: false. |
Response 202 Accepted
{ "execution_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }
GET /api/queries/{id} — Execution status
GET https://app.hubstream.io/api/queries/a1b2c3d4-...
Authorization: Bearer hs_...
Response 200 OK
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"state": "completed",
"processed_counter": 12450,
"objects_processed": 12450,
"objects_returned": 348,
"objects_billed": 348,
"api_calls_used": 26,
"duration_ms": 4812,
"error_message": null,
"submitted_at": "2026-05-21T09:00:00Z",
"started_at": "2026-05-21T09:00:02Z",
"completed_at": "2026-05-21T09:00:06Z",
"results_expires_at": "2026-05-28T09:00:06Z"
}
GET /api/queries/{id}/results — Download URL
Returns a pre-signed S3 URL (valid 1 hour) to download the result file.
Only available when state = completed.
{
"url": "https://s3.example.com/results/a1b2c3d4.json?X-Amz-Signature=...",
"expires_in": 3600
}
| Status | Meaning |
|---|---|
409 Conflict |
Execution not yet completed |
410 Gone |
Results have expired |
GET /api/saved-queries — List saved queries
Returns all saved queries for this portal, ordered by name.
[
{
"id": 42,
"name": "CTOs in France",
"description": "All contacts with jobtitle CTO in France",
"query_text": "SELECT firstname, lastname FROM contact WHERE jobtitle = 'CTO' LIMIT 500 RECENTLY_CREATED",
"created_at": "2026-05-01T10:00:00Z",
"updated_at": "2026-05-15T14:30:00Z"
}
]
POST /api/saved-queries/{id}/execute — Run a saved query
Submits a saved query using its stored SQL. Same rate limits as POST /api/queries.
POST https://app.hubstream.io/api/saved-queries/42/execute
Authorization: Bearer hs_...
Content-Type: application/json
{ "dry_run": false }
Response 202 Accepted
{
"execution_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"saved_query_id": 42,
"saved_query_name": "CTOs in France"
}
GET /api/quota — Rate-limit quota
Returns the current rate-limit state for this portal.
{
"submissions": { "limit": 20, "remaining": 17, "reset_at": 1748812345 },
"dry_runs": { "limit": 60, "remaining": 58, "reset_at": 1748812345 },
"concurrent": { "limit": 3, "active": 1, "available": 2 }
}
GET /api/schema — Portal HubSpot schema
Returns the cached HubSpot CRM schema for this portal: object types, properties, and associations.
{
"contact": {
"label": "Contact",
"properties": {
"firstname": { "label": "First name", "type": "string" },
"email": { "label": "Email", "type": "string" }
}
}
}
Returns
404if the schema has not been generated yet. Trigger a schema sync from the HubStream dashboard.
Execution states
| State | Description |
|---|---|
pending |
Queued — not yet started |
in_progress |
Processing. processed_counter updates in real time. |
completed |
Done. Results available. |
error |
Failed. See error_message. |
Recommended polling interval: 2 seconds.
Error reference
| HTTP | error value |
Cause |
|---|---|---|
| 401 | Authorization: Bearer <key> required |
Missing header |
| 401 | Invalid or expired API key |
Bad / revoked key |
| 403 | Portal is inactive |
Portal deactivated |
| 403 | Account is inactive |
Account suspended |
| 403 | plan_restriction |
Starter plan — upgrade to Growth or higher |
| 404 | (varies) | Resource not found |
| 409 | Execution not completed yet |
Results requested too early |
| 410 | Results have expired |
Result file deleted |
| 422 | (validation errors) | Invalid request body |
| 429 | rate_limit_exceeded |
Hourly burst limit hit |
| 429 | concurrent_limit_exceeded |
Too many simultaneous executions |
Workflow example
Python — Submit, poll, download
import time, httpx
BASE = "https://app.hubstream.io/api"
HEADERS = {"Authorization": "Bearer hs_your_key"}
# 1. Submit
r = httpx.post(f"{BASE}/queries", headers=HEADERS, json={
"sql": "SELECT firstname, lastname FROM contact LIMIT 50 RECENTLY_CREATED"
})
execution_id = r.json()["execution_id"]
# 2. Poll
while True:
status = httpx.get(f"{BASE}/queries/{execution_id}", headers=HEADERS).json()
if status["state"] == "completed":
break
if status["state"] == "error":
raise RuntimeError(status["error_message"])
time.sleep(2)
# 3. Download
result_url = httpx.get(f"{BASE}/queries/{execution_id}/results", headers=HEADERS).json()["url"]
data = httpx.get(result_url).json()
print(f"{len(data)} records — first: {data[0]}")
Check quota before a batch
quota = httpx.get(f"{BASE}/quota", headers=HEADERS).json()
if quota["submissions"]["remaining"] < 5:
wait = quota["submissions"]["reset_at"] - int(time.time())
time.sleep(wait + 1)
if quota["concurrent"]["available"] == 0:
print("All slots busy — wait for a running query to complete")