Mirror OnRamp without polling
If you keep a copy of OnRamp data in your warehouse, PSA, or internal tools, you're probably re-downloading everything on a loop and throwing 99% of it away. This recipe converts that loop into an event-driven sync: OnRamp tells you what changed the moment it changes; you fetch just that one thing. Same mirror, fresher data, a tiny fraction of the calls.
What you'll get
The math above is a real poller's profile: every project + its tasks re-fetched every few hours (~135k calls/month for a mid-sized portfolio). The event-driven version: one re-fetch per actual change (a few dozen a day) plus one nightly reconciliation sweep. That's the ~99%.
Is this for me?
Yes, if you call GET /v1/projects/... or GET /v1/tasks... on a schedule
today. The recipe is written as an incremental migration — your existing poller
becomes the safety net, so there's no risky cut-over moment.
The pattern in one paragraph
Subscribe to change events → on each delivery, take the object's uuid from
the payload, re-fetch it via the API, and upsert it into your copy → keep
one slow reconciliation sweep (nightly is typical) to catch anything a webhook
missed. Never write payload contents directly into your mirror — deliveries
aren't signed yet, and re-fetching also guarantees you store the latest state
even when deliveries arrive out of order or twice
(delivery behavior).
Step 1 — Subscribe to the change events
for EVENT in project_created project_updated project_completed project_archived \
task_created task_updated task_completed task_deleted \
subtask_updated subtask_completed; do
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
-d "{\"event_code\": \"$EVENT\", \"hook_url\": \"https://your-receiver.example.com/onramp-events\"}" \
"https://api.onramp.us/v1/webhooks"
done
import requests
BASE = "https://api.onramp.us/v1"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
EVENTS = [
"project_created", "project_updated", "project_completed", "project_archived",
"task_created", "task_updated", "task_completed", "task_deleted",
"subtask_updated", "subtask_completed",
]
for event_code in EVENTS:
requests.post(f"{BASE}/webhooks", headers=HEADERS, json={
"event_code": event_code,
"hook_url": "https://your-receiver.example.com/onramp-events",
})
const EVENTS = [
"project_created", "project_updated", "project_completed", "project_archived",
"task_created", "task_updated", "task_completed", "task_deleted",
"subtask_updated", "subtask_completed",
];
for (const event_code of EVENTS) {
await fetch("https://api.onramp.us/v1/webhooks", {
method: "POST",
headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json" },
body: JSON.stringify({ event_code, hook_url: "https://your-receiver.example.com/onramp-events" }),
});
}
Pick the subset that matters to your mirror — every subscription is one more delivery stream to handle.
Step 2 — The receiver: fetch-on-event → upsert
from flask import Flask, request
import requests
BASE = "https://api.onramp.us/v1"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
app = Flask(__name__)
seen = set() # at-least-once delivery → dedupe (Redis/db in production)
def fetch(path):
return requests.get(f"{BASE}{path}", headers=HEADERS).json()["data"]
def upsert_project(p): # replace with your database write
print("upsert project", p["uuid"], p["name"], p["completed_percentage"])
def upsert_task(t):
print("upsert task", t["uuid"], t["name"], t["status"]["name"])
@app.post("/onramp-events")
def on_event():
event = request.get_json(silent=True) or {}
name, payload = event.get("event_name", ""), event.get("payload") or {}
key = (name, event.get("timestamp"), str(payload)[:80])
if key in seen:
return "", 204
seen.add(key)
# A delivery is a POINTER, not data: re-fetch the object, store the fetch.
if name.startswith("project_"):
uuid = (payload.get("project") or {}).get("project_uuid")
if uuid:
upsert_project(fetch(f"/projects/{uuid}"))
elif name.startswith(("task_", "subtask_")):
uuid = (payload.get("task") or {}).get("uuid") or payload.get("parent_task_uuid")
if name == "task_deleted":
print("delete task", uuid) # your delete
elif uuid:
upsert_task(fetch(f"/tasks/{uuid}"))
return "", 200
app.run(port=8080)
import express from "express";
const BASE = "https://api.onramp.us/v1";
const HEADERS = { Authorization: "Bearer YOUR_API_KEY" };
const fetchData = async (p) =>
(await (await fetch(BASE + p, { headers: HEADERS })).json()).data;
const seen = new Set();
const upsertProject = (p) => console.log("upsert project", p.uuid, p.name);
const upsertTask = (t) => console.log("upsert task", t.uuid, t.status.name);
const app = express().use(express.json());
app.post("/onramp-events", async (req, res) => {
const { event_name = "", timestamp, payload = {} } = req.body ?? {};
const key = `${event_name}:${timestamp}:${JSON.stringify(payload).slice(0, 80)}`;
if (seen.has(key)) return res.sendStatus(204);
seen.add(key);
if (event_name.startsWith("project_")) {
const uuid = payload.project?.project_uuid;
if (uuid) upsertProject(await fetchData(`/projects/${uuid}`));
} else if (event_name.startsWith("task_") || event_name.startsWith("subtask_")) {
const uuid = payload.task?.uuid ?? payload.parent_task_uuid;
if (event_name === "task_deleted") console.log("delete task", uuid);
else if (uuid) upsertTask(await fetchData(`/tasks/${uuid}`));
}
res.sendStatus(200);
});
app.listen(8080);
# The two re-fetches the receiver makes (fetch-on-event):
curl -H "Authorization: Bearer YOUR_API_KEY" "https://api.onramp.us/v1/projects/PROJECT_UUID"
curl -H "Authorization: Bearer YOUR_API_KEY" "https://api.onramp.us/v1/tasks/TASK_UUID"
Return 200 fast. Deliveries for your org are processed in order — a slow receiver delays everything behind it. Queue slow work; answer immediately.
Step 3 — Keep one reconciliation sweep
Don't delete your poller — demote it. Once nightly (instead of every N hours), walk everything and upsert. This catches the changes that don't fire a public event (data-field edits, archived-object cleanup, anything added to the platform before an event exists for it) and any delivery that was missed.
# nightly — the demoted poller, now a safety net
for p in pages("/projects", "projects"):
upsert_project(p)
for t in pages("/tasks", "tasks", project_uuid=p["uuid"]):
upsert_task(t)
Your old poller code IS this sweep — the migration is: add the receiver, then turn the schedule down from every-4-hours to nightly. If webhooks ever misbehave, turn the dial back up. No cliff.
The completion gate (bonus pattern)
A common reason teams poll: "tell me when the kickoff form task is done."
The event-driven version — subscribe task_completed, and mark the tasks that
matter with a tag (e.g. gating) so the receiver can filter:
task = fetch(f"/tasks/{uuid}")
if any(tag["name"] == "gating" for tag in task["tags"]):
trigger_downstream(task)
No receiver infrastructure? The pure-polling fallback is one call on a
schedule: GET /v1/tasks?tag_uuid=…&status_code=TASK_FINISHED and diff against
the UUIDs you've already handled. It's minutes-stale instead of seconds — for
the full harvest-and-provision loop, see
completion-gated provisioning.
Good to know
- Dedupe before upsert — deliveries are at-least-once. Because you re-fetch, an occasional duplicate just re-writes the same row (harmless); the dedupe set keeps it cheap.
- Deletes need the payload uuid. A deleted task can't be re-fetched — take the uuid from the delivery and tombstone your row.
- Rate limits still apply (600 reads/min) — but an event-driven mirror uses a tiny fraction of them. The nightly sweep is the only bulk consumer.
- Migrate to UUIDs as you build this. Payloads and endpoints both drop
integer IDs on July 31, 2026 — key your mirror by
uuidfrom day one.