At-risk onboarding radar

Every morning, one Slack message tells your team exactly which onboardings need attention โ€” what's overdue, what's about to be, and who's gone quiet. No dashboards to remember to check.

What you'll get

One digest, not a firehose. This recipe deliberately sends a single daily summary instead of a ping per overdue task. Per-event alerts are how channels get muted โ€” and a muted channel protects nobody. A digest also keeps the script stateless: it never has to remember what it already alerted about.

Is this for me?

Yes, if your team lives in Slack and at-risk accounts surface too late. This is the single most-requested integration across OnRamp customers. You'll need about a day including Slack setup โ€” and most of that is Slack, not code.

Compared to Zapier: a Zap can forward one event. What it can't do is combine signals โ€” overdue plus approaching deadlines plus inactivity, rolled into one prioritized message. That combination is the whole value here.

Step 1 โ€” Two keys
  1. OnRamp API key โ€” in OnRamp: Settings โ†’ API.
  2. Slack incoming webhook โ€” in Slack: create an app โ†’ Incoming Webhooks โ†’ add to your alerts channel. You'll get a URL like https://hooks.slack.com/services/T000/B000/XXXX.
Step 2 โ€” Confirm both work
# Ask OnRamp for everything overdue right now (count is in data.pagination.total)
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.onramp.us/v1/tasks?is_overdue=true&limit=1"

# Post a test line to Slack
curl -X POST -H "Content-Type: application/json" \
  -d '{"text": "OnRamp radar test โ€” hello!"}' \
  "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK"
import requests

overdue = requests.get(
    "https://api.onramp.us/v1/tasks",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={"is_overdue": "true", "limit": 1},
).json()["data"]["pagination"]["total"]

requests.post(
    "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK",
    json={"text": f"OnRamp radar test โ€” {overdue} overdue tasks right now"},
)
const HEADERS = { Authorization: "Bearer YOUR_API_KEY" };

const body = await (await fetch(
  "https://api.onramp.us/v1/tasks?is_overdue=true&limit=1",
  { headers: HEADERS },
)).json();

await fetch("https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    text: `OnRamp radar test โ€” ${body.data.pagination.total} overdue tasks right now`,
  }),
});
Step 3 โ€” The daily digest

The logic in plain words: pull every overdue task, group them by project, add projects with work due in the next few days, sort the worst first, and post one message. Overdue is computed by OnRamp (is_overdue=true), so you never do date math on task rows.

import datetime as dt
from collections import defaultdict

import requests

BASE = "https://api.onramp.us/v1"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
SLACK_URL = "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK"
DUE_SOON_DAYS = 3  # tune to how far ahead your team plans

def pages(path, list_key, **params):
    page = 1
    while page:
        body = requests.get(f"{BASE}{path}", headers=HEADERS,
                            params=dict(params, page=page, limit=100)).json()
        yield from body["data"][list_key]
        page = body["data"]["pagination"]["nextPage"]

# 1. Group overdue tasks by project
overdue = defaultdict(list)
for task in pages("/tasks", "tasks", is_overdue="true"):
    overdue[task["project"]["uuid"]].append(task)

# 2. Projects with deadlines approaching (not yet overdue)
soon_cutoff = (dt.date.today() + dt.timedelta(days=DUE_SOON_DAYS)).isoformat()
due_soon = defaultdict(list)
for task in pages("/tasks", "tasks", due_date_before=soon_cutoff):
    if task["project"]["uuid"] not in overdue and not task.get("is_overdue"):
        due_soon[task["project"]["uuid"]].append(task)

if not overdue and not due_soon:
    raise SystemExit(0)  # nothing at risk โ€” stay silent, protect the signal

# 3. Fetch names + portal links for the flagged projects only
def project(uuid):
    body = requests.get(f"{BASE}/projects/{uuid}", headers=HEADERS).json()
    return body["data"]

lines = [f"๐Ÿšจ *{len(overdue) + len(due_soon)} onboardings need attention today*"]
for uuid, tasks in sorted(overdue.items(), key=lambda kv: -len(kv[1])):
    p = project(uuid)
    lines.append(
        f"โ€ข *{p['name']}* โ€” {len(tasks)} overdue "
        f"({p['completed_percentage']:.0f}% complete) โ€” <{p['internal_project_url']}|Open in OnRamp>"
    )
for uuid, tasks in due_soon.items():
    p = project(uuid)
    lines.append(
        f"โ€ข *{p['name']}* โ€” {len(tasks)} due in the next {DUE_SOON_DAYS} days "
        f"โ€” <{p['internal_project_url']}|Open in OnRamp>"
    )

requests.post(SLACK_URL, json={"text": "\n".join(lines)})
const BASE = "https://api.onramp.us/v1";
const HEADERS = { Authorization: "Bearer YOUR_API_KEY" };
const SLACK_URL = "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK";
const DUE_SOON_DAYS = 3;

const get = async (path, params = {}) => {
  const url = new URL(BASE + path);
  Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
  return (await fetch(url, { headers: HEADERS })).json();
};

async function* pages(path, listKey, params = {}) {
  let page = 1;
  while (page) {
    const { data } = await get(path, { ...params, page, limit: 100 });
    yield* data[listKey];
    page = data.pagination.nextPage;
  }
}

const overdue = new Map();
for await (const t of pages("/tasks", "tasks", { is_overdue: "true" })) {
  const key = t.project.uuid;
  overdue.set(key, [...(overdue.get(key) ?? []), t]);
}

const soonCutoff = new Date(Date.now() + DUE_SOON_DAYS * 864e5)
  .toISOString().slice(0, 10);
const dueSoon = new Map();
for await (const t of pages("/tasks", "tasks", { due_date_before: soonCutoff })) {
  if (!overdue.has(t.project.uuid)) {
    dueSoon.set(t.project.uuid, [...(dueSoon.get(t.project.uuid) ?? []), t]);
  }
}

if (overdue.size + dueSoon.size === 0) process.exit(0); // quiet day โ€” send nothing

const lines = [`๐Ÿšจ *${overdue.size + dueSoon.size} onboardings need attention today*`];
for (const [uuid, tasks] of [...overdue].sort((a, b) => b[1].length - a[1].length)) {
  const p = (await get(`/projects/${uuid}`)).data;
  lines.push(`โ€ข *${p.name}* โ€” ${tasks.length} overdue (${Math.round(p.completed_percentage)}% complete) โ€” <${p.internal_project_url}|Open in OnRamp>`);
}
for (const [uuid, tasks] of dueSoon) {
  const p = (await get(`/projects/${uuid}`)).data;
  lines.push(`โ€ข *${p.name}* โ€” ${tasks.length} due in the next ${DUE_SOON_DAYS} days โ€” <${p.internal_project_url}|Open in OnRamp>`);
}

await fetch(SLACK_URL, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ text: lines.join("\n") }),
});
# The digest needs grouping logic, so script it in Python or Node.
# The three API building blocks it uses:

# Every overdue task across all onboardings (grouped by .project in the reply)
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.onramp.us/v1/tasks?is_overdue=true&limit=100"

# Tasks with deadlines before a date
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.onramp.us/v1/tasks?due_date_before=2026-08-01&limit=100"

# A project's name, completion %, and links (for the digest lines)
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.onramp.us/v1/projects/PROJECT_UUID"
Step 4 โ€” Schedule it

Run it every weekday morning. Anything that can run a script on a schedule works: cron (0 8 * * 1-5), GitHub Actions schedule, or your own job runner. The script is stateless, so a missed run or a rerun is harmless.

Make it yours
  • Add an inactivity signal โ€” for each flagged project, call GET /v1/projects/{uuid}/activity?limit=1 and compare the newest entry's timestamp to today; "quiet for 9 days" is often a louder warning than overdue counts. One extra call per flagged project.
  • Customer-facing links โ€” swap internal_project_url for portal_project_url if the digest goes to a shared channel with customers.
  • Thresholds โ€” only flag projects with โ‰ฅ2 overdue tasks, or below a completion % floor by a certain age. Start loose; tighten if noisy.
Good to know
  • Due dates are calendar dates (YYYY-MM-DD), not timestamps. OnRamp computes is_overdue server-side; your script does no timezone math. Run the digest in the timezone your team plans in.
  • Silence is a feature. The script sends nothing when nothing is at risk. Resist a daily "all clear" โ€” it trains people to ignore the channel.
  • Statuses are objects ({id, name}) and lists live at data.<resource> with data.pagination (nextPage is null on the last page).
  • UUID-only. Integer IDs disappear from the API on July 31, 2026.