solarOSsolarOS Docs
Api

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, and external_references:write scopes; the operations preset 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" }
    ]
  }'

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.

Was this page helpful?

On this page