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)
- Dedup check — have we already created a project for this record?
- Create —
POST /v1/projectsfrom a playbook. - Back-reference — attach your system's record id as an external link.
- 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 —
| site_name | external_id | go_live_date | site_contact_email |
|---|---|---|---|
| Cascade Dental — Portland | CD-0001 | 2026-09-15 | portland@cascadedental.example |
| Cascade Dental — Salem | CD-0002 | 2026-09-22 | salem@cascadedental.example |
| Cascade Dental — Eugene | CD-0003 | 2026-09-29 | eugene@cascadedental.example |
| Cascade Dental — Bend | CD-0004 | 2026-10-06 | bend@cascadedental.example |
| Cascade Dental — Medford | CD-0005 | 2026-10-13 | medford@cascadedental.example |
— and the three rules that make a 50-site run boring (in the good way):
- 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.
- Checkpoint by external_id. Append each completed
external_idto 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. - Dry-run your data fields first.
POST /v1/datafield-valuesdefaults todry_run: true— run the whole batch once in dry-run, read thefailureslist, fix, then flipdry_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_dateas MM/DD/YYYY strings — while list filters elsewhere useYYYY-MM-DD.09/01/2026is 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_owneris 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.