solarOS has no built-in importer. There's no CSV upload UI and no
HubSpot integration. This page is a cookbook for writing your own
migration script against the generic Org API, using a HubSpot
contacts export as the example source. The same approach works for any CRM
export: swap externalSystem: "hubspot" for whatever source system you're
migrating from.
What you'll accomplish
Import a batch of customers (and their properties) from a CSV exported out of HubSpot, without creating duplicates if you have to re-run the script.
Prerequisites
- An API key with
customers:write,properties:write, andexternal_references:writescopes; theoperationspreset covers all three. See Authentication. - A CSV export from your old CRM (HubSpot, in this example).
Steps
1. Create an API key
Create a key with the operations scope preset (or hand-pick
customers:write, properties:write, external_references:write).
2. Create a Customer per row, with an External Reference and an Idempotency-Key
For each CSV row, call POST /customers with an externalReferences entry
pointing back at the HubSpot contact, and an Idempotency-Key derived from
the HubSpot record id, so re-running the script after a failure never
creates a duplicate:
curl -X POST "https://app.solaros.io/api/v1/customers" \
-H "Authorization: Bearer $SOLAROS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: hubspot-contact-100234-customer" \
-H "x-solaros-suppress-webhooks: 1" \
-d '{
"firstName": "Grace",
"lastName": "Hopper",
"email": "grace.hopper@example.com",
"externalReferences": [
{ "externalSystem": "hubspot", "externalObject": "contact", "externalId": "100234" }
]
}'
3. Suppress webhooks for the whole bulk pass
Set x-solaros-suppress-webhooks: 1 on every request during the migration
(shown above) so your team's Slack channel or webhook subscribers aren't
flooded by hundreds of customer.created events. Tag the source with
x-solaros-mutation-source: hubspot-migration-script so it's identifiable
later if you need to audit what the script wrote.
4. Create the linked Property
For each customer, call POST /properties with the new customerId and
its own External Reference:
curl -X POST "https://app.solaros.io/api/v1/properties" \
-H "Authorization: Bearer $SOLAROS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: hubspot-contact-100234-property" \
-H "x-solaros-suppress-webhooks: 1" \
-d '{
"customerId": "cus_abc123",
"address": "200 Sail Ave",
"city": "Annapolis",
"state": "MD",
"zipCode": "21401",
"externalReferences": [
{ "externalSystem": "hubspot", "externalObject": "property", "externalId": "100234" }
]
}'
5. Check for an existing link before creating (idempotent re-runs)
Idempotency-Key protects against retrying the same request. If you run
the whole script again days later (a separate execution, not a retry), use
GET /external-references/lookup first to skip rows that are already
linked:
curl -X GET "https://app.solaros.io/api/v1/external-references/lookup?externalSystem=hubspot&externalObject=contact&externalId=100234" \
-H "Authorization: Bearer $SOLAROS_API_KEY"
For checking many rows at once, POST /external-references/lookup/batch
does the same lookup for a list of ids in one call.
Verify it worked
Query customers by their HubSpot External Reference and confirm the count matches your source CSV's row count:
curl -X GET "https://app.solaros.io/api/v1/customers?externalSystem=hubspot" \
-H "Authorization: Bearer $SOLAROS_API_KEY"
Common problems
EXTERNAL_REFERENCE_CONFLICT(409). The same HubSpot id is already linked to a different solarOS record than the one your script is trying to write. Don't force-overwrite; resolve it manually (usually a sign two HubSpot contacts map to what should be one solarOS Customer, or vice versa).- Rate limit. The default is 5,000 requests/hour per API key. A migration of N contacts makes roughly 3×N requests (one lookup, one customer create, one property create per row); budget large imports across more than one hour, or request a higher limit for the key.
- Rollback. There's no bulk-delete endpoint. If a migration run goes
wrong, use the External References you wrote (
externalSystem: "hubspot") to find every record the script created, and undo them individually through the app or the API.
Full runnable scripts
Each script below reads a small embedded fake CSV (4 fake HubSpot contacts,
clearly marked), implements steps 1–4 end-to-end, and has a --dry-run mode
that prints the requests it would make without needing a real
SOLAROS_API_KEY, useful for checking the request shapes before you point
it at production data.
Bash (curl + jq)
#!/usr/bin/env bash
#
# Migrate a batch of HubSpot contacts (exported as CSV) into solarOS as
# Customers + Properties, using the generic Org API. solarOS has no built-in
# importer — this script IS the migration path.
#
# Usage:
# SOLAROS_API_KEY=sk_live_... ./migrate.sh # live run
# ./migrate.sh --dry-run # print request plans, no calls, no key needed
#
# Requires: bash, curl, jq
set -euo pipefail
DRY_RUN=0
if [[ "${1:-}" == "--dry-run" || -z "${SOLAROS_API_KEY:-}" ]]; then
DRY_RUN=1
fi
BASE_URL="${SOLAROS_BASE_URL:-https://app.solaros.io/api/v1}"
API_KEY="${SOLAROS_API_KEY:-sk_live_FAKE_KEY_FOR_DRY_RUN_ONLY}"
# Fake HubSpot contacts export — 4 rows, clearly fake, embedded so this
# script runs standalone without a real CSV file.
CSV_DATA='hubspot_contact_id,first_name,last_name,email,phone,street,city,state,zip
100234,Grace,Hopper,grace.hopper@example.com,+15555550101,200 Sail Ave,Annapolis,MD,21401
100235,Ada,Lovelace,ada.lovelace@example.com,+15555550102,10 Analytical Way,Cambridge,MA,02139
100236,Katherine,Johnson,katherine.johnson@example.com,+15555550103,45 Orbit Ln,Hampton,VA,23666
100237,Margaret,Hamilton,margaret.hamilton@example.com,,12 Apollo Ct,Houston,TX,77058'
request() {
local method="$1" path="$2" idempotency_key="$3" body="$4"
if [[ "$DRY_RUN" == "1" ]]; then
# Printed to stderr so it's visible even though callers capture this
# function's stdout with $(...) to read the (fake, in dry-run) response.
{
echo "PLAN: $method $BASE_URL$path"
echo " Authorization: Bearer \${SOLAROS_API_KEY}"
echo " Idempotency-Key: $idempotency_key"
echo " x-solaros-suppress-webhooks: 1"
echo " body: $body"
} >&2
return 0
fi
curl -sS -X "$method" "$BASE_URL$path" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $idempotency_key" \
-H "x-solaros-suppress-webhooks: 1" \
-H "x-solaros-mutation-source: hubspot-migration-script" \
-d "$body"
}
# Step 5: check whether a record for this External Reference already exists
# (idempotent across separate script runs, not just retries of one request).
lookup_existing() {
local external_object="$1" external_id="$2"
if [[ "$DRY_RUN" == "1" ]]; then
echo "PLAN: GET $BASE_URL/external-references/lookup?externalSystem=hubspot&externalObject=$external_object&externalId=$external_id" >&2
return 0
fi
curl -sS -X GET \
"$BASE_URL/external-references/lookup?externalSystem=hubspot&externalObject=$external_object&externalId=$external_id" \
-H "Authorization: Bearer $API_KEY"
}
echo "$CSV_DATA" | tail -n +2 | while IFS=, read -r hs_id first last email phone street city state zip; do
echo "--- HubSpot contact $hs_id ($first $last) ---"
# Step 5: skip creating a Customer that's already linked.
lookup_existing "contact" "$hs_id" > /dev/null
customer_body=$(jq -n \
--arg firstName "$first" --arg lastName "$last" --arg email "$email" \
--arg phone "$phone" --arg hsId "$hs_id" \
'{
firstName: $firstName,
lastName: $lastName,
email: $email,
phone: (if $phone == "" then null else $phone end),
externalReferences: [{ externalSystem: "hubspot", externalObject: "contact", externalId: $hsId }]
}')
# Step 2 + 3: create the Customer, Idempotency-Key derived from the
# HubSpot id so a re-run after a failure never creates a duplicate.
customer_response=$(request POST "/customers" "hubspot-contact-$hs_id-customer" "$customer_body")
if [[ "$DRY_RUN" == "1" ]]; then
customer_id="cus_FAKE_$hs_id"
else
customer_id=$(echo "$customer_response" | jq -r '.data.id')
fi
property_body=$(jq -n \
--arg customerId "$customer_id" --arg address "$street" --arg city "$city" \
--arg state "$state" --arg zip "$zip" --arg hsId "$hs_id" \
'{
customerId: $customerId,
address: $address,
city: $city,
state: $state,
zipCode: $zip,
externalReferences: [{ externalSystem: "hubspot", externalObject: "property", externalId: $hsId }]
}')
# Step 4: create the linked Property.
request POST "/properties" "hubspot-contact-$hs_id-property" "$property_body" > /dev/null
echo "done: $hs_id -> customer $customer_id"
done
echo ""
echo "Verify: GET $BASE_URL/customers?externalSystem=hubspot"
JavaScript (Node 20, built-in fetch, no dependencies)
#!/usr/bin/env node
// Migrate a batch of HubSpot contacts (exported as CSV) into solarOS as
// Customers + Properties, using the generic Org API. solarOS has no
// built-in importer — this script IS the migration path.
//
// Usage:
// SOLAROS_API_KEY=sk_live_... node migrate.js # live run
// node migrate.js --dry-run # print request plans, no calls, no key needed
//
// Requires: Node 20+ (built-in fetch). No dependencies.
const DRY_RUN = process.argv.includes("--dry-run") || !process.env.SOLAROS_API_KEY;
const BASE_URL = process.env.SOLAROS_BASE_URL ?? "https://app.solaros.io/api/v1";
const API_KEY = process.env.SOLAROS_API_KEY ?? "sk_live_FAKE_KEY_FOR_DRY_RUN_ONLY";
// Fake HubSpot contacts export — 4 rows, clearly fake, embedded so this
// script runs standalone without a real CSV file.
const FAKE_HUBSPOT_ROWS = [
{ hubspotContactId: "100234", firstName: "Grace", lastName: "Hopper", email: "grace.hopper@example.com", phone: "+15555550101", street: "200 Sail Ave", city: "Annapolis", state: "MD", zip: "21401" },
{ hubspotContactId: "100235", firstName: "Ada", lastName: "Lovelace", email: "ada.lovelace@example.com", phone: "+15555550102", street: "10 Analytical Way", city: "Cambridge", state: "MA", zip: "02139" },
{ hubspotContactId: "100236", firstName: "Katherine", lastName: "Johnson", email: "katherine.johnson@example.com", phone: "+15555550103", street: "45 Orbit Ln", city: "Hampton", state: "VA", zip: "23666" },
{ hubspotContactId: "100237", firstName: "Margaret", lastName: "Hamilton", email: "margaret.hamilton@example.com", phone: null, street: "12 Apollo Ct", city: "Houston", state: "TX", zip: "77058" },
];
async function request(method, path, { idempotencyKey, body } = {}) {
const headers = {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
// Suppress outbound webhooks for the whole bulk pass — see
// apps/web/src/lib/org-api/webhook-controls.ts.
"x-solaros-suppress-webhooks": "1",
"x-solaros-mutation-source": "hubspot-migration-script",
};
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
if (DRY_RUN) {
console.log(`PLAN: ${method} ${BASE_URL}${path}`);
console.log(` headers: ${JSON.stringify(headers)}`);
if (body) console.log(` body: ${JSON.stringify(body)}`);
return { data: { id: `FAKE_${path.split("/")[1] ?? "id"}_${idempotencyKey ?? "lookup"}` } };
}
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const result = await response.json();
if (!response.ok) {
throw new Error(`${method} ${path} failed: ${result.error?.code} ${result.error?.message}`);
}
return result;
}
async function migrateRow(row) {
console.log(`--- HubSpot contact ${row.hubspotContactId} (${row.firstName} ${row.lastName}) ---`);
// Step 5: check whether this record already exists before creating —
// makes re-running the script across separate executions idempotent,
// not just retries of a single request.
await request(
"GET",
`/external-references/lookup?externalSystem=hubspot&externalObject=contact&externalId=${row.hubspotContactId}`,
);
// Step 2 + 3: create the Customer. The Idempotency-Key is derived from
// the HubSpot id, so retrying this script after a failure never creates
// a duplicate Customer.
const customerResult = await request("POST", "/customers", {
idempotencyKey: `hubspot-contact-${row.hubspotContactId}-customer`,
body: {
firstName: row.firstName,
lastName: row.lastName,
email: row.email,
phone: row.phone,
externalReferences: [
{ externalSystem: "hubspot", externalObject: "contact", externalId: row.hubspotContactId },
],
},
});
const customerId = customerResult.data.id;
// Step 4: create the linked Property.
await request("POST", "/properties", {
idempotencyKey: `hubspot-contact-${row.hubspotContactId}-property`,
body: {
customerId,
address: row.street,
city: row.city,
state: row.state,
zipCode: row.zip,
externalReferences: [
{ externalSystem: "hubspot", externalObject: "property", externalId: row.hubspotContactId },
],
},
});
console.log(`done: ${row.hubspotContactId} -> customer ${customerId}`);
}
async function main() {
for (const row of FAKE_HUBSPOT_ROWS) {
await migrateRow(row);
}
console.log(`\nVerify: GET ${BASE_URL}/customers?externalSystem=hubspot`);
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
Python (3.11, requests)
#!/usr/bin/env python3
"""Migrate a batch of HubSpot contacts (exported as CSV) into solarOS as
Customers + Properties, using the generic Org API. solarOS has no built-in
importer -- this script IS the migration path.
Usage:
SOLAROS_API_KEY=sk_live_... python3 migrate.py # live run
python3 migrate.py --dry-run # print request plans, no calls, no key needed
Requires: Python 3.11+, the `requests` package (pip install requests).
"""
import json
import os
import sys
import requests
DRY_RUN = "--dry-run" in sys.argv or not os.environ.get("SOLAROS_API_KEY")
BASE_URL = os.environ.get("SOLAROS_BASE_URL", "https://app.solaros.io/api/v1")
API_KEY = os.environ.get("SOLAROS_API_KEY", "sk_live_FAKE_KEY_FOR_DRY_RUN_ONLY")
# Fake HubSpot contacts export -- 4 rows, clearly fake, embedded so this
# script runs standalone without a real CSV file.
FAKE_HUBSPOT_ROWS = [
{"hubspot_contact_id": "100234", "first_name": "Grace", "last_name": "Hopper", "email": "grace.hopper@example.com", "phone": "+15555550101", "street": "200 Sail Ave", "city": "Annapolis", "state": "MD", "zip": "21401"},
{"hubspot_contact_id": "100235", "first_name": "Ada", "last_name": "Lovelace", "email": "ada.lovelace@example.com", "phone": "+15555550102", "street": "10 Analytical Way", "city": "Cambridge", "state": "MA", "zip": "02139"},
{"hubspot_contact_id": "100236", "first_name": "Katherine", "last_name": "Johnson", "email": "katherine.johnson@example.com", "phone": "+15555550103", "street": "45 Orbit Ln", "city": "Hampton", "state": "VA", "zip": "23666"},
{"hubspot_contact_id": "100237", "first_name": "Margaret", "last_name": "Hamilton", "email": "margaret.hamilton@example.com", "phone": None, "street": "12 Apollo Ct", "city": "Houston", "state": "TX", "zip": "77058"},
]
def request(method, path, idempotency_key=None, body=None):
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
# Suppress outbound webhooks for the whole bulk pass -- see
# apps/web/src/lib/org-api/webhook_controls.ts.
"x-solaros-suppress-webhooks": "1",
"x-solaros-mutation-source": "hubspot-migration-script",
}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
if DRY_RUN:
print(f"PLAN: {method} {BASE_URL}{path}")
print(f" headers: {json.dumps(headers)}")
if body:
print(f" body: {json.dumps(body)}")
fake_id = f"FAKE_{path.strip('/').split('/')[0]}_{idempotency_key or 'lookup'}"
return {"data": {"id": fake_id}}
response = requests.request(method, f"{BASE_URL}{path}", headers=headers, json=body)
result = response.json()
if not response.ok:
error = result.get("error", {})
raise RuntimeError(f"{method} {path} failed: {error.get('code')} {error.get('message')}")
return result
def migrate_row(row):
hs_id = row["hubspot_contact_id"]
print(f"--- HubSpot contact {hs_id} ({row['first_name']} {row['last_name']}) ---")
# Step 5: check whether this record already exists before creating --
# makes re-running the script across separate executions idempotent,
# not just retries of a single request.
request(
"GET",
f"/external-references/lookup?externalSystem=hubspot&externalObject=contact&externalId={hs_id}",
)
# Step 2 + 3: create the Customer. The Idempotency-Key is derived from
# the HubSpot id, so retrying this script after a failure never creates
# a duplicate Customer.
customer_result = request(
"POST",
"/customers",
idempotency_key=f"hubspot-contact-{hs_id}-customer",
body={
"firstName": row["first_name"],
"lastName": row["last_name"],
"email": row["email"],
"phone": row["phone"],
"externalReferences": [
{"externalSystem": "hubspot", "externalObject": "contact", "externalId": hs_id},
],
},
)
customer_id = customer_result["data"]["id"]
# Step 4: create the linked Property.
request(
"POST",
"/properties",
idempotency_key=f"hubspot-contact-{hs_id}-property",
body={
"customerId": customer_id,
"address": row["street"],
"city": row["city"],
"state": row["state"],
"zipCode": row["zip"],
"externalReferences": [
{"externalSystem": "hubspot", "externalObject": "property", "externalId": hs_id},
],
},
)
print(f"done: {hs_id} -> customer {customer_id}")
def main():
for row in FAKE_HUBSPOT_ROWS:
migrate_row(row)
print(f"\nVerify: GET {BASE_URL}/customers?externalSystem=hubspot")
if __name__ == "__main__":
main()
These three files also live at
apps/docs/content/api/_migrate-examples/migrate.{sh,js,py} in this
repo, runnable directly (./migrate.sh --dry-run, node migrate.js --dry-run, python3 migrate.py --dry-run); the content above is kept in
sync with them by hand; docs-samples CI catches drift in the
METHOD /path calls either copy makes.