# How do you pull a Rodz signal’s leads through the API, without a webhook?

> Source: https://www.rodz.io/en/guides/api/poll-signal-leads/
> Updated on 2026-10-12 · Peter Cools · API recipe · Job changes · API REST

## Short answer

Call GET /api/v1/signals/{signal_id}/leads with the since parameter, page with limit and offset while hasMore is true, then write each lead to your CRM, deduplicated on contact.lead_id. Reading leads is free: a lead is charged once, when it is produced. A script scheduled every hour is enough, with no server exposed to the internet.

## Key facts

- Reading leads is **free**: a lead is charged once, when the signal produces it.
- A page holds **1 to 100 leads** (`limit`, default 25), newest first.
- The rate limit is **100 requests per second** per API key; above it the API answers `429` with `Retry-After: 1`.
- Each lead has exactly the shape of the signal’s webhook: `signal`, `contact` and `company` objects, snake_case fields.

Cost: Free to read (the lead is charged when produced)

## Before you start

- A Rodz API key, created under **Integrations → API** in the dashboard (format `USR_...`).
- An active signal, for example a Job Changes signal, and its id.
- Python 3.9+ with the `requests` library (`pip install requests`), or just `curl`.
- Somewhere to write the leads: CRM, database, spreadsheet.

## Steps

### 1. Find your signal’s id

`GET /api/v1/signals` lists the account’s signals with their `id`, `status`, `signalTypeId` and their `leadsToday` and `leadsTotal` counters. Filter with `status=active` or `signalTypeId=job_changes`.

`List active signals`

```bash
curl -G "https://api.rodz.io/api/v1/signals" \
  -H "Authorization: Bearer $RODZ_API_KEY" \
  --data-urlencode "status=active"
```

### 2. Read the leads produced since a date

`GET /api/v1/signals/{signal_id}/leads` returns leads newest first. `since` (ISO-8601 timestamp) keeps only the leads produced at or after that instant. The response gives `total`, `limit`, `offset` and `hasMore`.

Leads your filters removed are not returned: you only read what was delivered.

`One page of leads`

```bash
curl -G "https://api.rodz.io/api/v1/signals/$SIGNAL_ID/leads" \
  -H "Authorization: Bearer $RODZ_API_KEY" \
  --data-urlencode "since=2026-10-11T00:00:00+00:00" \
  --data-urlencode "limit=100" \
  --data-urlencode "offset=0"
```

### 3. Paginate and respect the rate limit

While `hasMore` is `true`, call the same URL again with `offset` increased by `limit`. On a `429` response, wait the number of seconds given by the `Retry-After` header, then send the same request again.

The script below does both, and keeps the time of the last run in a small file so it only reads new leads.

`poll_rodz_leads.py`

```python
import json
import os
import pathlib
import time
from datetime import datetime, timedelta, timezone

import requests

API = "https://api.rodz.io/api/v1"
SIGNAL_ID = os.environ["RODZ_SIGNAL_ID"]
STATE = pathlib.Path("rodz_last_run.json")

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['RODZ_API_KEY']}"


def get(path: str, params: dict) -> dict:
    """GET with a retry on 429 (Retry-After is given in seconds)."""
    while True:
        resp = session.get(f"{API}{path}", params=params, timeout=30)
        if resp.status_code == 429:
            time.sleep(float(resp.headers.get("Retry-After", "1")))
            continue
        resp.raise_for_status()
        return resp.json()


def leads_since(since: str):
    """Yield every lead produced at or after `since`, page by page."""
    offset = 0
    while True:
        page = get(
            f"/signals/{SIGNAL_ID}/leads",
            {"since": since, "limit": 100, "offset": offset},
        )
        yield from page["leads"]
        if not page["hasMore"]:
            return
        offset += page["limit"]


def upsert_into_crm(lead: dict) -> None:
    """Replace with your CRM call. Key on contact.lead_id so a re-read is harmless."""
    contact = lead.get("contact") or {}  # "" or null on company-only leads
    company = lead.get("company") or {}
    key = contact.get("lead_id") or f"{lead['signal']['id']}:{company.get('website', '')}"
    print(key, contact.get("first_name"), contact.get("last_name"), company.get("name"))


def main() -> None:
    # First run: the last 24 hours. Later runs: from the start of the previous run.
    default = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
    since = json.loads(STATE.read_text())["since"] if STATE.exists() else default
    started = datetime.now(timezone.utc).isoformat()  # taken BEFORE reading
    count = 0
    for lead in leads_since(since):
        upsert_into_crm(lead)
        count += 1
    STATE.write_text(json.dumps({"since": started}))
    print(f"{count} lead(s) since {since}")


if __name__ == "__main__":
    main()
```

### 4. Schedule the script

Run it every hour (cron, scheduled task, CI job) with `RODZ_API_KEY` and `RODZ_SIGNAL_ID` as environment variables. The start time is saved *before* reading: a lead produced during the run is read again next time, and the `lead_id` key makes that re-read harmless in your CRM.

`crontab: every hour, at minute 5`

```bash
5 * * * * cd /opt/rodz && RODZ_API_KEY=USR_xxx RODZ_SIGNAL_ID=a1b2c3d4-... python3 poll_rodz_leads.py >> poll.log 2>&1
```

## Example payload

One page returned by `GET /api/v1/signals/{signal_id}/leads` (one Job Changes lead, fictitious data).

```json
{
  "signalId": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8",
  "total": 42,
  "limit": 100,
  "offset": 0,
  "hasMore": false,
  "leads": [
    {
      "signal": {
        "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8",
        "name": "New sales leaders in SaaS",
        "type": "job_changes",
        "last_signal": "Job Change",
        "last_signal_date": "2026-10-11",
        "previous_title": "Head of Sales",
        "previous_company": "Northwind Analytics",
        "new_title": "VP Sales",
        "new_company": "Tech Corp",
        "new_job_started_on": "09/2026",
        "new_job_start_month": "2026-09",
        "change_type": "company"
      },
      "contact": {
        "lead_id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
        "created_at": "2026-10-11T09:12:00+00:00",
        "first_name": "Julien",
        "last_name": "Martin",
        "email": "julien.martin@techcorp.example",
        "phone": "",
        "title": "VP Sales",
        "location": "Paris, Île-de-France, France",
        "linkedin_profile_url": "https://www.linkedin.com/in/julien-martin-example",
        "matched_persona": "Sales Director"
      },
      "company": {
        "name": "Tech Corp",
        "website": "https://techcorp.example",
        "linkedin_url": "https://www.linkedin.com/company/techcorp-example",
        "industry": "Software Development",
        "employee_range": "201-500",
        "hq_country_code": "FR"
      }
    }
  ]
}
```

## When to prefer API polling

| Need                               | Webhook                             | API polling                  |
| ---------------------------------- | ----------------------------------- | ---------------------------- |
| Get the lead within seconds        | Yes                                 | No (at your schedule’s pace) |
| Server reachable from the internet | Required                            | Not needed                   |
| Catch up on leads after an outage  | `POST /signals/{id}/replay-webhook` | Yes, with `since`            |
| Cost                               | Included in the lead price          | Free                         |

The two combine well: a webhook for real time, and the polling script once a day as a safety net.

## Reading several signals

Loop over the ids returned by `GET /api/v1/signals?status=active` and keep one `since` date per signal. To see only the signals created by a given integration (and not production ones while you test), add `createdByThisKey=true`.

## Troubleshooting

- **`404 NOT_FOUND`**: the signal id is wrong, or the signal belongs to another account than the key’s.
- **`401 UNAUTHORIZED`**: the key is missing or revoked; the header must be `Authorization: Bearer USR_...`.
- **No leads**: check that the signal is active and that `since` is not in the future. The signal’s funnel (`GET /api/v1/signals/{signal_id}/funnel`) shows which filter removes the candidates.

## Frequently asked questions

### Webhook or API polling: which one?

The webhook delivers each lead the moment it is produced, but needs a server reachable from the internet. Polling only needs a scheduled script, and it also back-fills a CRM after an outage. Both return exactly the same format.

### Does reading leads use credits?

No. A lead is charged once, when the signal produces it. Reading it through the API, as often as you need, is free.

### How do I avoid duplicates in my CRM?

Use `contact.lead_id` as the upsert key. Because `since` includes leads produced from the given instant onward, two runs can overlap: the key makes the overlap harmless.

### What happens if I exceed the rate limit?

The API answers `429 Too Many Requests` with a `Retry-After` header. Wait that many seconds and send the request again. At 100 requests per second per key, an hourly polling script does not come close.

### Why is `contact` empty on some leads?

Some signals deliver companies without a person (a funding round without persona targeting, for example). `contact` is then empty: keep a fallback key based on the company, as in the script.

## Sources

- [Rodz API reference: List a signal’s leads, List signals, rate limits](https://api.rodz.io/docs)
- [Rodz API OpenAPI specification](https://api.rodz.io/openapi.json)
- [RFC 9110: the Retry-After header](https://www.rfc-editor.org/rfc/rfc9110#field.retry-after)

## Related guides

- https://www.rodz.io/en/guides/api/webhook-receiver-hmac/
- https://www.rodz.io/en/guides/api/create-and-route-a-signal/
- https://www.rodz.io/en/guides/use-cases/job-changes-to-hubspot/
