Guides
Worked recipes: seller reports, top listings, Google Sheets, lead sources next to traffic, lead lookup, hot-lead alerts, catching up and a nightly sync.
Every recipe assumes these shell variables, and the rules in Conventions:
from is included, to is excluded, weeks start on Monday, and days are America/Chicago.
B=https://api.ruuster.com/v1
H=(-H "x-api-key: $RUUSTER_API_KEY" -H "x-email: you@yourbrokerage.com")
The numbers these recipes produce are only as good as the sources behind them. Read Data notes before putting them in front of a seller.
1. Weekly seller report with week-over-week change
This pulls the last two complete weeks for one listing in a single call and compares them.
- Find the listing by MLS number and check that exactly one attributed listing matched.
- Request two complete weeks at
granularity=week. - Work out the change from the two buckets.
MLS=22418765
LOOKUP=$(curl -s "$B/properties?mlsRecordId=$MLS" "${H[@]}")
if [ "$(echo "$LOOKUP" | jq '.data.properties | length')" != "1" ]; then
echo "Not exactly one of your listings matched:"
echo "$LOOKUP" | jq '{ambiguous: .data.ambiguous, matches: [.data.properties[].mlsRecordId]}'; exit 1
fi
PID=$(echo "$LOOKUP" | jq -r '.data.properties[0].propertyId')
FROM=2026-09-14; TO=2026-09-28 # two complete Monday-start weeks
curl -s "$B/properties/$PID/engagement?from=$FROM&to=$TO&granularity=week" "${H[@]}" \
| jq '.data.series as $s
| { listing: .data.propertyId, thisWeek: $s[1], lastWeek: $s[0],
change: { views: ($s[1].views.total - $s[0].views.total),
visitors: ($s[1].views.public.users - $s[0].views.public.users),
favorites: ($s[1].favorites - $s[0].favorites),
tourRequests: ($s[1].tourRequests - $s[0].tourRequests),
contactAgent: ($s[1].contactAgentClicks - $s[0].contactAgentClicks) } }'
Before trusting the change, check that meta.coverage.bucketsCovered is 2. Buckets outside coverage
come back as null.
2. Which listings got the most attention this week
curl -s "$B/properties/engagement?from=2026-09-21&to=2026-09-28&granularity=week&sort=views&limit=10" "${H[@]}" \
| jq -r '.data.properties[] | [.mlsRecordId, .address, .totals.views.total, .totals.views.public.users, .totals.favorites, .totals.tourRequests] | @tsv'
Change sort to favorites, tourRequests or contactAgentClicks for other rankings.
3. Monthly brokerage summary into Google Sheets
This Apps Script adds one row per month from /summary, and one row per listing from
/properties/engagement. Keep the key in Script Properties, never in the sheet.
- In the sheet, open Extensions › Apps Script. Under Project Settings › Script Properties, add
RUUSTER_API_KEYandRUUSTER_EMAIL. - Paste the script and run
pullMonthlyonce. - Add a time-driven trigger for the 2nd of each month, after the previous month is complete.
const B = "https://api.ruuster.com/v1";
function headers_() {
const p = PropertiesService.getScriptProperties();
return { "x-api-key": p.getProperty("RUUSTER_API_KEY"), "x-email": p.getProperty("RUUSTER_EMAIL") };
}
function get_(path) {
const r = UrlFetchApp.fetch(B + path, { headers: headers_(), muteHttpExceptions: true });
const body = JSON.parse(r.getContentText());
if (!body.ok) throw new Error(body.error.code + ": " + body.error.message + " (" + body.requestId + ")");
return body;
}
function monthRange_() { // the previous complete month
const d = new Date(); const to = new Date(d.getFullYear(), d.getMonth(), 1);
const from = new Date(to.getFullYear(), to.getMonth() - 1, 1);
const f = x => Utilities.formatDate(x, "America/Chicago", "yyyy-MM-dd");
return { from: f(from), to: f(to) };
}
function pullMonthly() {
const { from, to } = monthRange_();
const ss = SpreadsheetApp.getActive();
const s = get_(`/summary?from=${from}&to=${to}&granularity=month`).data.series[0];
const sum = ss.getSheetByName("Summary") || ss.insertSheet("Summary");
if (sum.getLastRow() === 0) sum.appendRow(["month","publicPageViews","leadListingViews","uniqueActiveLeads",
"leadsCreated","tourRequests","contactAgent","emailsSent","openRateHuman"]);
sum.appendRow([s.bucket, s.pageViews.public, s.pageViews.lead, s.uniqueActiveLeads, s.leadsCreated,
s.tourRequests, s.contactAgentClicks, s.emails.sent, s.emails.openRateHuman]);
const top = ss.getSheetByName("Listings") || ss.insertSheet("Listings");
if (top.getLastRow() === 0) top.appendRow(["month","mlsRecordId","address","views","visitors","favorites","tourRequests"]);
let cursor = null, rows = 0;
do {
const page = get_(`/properties/engagement?from=${from}&to=${to}&granularity=month&sort=views&limit=100`
+ (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""));
page.data.properties.forEach(p => top.appendRow([from, p.mlsRecordId, p.address, p.totals.views.total,
p.totals.views.public.users, p.totals.favorites, p.totals.tourRequests]));
rows += page.data.properties.length; cursor = page.meta.nextCursor;
} while (cursor && rows < 500);
}
4. New-lead sources next to website traffic
This puts visits by source next to leads created and engaged by source, week by week. Sources only
line up where your links carry UTM tags, such as paid social, paid search and email. Untagged traffic
shows as referral or (direct) in /traffic, and as (direct) or (crm) in /leads/stats. Add
your own spend figures in your spreadsheet.
FROM=2026-09-07; TO=2026-10-05
curl -s "$B/traffic?from=$FROM&to=$TO&granularity=week&groupBy=source,medium" "${H[@]}" > traffic.json
curl -s "$B/leads/stats?from=$FROM&to=$TO&granularity=week&groupBy=source" "${H[@]}" > leads.json
jq -rn --slurpfile t traffic.json --slurpfile l leads.json '
[ $t[0].data.series[] | {week: .bucket, source, medium, sessions, newUsers} ] as $traffic
| [ $l[0].data.series[] | .bucket as $w | .bySource[] | {week: $w, source, medium, leads: .created, engaged: .engagedWithin7d} ] as $leads
| [ $traffic[] as $tr
| ([ $leads[] | select(.week == $tr.week and .source == $tr.source and .medium == $tr.medium) ][0] // {leads: 0, engaged: 0}) as $ld
| $tr + { leads: $ld.leads, engaged: $ld.engaged } ]
| (["week","source","medium","sessions","newUsers","leads","engaged7d"] | @csv),
(.[] | [.week,.source,.medium,.sessions,.newUsers,.leads,.engaged] | @csv)' > sources.csv
engagedWithin7d for the most recent week will still rise.
5. Find a lead by phone, then pull their activity
Phone numbers match on the last 10 digits, so any formatting works.
MATCHES=$(curl -s "$B/leads/lookup?phone=(520)%20555-0142" "${H[@]}")
echo "$MATCHES" | jq '.data.leads[] | {leadId, firstName, lastName, crmExternalId, matchedOn}'
# More than one match is normal: a registered user and a CRM contact can be the same person.
LEAD=$(echo "$MATCHES" | jq -r '.data.leads[0].leadId') # use the whole value, e.g. user:c0ff3e21-…
curl -s "$B/leads/$LEAD/activity?from=2026-09-01&to=2026-10-01&granularity=day&events=1" "${H[@]}" \
| jq '{totals: .data.totals, recent: [.data.events[:10][] | {at, kind, property: .property.address}]}'
curl -s "$B/leads/$LEAD/emails?from=2026-09-01&to=2026-10-01" "${H[@]}" \
| jq '.data.emails[] | {sentAt, subject, humanOpen, clicks}'
These are lookup calls, so they count toward the lower lookup limit.
6. Hot-lead alerts into your CRM with a webhook
- Create the subscription and store the secret from the response. It's shown only once.
curl -s -X POST "$B/webhooks" "${H[@]}" -H "Content-Type: application/json" -d '{
"url": "https://hooks.yourbrokerage.com/ruuster",
"events": ["lead.tour_request", "lead.contact_agent", "lead.favorite"],
"description": "Hot-lead alerts"
}' | jq '{id: .data.id, secret: .data.secret}'
- Receive each delivery, verify it, acknowledge it, then process it from a queue (Node/Express):
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.RUUSTER_WEBHOOK_SECRET;
const app = express();
const ruuster = (path) => fetch("https://api.ruuster.com/v1" + path, {
headers: { "x-api-key": process.env.RUUSTER_API_KEY, "x-email": process.env.RUUSTER_EMAIL },
}).then((r) => r.json());
function verify(req) {
const parts = (req.get("Ruuster-Signature") ?? "").split(",");
const t = Number(parts.find(p => p.startsWith("t="))?.slice(2));
const sigs = parts.filter(p => p.startsWith("v1=")).map(p => p.slice(3));
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex");
return sigs.some(s => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
app.post("/ruuster", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req)) return res.sendStatus(401);
const body = JSON.parse(req.body.toString("utf8"));
const events = body.kind === "batch" ? body.events : [body];
for (const e of events) queue.push(e); // save first
res.sendStatus(200); // then answer, within 10 seconds
});
async function handle(e) { // your worker
if (await seen(e.id)) return; // the same event can arrive twice
if (e.kind === "webhook.test") return;
const { lead, property } = e.data;
const person = lead.crmExternalId ? await crm.findPerson(lead.crmExternalId) : null;
await crm.createTask({
assigneeEmail: (await ruuster(`/agents/${lead.agentId}`)).data.agent.email, // match to your CRM user
personId: person?.id,
title: `${e.kind.replace("lead.", "").replace("_", " ")}: ${lead.firstName} ${lead.lastName}`,
note: property ? `${property.address} (MLS ${property.mlsRecordId})` : "",
});
await markSeen(e.id);
}
- Test it.
curl -s -X POST "$B/webhooks/<id>/test" "${H[@]}"should returnstatusCode: 200.
7. Catching up after your webhook endpoint was down
Replay, once your endpoint is back up:
curl -s "$B/webhooks/whk_2c7f9a4e1b" "${H[@]}" | jq '.data.subscription | {status, disabledReason, health}'
curl -s -X PATCH "$B/webhooks/whk_2c7f9a4e1b" "${H[@]}" -H "Content-Type: application/json" -d '{"status":"active"}'
curl -s -X POST "$B/webhooks/whk_2c7f9a4e1b/replay" "${H[@]}" -H "Content-Type: application/json" \
-d '{"from":"2026-09-30T02:00:00Z","to":"2026-10-01T09:00:00Z","onlyFailed":true}'
The second call is only needed if status is disabled. Each replay covers at most 7 days, within
the last 30.
Event feed, to pull instead of waiting:
CURSOR="" # leave since off on the first call to start 24 hours back; save the last nextCursor
while :; do
R=$(curl -s "$B/events?kinds=lead.tour_request,lead.contact_agent&limit=500${CURSOR:+&since=$CURSOR}" "${H[@]}")
echo "$R" | jq -c '.data.events[]' | while read -r e; do process_if_unseen "$e"; done # de-duplicate on .id
CURSOR=$(echo "$R" | jq -r '.meta.nextCursor'); save_cursor "$CURSOR"
[ "$(echo "$R" | jq -r '.meta.hasMore')" = "true" ] || break
done
Events are kept for 30 days. For older gaps, rebuild from /leads/{leadId}/activity?events=1.
8. Nightly sync into your own database
This pulls listings, per-listing daily engagement and changed leads. Each night it re-pulls the last
three complete days, because recent figures can still move. Never sync days after
meta.coverage.to. Follow nextCursor, and wait Retry-After seconds on a 429.
import requests, time, datetime as dt
B = "https://api.ruuster.com/v1"
H = {"x-api-key": KEY, "x-email": EMAIL}
def get(path, **params):
while True:
r = requests.get(B + path, headers=H, params=params, timeout=60)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "5"))); continue
body = r.json()
if not body["ok"]:
raise RuntimeError(f'{body["error"]["code"]}: {body["error"]["message"]} ({body.get("requestId")})')
return body
def pages(path, **params):
cursor = None
while True:
body = get(path, **params, **({"cursor": cursor} if cursor else {}))
yield body
cursor = body["meta"].get("nextCursor")
if not cursor:
break
last_run = state.get("last_run") # date of the previous successful run
latest = dt.date.fromisoformat(get("/me")["data"]["coverage"]["to"])
to = (latest + dt.timedelta(days=1)).isoformat() # exclusive
frm = (latest - dt.timedelta(days=2)).isoformat() # three complete days
# 1. Listings that changed since the last run
for p in pages("/properties", status="all", dateField="updated", **({"from": last_run} if last_run else {}), limit=500):
upsert("listing", p["data"]["properties"], key=["propertyId"])
# 2. Daily engagement for every listing, last three days
for p in pages("/properties/engagement", **{"from": frm, "to": to}, granularity="day", status="all", limit=500):
for item in p["data"]["properties"]:
for b in item.get("series", []):
upsert("listing_day", [{"propertyId": item["propertyId"], **b}], key=["propertyId", "bucket"])
# 3. Leads that changed, then their daily activity for the same three days
for p in pages("/leads", dateField="updated", **({"from": last_run} if last_run else {}), limit=200):
upsert("lead", p["data"]["leads"], key=["leadId"])
for lead in p["data"]["leads"]:
s = get(f'/leads/{lead["leadId"]}/activity', **{"from": frm, "to": to}, granularity="day")["data"]
upsert("lead_day", [{"leadId": lead["leadId"], **b} for b in s["series"]], key=["leadId", "bucket"])
# 4. Brokerage weeks
upsert("summary_week", get("/summary", granularity="week")["data"]["series"], key=["bucket"])
state["last_run"] = latest.isoformat()
The first run pulls history from the earliest date you want within meta.coverage.from; after that,
each run pulls only what's new. Use ?countOnly=1 to size a pull against the daily row quotas
before you run it.