# Guides

Source: https://help.ruuster.com/api/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](https://help.ruuster.com/api/conventions):
`from` is included, `to` is excluded, weeks start on Monday, and days are America/Chicago.

```bash
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](https://help.ruuster.com/api/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.

```bash
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

```bash
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.

```js
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.

```bash
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.

```bash
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.

```bash
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}'
```

2. Receive each delivery, verify it, acknowledge it, then process it from a queue (Node/Express):

```js
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);
}
```

3. 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:

```bash
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:

```bash
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`.

```python
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.
