Zero-touch kickoff

On Salesforce or HubSpot? Stop here — OnRamp's native workflow automation creates projects from CRM triggers with no code at all. This recipe is for everything else: product signups, billing events, PSA tools, home-grown systems, and bulk rollouts (multi-site fleets, district deployments, payer mandates).

What you'll get
Is this for me?

Yes, if projects in OnRamp start life as manual copying from another system, or you onboard many sites per contract. Plan 2–5 days for a hardened integration — most of it in your system's trigger, not the OnRamp side.

The loop (per project)
  1. Dedup check — have we already created a project for this record?
  2. CreatePOST /v1/projects from a playbook.
  3. Back-reference — attach your system's record id as an external link.
  4. Seed data fields — push the deal/site facts into the project.

The back-reference in step 3 is what makes step 1 work next time: the external_id you attach is searchable via GET /v1/projects?external_id=…. That's the dedup loop — your record id is the idempotency key.

Step by step
# 1. DEDUP — anything already linked to this record of ours?
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.onramp.us/v1/projects?external_id=CD-0001&limit=1"
# → data.pagination.total == 0 means safe to create

# 2. CREATE (dates are MM/DD/YYYY here — see the footgun note below)
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Cascade Dental — Portland onboarding",
    "playbook_uuid": "PLAYBOOK_UUID",
    "account": "Cascade Dental",
    "start_date": "09/01/2026",
    "due_date": "09/15/2026",
    "project_owner": "csm@yourco.example",
    "customer_users": ["portland@cascadedental.example"],
    "send_invite_email_customers": false
  }' \
  "https://api.onramp.us/v1/projects"
# → note data.uuid in the response

# 3. BACK-REFERENCE — your record id becomes the dedup key
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "integration_uuid": "YOUR_INTEGRATION_UUID",
    "external_id": "CD-0001",
    "external_type": "site",
    "external_label": "Site CD-0001 in FleetDB",
    "external_link": "https://fleetdb.example.com/sites/CD-0001"
  }' \
  "https://api.onramp.us/v1/projects/PROJECT_UUID/external-links"

# 4. SEED DATA FIELDS — dry_run DEFAULTS TO TRUE; flip it to write
curl -X PUT ... # first look up the project's datafield value uuids, then:
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"updates": {"DATAFIELD_VALUE_UUID": "2026-09-15"}, "dry_run": false}' \
  "https://api.onramp.us/v1/datafield-values"
import requests

BASE = "https://api.onramp.us/v1"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}

def kickoff(site):  # site = one row of your system's data
    # 1. DEDUP — your record id is the idempotency key
    existing = requests.get(f"{BASE}/projects", headers=HEADERS, params={
        "external_id": site["external_id"], "limit": 1,
    }).json()["data"]["pagination"]["total"]
    if existing:
        return "already exists"

    # 2. CREATE (start_date is MM/DD/YYYY — the one US-format field)
    project = requests.post(f"{BASE}/projects", headers=HEADERS, json={
        "name": f"{site['site_name']} onboarding",
        "playbook_uuid": "PLAYBOOK_UUID",
        "account": "Cascade Dental",
        "start_date": site["start_date_mmddyyyy"],
        "project_owner": "csm@yourco.example",
        "customer_users": [site["site_contact_email"]],
        "send_invite_email_customers": False,  # invite later, deliberately
    }).json()["data"]

    # 3. BACK-REFERENCE — makes step 1 work forever after
    requests.post(f"{BASE}/projects/{project['uuid']}/external-links",
                  headers=HEADERS, json={
        "integration_uuid": "YOUR_INTEGRATION_UUID",
        "external_id": site["external_id"],
        "external_type": "site",
        "external_label": f"Site {site['external_id']}",
    })
    return project["uuid"]
const BASE = "https://api.onramp.us/v1";
const HEADERS = {
  Authorization: "Bearer YOUR_API_KEY",
  "Content-Type": "application/json",
};

async function kickoff(site) {
  // 1. DEDUP
  const dupCheck = await (await fetch(
    `${BASE}/projects?external_id=${encodeURIComponent(site.external_id)}&limit=1`,
    { headers: HEADERS },
  )).json();
  if (dupCheck.data.pagination.total > 0) return "already exists";

  // 2. CREATE (start_date is MM/DD/YYYY)
  const project = (await (await fetch(`${BASE}/projects`, {
    method: "POST", headers: HEADERS,
    body: JSON.stringify({
      name: `${site.site_name} onboarding`,
      playbook_uuid: "PLAYBOOK_UUID",
      account: "Cascade Dental",
      start_date: site.start_date_mmddyyyy,
      project_owner: "csm@yourco.example",
      customer_users: [site.site_contact_email],
      send_invite_email_customers: false,
    }),
  })).json()).data;

  // 3. BACK-REFERENCE
  await fetch(`${BASE}/projects/${project.uuid}/external-links`, {
    method: "POST", headers: HEADERS,
    body: JSON.stringify({
      integration_uuid: "YOUR_INTEGRATION_UUID",
      external_id: site.external_id,
      external_type: "site",
      external_label: `Site ${site.external_id}`,
    }),
  });
  return project.uuid;
}
Bulk rollouts

Here's a sample input file for a multi-site rollout —

Example output · bulk_sites_sample.csv
site_nameexternal_idgo_live_datesite_contact_email
Cascade Dental — PortlandCD-00012026-09-15portland@cascadedental.example
Cascade Dental — SalemCD-00022026-09-22salem@cascadedental.example
Cascade Dental — EugeneCD-00032026-09-29eugene@cascadedental.example
Cascade Dental — BendCD-00042026-10-06bend@cascadedental.example
Cascade Dental — MedfordCD-00052026-10-13medford@cascadedental.example
Download bulk_sites_sample.csv424 B

— and the three rules that make a 50-site run boring (in the good way):

  1. Respect the write budget. Writes are capped at 200/minute. Each site costs ~2–3 writes (create + link + fields), so pace at ≤60 sites/minute — a 1-second sleep per site is plenty.
  2. Checkpoint by external_id. Append each completed external_id to a local file (or db row) as you go. A crashed run restarts and skips them — and even without the checkpoint, the dedup check makes re-runs safe.
  3. Dry-run your data fields first. POST /v1/datafield-values defaults to dry_run: true — run the whole batch once in dry-run, read the failures list, fix, then flip dry_run: false.
Honest talk about retries

There are no idempotency keys on project creation yet — dedup is check-then-create, and the check and the create are not atomic. Two workers processing the same record at the same moment can both pass the check and create twins. Keep the loop single-threaded per record (a queue with one consumer per external_id is the usual shape), and treat the dedup check as a safety net, not a lock. When idempotency keys ship, this section will change.

Good to know
  • 📅 The MM/DD/YYYY footgun. Project creation takes start_date/ due_date as MM/DD/YYYY strings — while list filters elsewhere use YYYY-MM-DD. 09/01/2026 is September 1st, not January 9th. Convert explicitly; don't pass ISO dates here.
  • Invite emails are off by default (send_invite_email, send_invite_email_customers). Deliberate: bulk-created projects usually want a human-timed invite, not 50 midnight emails. Flip them consciously.
  • project_owner is required — a UUID or an email of an internal user. task_roles ({role_uuid: [user or email, …]}) lets the playbook's role placeholders resolve at create time.
  • The external_id never shows in project responses — it's write (external-links) and filter (?external_id=) only. Keep your mapping in your system too.
  • UUID-only — integer IDs leave the API July 31, 2026.