1️⃣ Get an Access Token
Exchange your client_id and client_secret for a short-lived OAuth 2.0 access token (valid for 5 minutes).
The Data Export API is the primary integration path for pulling Steer data into your data warehouse. Steer produces a daily snapshot of each tenant’s data as gzip-compressed JSON files; the API authenticates you with OAuth 2.0 and returns short-lived download links for those files.
1️⃣ Get an Access Token
Exchange your client_id and client_secret for a short-lived OAuth 2.0 access token (valid for 5 minutes).
2️⃣ Request File Links
Call the export endpoint for a data type. The response lists matching files with presigned S3 URLs (valid for 1 hour).
3️⃣ Download & Decompress
Download every URL in the response, gunzip each file, and parse the newline-delimited JSON records.
Base URLs
https://auth.steercrm.comhttps://app.steercrm.com🔐 Credentials
Steer provisions API credentials during onboarding and sends them to the email address on file:
client_id — identifies your integrationclient_secret — store in a secret manager, never commit to source control or logsAuthenticate using the OAuth 2.0 client credentials flow against the Steer token endpoint:
curl --location 'https://auth.steercrm.com/realms/steer/protocol/openid-connect/token' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=client_credentials' \ --data-urlencode 'client_id=YOUR_CLIENT_ID' \ --data-urlencode 'client_secret=YOUR_CLIENT_SECRET'{ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6...", "expires_in": 300, "refresh_expires_in": 0, "token_type": "Bearer", "not-before-policy": 0, "scope": "email profile"}Use the token in the Authorization header of every API request:
Authorization: Bearer <access_token>List the available export files for a data type and receive presigned download URLs:
GET /api/v1/public/data-export/{type}/filesAuthorization: Bearer <access_token>| Parameter | Description |
|---|---|
type | The data type to export — see Available Data Types below. |
| Parameter | Type | Required | Description |
|---|---|---|---|
tenantId | UUID | No | Restrict results to one tenant. If omitted, files for all tenants available to your credentials are returned. |
date | YYYY-MM-DD | No | Return the snapshot for a specific date. Cannot be combined with from/to. |
from, to | YYYY-MM-DD | No | Return snapshots within a date range (inclusive). Maximum span is 31 days. Cannot be combined with date. |
Latest invoice files for all available tenants:
curl --location 'https://app.steercrm.com/api/v1/public/data-export/invoice/files' \ --header 'Authorization: Bearer <access_token>'Snapshot for one tenant on a specific date:
curl --location 'https://app.steercrm.com/api/v1/public/data-export/invoice/files?tenantId=3201e5c3-334c-4c26-a685-5a94b8a1b2e0&date=2026-04-14' \ --header 'Authorization: Bearer <access_token>'All snapshots within a range (max 31 days):
curl --location 'https://app.steercrm.com/api/v1/public/data-export/invoice/files?from=2026-04-01&to=2026-04-30' \ --header 'Authorization: Bearer <access_token>'{ "status": "Success", "data": { "files": [ { "tenantId": "3201e5c3-334c-4c26-a685-5a94b8a1b2e0", "type": "invoice", "fileName": "invoice_0_0_0.json.gz", "fileDate": "2026-04-14", "sizeBytes": 289134, "presignedUrl": "https://s3-steer-dwh-export.s3.us-east-2.amazonaws.com/3201e5c3-334c-4c26-a685-5a94b8a1b2e0/invoice/2026-04-14/invoice_0_0_0.json.gz?X-Amz-Expires=3600&...", "expiresAt": "2026-04-15T11:05:06.277965Z" }, { "tenantId": "3201e5c3-334c-4c26-a685-5a94b8a1b2e0", "type": "invoice", "fileName": "invoice_0_2_0.json.gz", "fileDate": "2026-04-14", "sizeBytes": 301887, "presignedUrl": "https://s3-steer-dwh-export.s3.us-east-2.amazonaws.com/3201e5c3-334c-4c26-a685-5a94b8a1b2e0/invoice/2026-04-14/invoice_0_2_0.json.gz?X-Amz-Expires=3600&...", "expiresAt": "2026-04-15T11:05:06.278113Z" }, { "tenantId": "3201e5c3-334c-4c26-a685-5a94b8a1b2e0", "type": "invoice", "fileName": "invoice_0_3_0.json.gz", "fileDate": "2026-04-14", "sizeBytes": 160661, "presignedUrl": "https://s3-steer-dwh-export.s3.us-east-2.amazonaws.com/3201e5c3-334c-4c26-a685-5a94b8a1b2e0/invoice/2026-04-14/invoice_0_3_0.json.gz?X-Amz-Expires=3600&...", "expiresAt": "2026-04-15T11:05:06.278204Z" }, { "tenantId": "00ebab6e-4564-4255-ab72-33e6314da271", "type": "invoice", "fileName": "invoice_0_0_0.json.gz", "fileDate": "2026-04-14", "sizeBytes": 21317593, "presignedUrl": "https://s3-steer-dwh-export.s3.us-east-2.amazonaws.com/00ebab6e-4564-4255-ab72-33e6314da271/invoice/2026-04-14/invoice_0_0_0.json.gz?X-Amz-Expires=3600&...", "expiresAt": "2026-04-15T11:05:06.2510227Z" } ] }}| Field | Description |
|---|---|
tenantId | The tenant this file belongs to. |
type | The requested data type. |
fileName | File name. Not unique — it repeats across dates and tenants; identify a file by tenantId + fileDate + fileName. |
fileDate | The UTC date of the snapshot this file belongs to. |
sizeBytes | Compressed file size in bytes. |
presignedUrl | Temporary, pre-authenticated S3 download URL — no additional credentials needed. |
expiresAt | Timestamp (UTC) after which the presignedUrl stops working. |
A snapshot is split across several files. Each one is a slice of the same dataset, so the complete snapshot is the union of every file sharing the same tenantId and fileDate — download them all and concatenate the records, in any order.
In the response above, tenant 3201e5c3… has three files for 2026-04-14; together they are that tenant’s complete invoice snapshot for the day.
Each presignedUrl is a direct download link — no Authorization header required:
curl --location --output invoice_0_0_0.json.gz '<presignedUrl>'.json.gz)tenantId + fileDate together form a full snapshot of that tenant’s data for that type as of the export date — not a deltagunzip -k invoice_0_0_0.json.gzhead -n 1 invoice_0_0_0.jsonEach line is one record:
{"STEER_INVOICE_ID": "…", "STEER_TENANT_ID": "…", "STEER_SHOP_ID": "…", "STEER_UPDATED_AT": "2026-04-13 21:14:02.772", "…": "…"}To rebuild the whole snapshot, decompress every file of that date together:
cat invoice_0_*.json.gz | gunzip -c > invoice_2026-04-14.jsonwc -l invoice_2026-04-14.jsonStream-decompress and parse line by line — do not json.load() the whole file, both because it is NDJSON and because large types (e.g. invoice) can be multi-GB uncompressed:
import globimport gzipimport json
records = []for path in sorted(glob.glob("invoice_0_*.json.gz")): with gzip.open(path, "rt", encoding="utf-8") as f: for line in f: records.append(json.loads(line))
print(f"{len(records):,} invoices")type value | Schema match | Primary key |
|---|---|---|
appointment | Appointment | STEER_APPOINTMENT_ID |
call | Call | STEER_CALL_ID |
campaign | Campaign | STEER_CAMPAIGN_ID |
customer | Customer | STEER_CUSTOMER_ID |
invoice | Invoice | STEER_INVOICE_ID |
message_direct_mail | Message (direct mail) | STEER_MESSAGE_ID |
message_email | Message (email) | STEER_MESSAGE_ID |
message_text | Message (text/SMS) | STEER_MESSAGE_ID |
opportunity_hub | Opportunity Hub | STEER_CUSTOMER_ID + STEER_VEHICLE_ID (vehicle can be null) |
review | Review | STEER_REVIEW_ID |
shop | Shop | STEER_SHOP_ID |
survey | Survey | STEER_SURVEY_ID |
vehicle | Vehicle | STEER_VEHICLE_ID |
A complete sync cycle — authenticate, list the latest files, download, and parse:
import gzipimport jsonimport ioimport requestsfrom collections import defaultdict
AUTH_URL = "https://auth.steercrm.com/realms/steer/protocol/openid-connect/token"API_URL = "https://app.steercrm.com/api/v1/public/data-export"
CLIENT_ID = "YOUR_CLIENT_ID" # from environment / secret managerCLIENT_SECRET = "YOUR_CLIENT_SECRET" # never hardcode in real code
# Step 1: get an access token (valid 5 minutes)token_resp = requests.post(AUTH_URL, data={ "grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,})token_resp.raise_for_status()access_token = token_resp.json()["access_token"]
# Step 2: list the latest invoice files for all tenantsfiles_resp = requests.get( f"{API_URL}/invoice/files", headers={"Authorization": f"Bearer {access_token}"},)files_resp.raise_for_status()files = files_resp.json()["data"]["files"]
# Step 3: group the files into snapshots — one per tenant and datesnapshots = defaultdict(list)for export_file in files: snapshots[(export_file["tenantId"], export_file["fileDate"])].append(export_file)
# Step 4: download every file of a snapshot and parse the NDJSON payloadsfor (tenant_id, file_date), snapshot_files in snapshots.items(): records = [] for snapshot_file in snapshot_files: download = requests.get(snapshot_file["presignedUrl"], stream=True) download.raise_for_status()
with gzip.open(io.BytesIO(download.content), "rt", encoding="utf-8") as gz: for line in gz: records.append(json.loads(line))
print(f"tenant {tenant_id}: {len(records):,} invoices ({file_date}, {len(snapshot_files)} files)")Because each snapshot is full, not a delta, your pipeline computes changes itself. Load every file of a tenantId + fileDate before comparing — a partially loaded snapshot looks like mass deletions:
STEER_UPDATED_AT column against your last sync time.shop, survey), MERGE on primary key + STEER_UPDATED_AT for large ones (invoice, customer, vehicle, appointment).STEER_UPDATED_AT: opportunity_hub scores are recalculated every day and carry no update timestamp. Replace the tenant’s rows with each snapshot instead of filtering by update time.fileDate carries the UTC export date — the file name does not.tenantId + fileDate together contain the complete dataset for that type as of the export time.date or from/to (max 31 days per request) to backfill.| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized on the files endpoint | Access token expired (5-minute lifetime) or malformed Authorization header | Request a new token; send Authorization: Bearer <token> |
400 Bad Request with date parameters | date combined with from/to, or range wider than 31 days | Use either date or from/to; keep ranges ≤ 31 days |
403 Forbidden when downloading from the presigned URL | Presigned URL expired (1-hour lifetime) or URL was truncated/re-encoded | Call the files endpoint again and use the fresh presignedUrl verbatim |
Empty files array | No snapshot exists for the requested date/tenant yet | Retry later or query without date parameters to get the latest available snapshot |
| Record count far below expectations | Only some of the snapshot’s files were loaded | A snapshot spans several files — load every entry sharing the same tenantId and fileDate |
| Downloaded files overwrite each other | Saving by fileName, which repeats across dates and tenants | Save under a path that includes tenantId and fileDate |
404 Not Found on a file name you built yourself | File names cannot be derived — the numbers in them are writer ids with gaps | Use the presignedUrl values from the response verbatim |
| JSON parse error on the decompressed file | Parsing the file as a single JSON document | The payload is NDJSON — parse one JSON object per line |
Need help? Contact Steer support with your client_id (never the secret), the endpoint you called, and the response you received.