Menu · Guides
Guides

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.

View as Markdown

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.

  1. Find the listing by MLS number and check that exactly one attributed listing matched.
  2. Request two complete weeks at granularity=week.
  3. 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.

  1. In the sheet, open Extensions › Apps Script. Under Project Settings › Script Properties, add RUUSTER_API_KEY and RUUSTER_EMAIL.
  2. Paste the script and run pullMonthly once.
  3. 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

  1. 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}'
  1. 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);
}
  1. Test it. curl -s -X POST "$B/webhooks/<id>/test" "${H[@]}" should return statusCode: 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.