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 uuid from day one.