# Realtor Flow Tool Kit

> Listings, lead surveys, video articles and lead capture for any real estate website. Keep the website you have. Paste one line to put your listings, a lead-scoring survey or a contact form on any page, or call the API from your own code. The work runs on Realtor Flow.

- Documentation: https://api.realtorflow.ca/
- Base address of the API: https://api.realtorflow.ca/v1
- The API as an OpenAPI 3.1 file: https://api.realtorflow.ca/openapi.json
- Realtor Flow: https://www.realtorflow.ca
- Contact: jarvis@realtorflow.ca

## What the tool kit is

The Realtor Flow Tool Kit puts the working parts of a real estate website on a site you already have. The site stays where it is, on WordPress, on a site builder or on your own code. The tools run on Realtor Flow and are reached in two ways:

- **A line you paste** into a page. It draws your listings, a survey, a contact form or a newsletter sign-up wherever you put a tag. There is no code to write.
- **An API** your own code calls with a key. It answers in JSON.

| Tool | What it does | Pasted line | API |
| --- | --- | --- | --- |
| [Surveys](https://api.realtorflow.ca/#surveys) | A short quiz that scores each person and files them as a hot, warm or nurture lead. Write it yourself, or describe it and have it drafted. | Yes | Yes |
| [Video articles](https://api.realtorflow.ca/#videos) | A YouTube video and its transcript in. A headline, an article, questions and answers, ready HTML and search markup out. |  | Yes |
| [Listings](https://api.realtorflow.ca/#listings) | Your homes for sale and for rent: a searchable grid and listing pages with photos, an enquiry form and search markup. | Yes | Yes |
| [Bookings](https://api.realtorflow.ca/#bookings) | Short-stay requests on listings that take bookings: dates, guests and an estimate, then the broker confirms. No payment is taken. | Yes | Yes |
| [Articles and translation](https://api.realtorflow.ca/#writing) | A rough draft becomes a clear article that search engines and AI assistants can quote; text is translated into English, French, Spanish, German or Chinese. AI writes them, so they count. |  | Yes |
| [AI visibility and traffic](https://api.realtorflow.ca/#ai) | The files AI assistants read about the business, built for your site, and a count of the visits that AI assistants send you. | Yes | Yes |
| [Newsletter](https://api.realtorflow.ca/#newsletter) | A sign-up box that adds people to the account's mailing list, with their consent kept word for word. Campaigns are sent from the dashboard. | Yes | Yes |
| [Leads](https://api.realtorflow.ca/#leads) | An enquiry from any form lands in the account's lead inbox and the alert email goes out. | Yes | Yes |
| [Media](https://api.realtorflow.ca/#media) | A photo library: upload once, sized for every screen and served for good, ready for pages and listings. |  | Yes |

On WordPress, the [plugin](https://api.realtorflow.ca/#wordpress) does both for you: the tools go on a page as a block or a shortcode, and listings are written into the page itself so search engines and AI assistants read them.

> More of the platform's tools are on their way into the tool kit. This page lists only what works today.

## Quick start: paste one line

For designers. A survey on a page in about five minutes, with no code.

1. **Make a website key**: In the [Realtor Flow dashboard](https://dashboard.realtorflow.ca), open **Developers** and choose **Make a website key**. Type the address of the website it will be used on. The line to paste appears on the same page with your key already in it.

2. **Paste the line**: Put it on the page once, anywhere. In the site's footer is a good place: it is then on every page.

```html
<script src="https://api.realtorflow.ca/v1/kit.js" data-key="rf_pub_..." async></script>
```

3. **Put a tag where the survey goes**: The name is the survey's short name. The Developers page writes this tag for each of your surveys.

```html
<div data-rf-survey="ready-to-buy"></div>
```

Open the page. The survey draws itself in the page's own font and colours. Each person who finishes appears in the account's lead inbox, scored and marked hot, warm or nurture.

> A survey has to be **live** to show on a page. A draft shows a short note in its place that says so.

## Quick start: call the API

For developers. A first call in a minute, with a key that cannot break or use up anything.

1. **Make a test key**: In the [dashboard](https://dashboard.realtorflow.ca), open **Developers**, choose **New key** and pick **Test key**. It is shown once: copy it then.

2. **Check that it works**

```bash
curl https://api.realtorflow.ca/v1/account \
  -H "Authorization: Bearer $RF_KEY"
```

```json
{
  "object": "account",
  "name": "Harbour Realty",
  "mode": "test",
  "key": "Local testing",
  "language": "en",
  "tools": {
    "videos": true,
    "surveys": true,
    "leads": true,
    "listings": true,
    "bookings": true,
    "newsletter": true,
    "ai_visibility": true,
    "ai_traffic": true,
    "articles": true,
    "translation": true,
    "media": true
  },
  "usage": {
    "resets_at": "2026-12-01T00:00:00.000Z",
    "units": [
      {
        "unit": "video_article",
        "label": "Video articles",
        "used": 3,
        "in_progress": 1,
        "limit": 25,
        "left": 21
      },
      {
        "unit": "survey_draft",
        "label": "Surveys drafted by AI",
        "used": 2,
        "in_progress": 0,
        "limit": 50,
        "left": 48
      }
    ]
  }
}
```

3. **Build against the test key**: Every call answers in the same shape as the real thing. Nothing is saved, nothing is sent and nothing is counted. When your page looks right, change to the live key. See [Test mode](https://api.realtorflow.ca/#test-mode).

## The pasted line

```html
<script src="https://api.realtorflow.ca/v1/kit.js" data-key="rf_pub_..." async></script>
```

One copy per page is enough; a second copy does nothing. `async` keeps it from holding up the page. It has no dependencies and sets no cookies.

Add `data-count="on"` to it to [count visits](https://api.realtorflow.ca/#embed-count), including the ones AI assistants send you.

The key in it is a **website key** (it starts `rf_pub_`). A website key is made to be seen in a page's source, so it is tied down in two ways:

- It only works from a browser, on the websites listed on the key (up to 10). `www.` is ignored, so a key for `example.com` also works on `www.example.com`. Any other subdomain is listed on its own. Add `localhost` to try it on your own machine.
- It only opens what a visitor needs: reading a live survey's questions (never its points), sending a finished survey, sending a contact form, signing up for the newsletter and showing the listings visitors may see (active and pending ones, and a sold one on its own page). It cannot read leads, drafts or anything else on the account.

Pages that draw themselves after loading (page builders, single-page apps) are covered: new tags are picked up as they appear. To ask for it by hand, call `RealtorFlow.mount()`.

When the key, the website or the survey's name is not right, the tag shows a short note that says what to fix, and the same sentence is written to the browser's console.

## A survey on a page

```html
<div data-rf-survey="ready-to-buy"></div>
```

The visitor sees the title and intro with a start button, then one question at a time with a progress bar, then a short contact step (name, email and, if the survey asks for it, a phone number), then the result that fits their score.

What happens on our side when they finish:

- The answers are scored. 80 points or more is **hot**, 50 or more is **warm**, anything less is **nurture**.
- A lead is added to the account's inbox with the score, every answer, the page it came from and the `utm_source`, `utm_medium` and `utm_campaign` of that page's address.
- The account's alert email goes out, and any follow-up the account has set up starts.

The visitor is shown the result message only. Points and scores never reach the browser.

To make a survey, use the dashboard (**Surveys & Quizzes**, where one can be built by describing it in a chat) or the [Surveys](https://api.realtorflow.ca/#surveys) calls.

## A contact form on a page

```html
<div data-rf-form="contact"
     data-rf-title="Ask about this home"
     data-rf-button="Send my question"></div>
```

A form with name, email, phone (optional) and message. `data-rf-title` and `data-rf-button` are optional. What is sent lands in the account's lead inbox with the page it came from, and the account's alert email goes out.

## Count visits from AI assistants

```html
<script src="https://api.realtorflow.ca/v1/kit.js" data-key="rf_pub_..."
        data-count="on" async></script>
```

With `data-count="on"`, the pasted line counts each page opened, the way Realtor Flow sites are counted: no cookie, and the visitor's address is never kept (a visitor is told apart for one day only, by a code that cannot be turned back into an address). Pages that change their address without reloading are counted too.

Each visit is sorted by where it came from: **AI assistants** (ChatGPT, Perplexity, Gemini, Copilot, Claude and others, from the link in their answer or their tag in the address), search engines, social networks, other sites or direct. The figures are in the dashboard's **AI-Traffic Analytics** and through [the API](https://api.realtorflow.ca/#get-analytics).

> AI crawlers (the programs that read pages for an assistant) do not run scripts, so a pasted line cannot count them. Realtor Flow sites count them on the server, and so does the [WordPress plugin](https://api.realtorflow.ca/#wordpress).

## Booking requests on a page

```html
<div data-rf-booking="beach-cottage"></div>
```

A "Request to book" card for a listing that takes bookings: the nightly rate, the shortest stay and the most guests, the nights already booked, check-in and check-out, guests, a running estimate and the guest's details. In [the listings grid](https://api.realtorflow.ca/#embed-listings), the same card appears on every listing that takes bookings, above the enquiry form.

- Booked nights are refused before anything is sent, and again on our side, in the account's language.
- The request lands in the dashboard's **Bookings & Reservations** as an enquiry. The broker gets an alert and the guest a receipt that says it is a request, not a booking.
- No payment is taken. The estimate is nights times the nightly rate, and the card says so.

A listing takes bookings once it has [booking terms](https://api.realtorflow.ca/#put-listing-booking), set in the dashboard or with the API.

## A newsletter sign-up on a page

```html
<div data-rf-newsletter
     data-rf-title="The monthly market note"
     data-rf-text="Prices, new listings and open houses, once a month."></div>
```

An email field, a box the visitor ticks themselves and a button. The box is never ticked for them, and nothing is sent until it is.

The words beside the box are kept with the address, word for word, as the record of their consent: the account's standard line ("I agree to receive emails from Harbour Realty. I can unsubscribe at any time."), or your own with `data-rf-consent`. Every newsletter sent from the account carries an unsubscribe link.

| Attribute | What it does |
| --- | --- |
| `data-rf-title` | A heading above the box. |
| `data-rf-text` | A line under the heading: what they will get and how often. |
| `data-rf-consent` | Your own words beside the box, instead of the standard line. Say who will write and that they can unsubscribe. |
| `data-rf-name="on"` | Also ask for their name (optional for them). |
| `data-rf-button` | The button's words. The default is "Subscribe". |

The answer is the same whether the address is new, already on the list or blocked, so the box cannot be used to find out who subscribes.

## Listings on a page

```html
<!-- A grid of the account's homes, with filters -->
<div data-rf-listings></div>

<!-- One listing, for a page of its own -->
<div data-rf-listing="brick-duplex-near-the-river"></div>
```

The grid shows the active and pending listings, featured ones first, with filters for sale or rent, the type of home and the number of bedrooms. A card opens the listing in place: its photos, price, facts, description, features and an enquiry form. A sold listing leaves the grid but its own page still opens.

- The open listing gets its own address (`?rf_listing=` and its name), so it can be shared, bookmarked and reached with the back button.
- While a listing is open, its search markup (`RealEstateListing`) is in the page's head, with the page's address.
- An enquiry from a listing lands in the lead inbox filed with that listing.
- Prices, rooms and labels are written in the account's language, with its currency and unit of area.

| Attribute | What it does |
| --- | --- |
| `data-rf-listings` | Empty for all listings, or names separated by commas to show only those. |
| `data-rf-type` | `sale` or `rent`: start the grid on one of them. |
| `data-rf-limit` | How many cards at a time, up to 60. The default is 12; a button loads more. |
| `data-rf-filters="off"` | Hide the filters. |

Listings are added in the dashboard (**Listings & Property Engine**) or with the [Listings](https://api.realtorflow.ca/#listings) calls.

## WordPress plugin

Every tool on a WordPress site, without pasting anything, and readable by search engines and AI assistants.

**[Download the plugin](https://api.realtorflow.ca/v1/wordpress/realtorflow-toolkit.zip)** (version 1.0.0, WordPress 6.0 or later, PHP 7.4 or later). In WordPress: **Plugins > Add New > Upload Plugin**, then activate it.

1. **Make two keys**: On the Developers page of the Realtor Flow dashboard: a website key with the WordPress site's address on it, and a live server key.

2. **Paste them**: In WordPress, **Settings > Realtor Flow**. The page says which account it reached and which tools are on. The server key stays on the server and is never put in a page; to keep it out of the database too, put it in `wp-config.php` instead:

```php
define( 'RF_TOOLKIT_SERVER_KEY', 'rf_live_...' );
```

3. **Put the tools on pages**: In the block editor, add the **Realtor Flow** block and choose what it shows. Or use a shortcode wherever shortcodes work.

| Shortcode | What it shows |
| --- | --- |
| `[realtorflow_listings]` | The listings grid. Options: `type="sale"` or `"rent"`, `limit="12"`, `filters="off"`, `only="name-one,name-two"`. |
| `[realtorflow_listing name="..."]` | One listing on a page of its own. |
| `[realtorflow_survey name="..."]` | A lead survey. |
| `[realtorflow_form]` | A contact form. Options: `title`, `button`. |
| `[realtorflow_newsletter]` | Newsletter sign-up. Options: `title`, `text`, `consent`, `button`, `name="on"`. |
| `[realtorflow_booking name="..."]` | A booking request for a rental. |

### What it does beyond the pasted line

- **Listings in the page itself.** The grid and each listing are written into the HTML on the server, with photos, prices, facts, descriptions and their search markup, so crawlers that do not run scripts still read them. Visitors see the same listings drawn by the pasted line.
- **A page for each listing.** A listing opened on the listings page (`?rf_listing=` and its name) gets its own title, canonical address and `RealEstateListing` markup, including alongside Yoast SEO and Rank Math.
- **AI search files.** `/llms.txt` (the business, its published pages and its listings), the AI crawler lines added to WordPress's `robots.txt`, and the business's `RealEstateAgent` markup on the home page. Each can be turned off, for example when an SEO plugin already describes the business.
- **AI crawlers counted.** Visits from GPTBot, ClaudeBot, PerplexityBot and the others are noted as pages are drawn and sent every few minutes, with [Report AI crawler visits](https://api.realtorflow.ca/#post-crawls). They show in the dashboard's AI traffic.
- **Page views and AI-assistant visits** counted as with `data-count="on"`: no cookie, no address kept.

> Listings and the AI files are kept for a few minutes so pages stay fast. **Settings > Realtor Flow** has a button to read them fresh.

## Make it match the site

Surveys and forms take the page's font and text colour as they are. The accent colour comes from the account. Everything else is set with CSS custom properties, on the tag or on any element around it:

```css
[data-rf-survey], [data-rf-form] {
  --rf-accent: #0b6e4f;
  --rf-radius: 4px;
  --rf-bg: #ffffff;
  --rf-width: 560px;
}

/* The card itself can be reached too. */
[data-rf-survey]::part(card) {
  box-shadow: 0 10px 30px rgba(0, 0, 0, .08);
}
```

| Property | What it sets | When left out |
| --- | --- | --- |
| `--rf-accent` | Buttons, the chosen answer, the progress bar | The account's accent colour |
| `--rf-on-accent` | Text on the accent colour | Black or white, whichever reads better |
| `--rf-bg` | The card's background | Transparent |
| `--rf-text` | Text colour | The page's |
| `--rf-font` | Font | The page's |
| `--rf-heading-font` | Font of the headings | The page's |
| `--rf-title-size` | Size of the headings | `1.5em` |
| `--rf-radius` | Rounding of the card | `12px` |
| `--rf-width` | The card's greatest width | `640px` |
| `--rf-margin` | Space around the card. `0 auto` centres it | `0`: it lines up with the text around it |
| `--rf-padding` | Space inside the card | 20 to 32 pixels, by screen width |
| `--rf-border` | The card's border | A thin grey line |
| `--rf-field-bg` | Background of the form fields | Transparent |
| `--rf-card-width` | Listings: the smallest width of a card in the grid | `250px` |
| `--rf-gap` | Listings: the space between cards | `20px` |

Listings have no frame of their own: they fill the space they are given. Give them one with `--rf-border` and `--rf-padding`.

Each survey, form or listing grid draws itself inside its own shadow root, so the page's styles do not break it and it breaks none of the page's.

## Events

Each tag announces what happens in it. The events bubble, so one listener on `document` hears them all. Use them for analytics or to show something of your own afterwards.

| Event | When | `event.detail` |
| --- | --- | --- |
| `rf:ready` | A survey, form or listings tag has drawn itself | `{ survey }`, `{ form }`, `{ listings }`, `{ listing }`, `{ booking }` or `{ newsletter }` |
| `rf:listing` | A listing was opened | `{ listing }` |
| `rf:subscribe` | Someone signed up for the newsletter | `{ newsletter: true }` |
| `rf:booking` | A booking request was sent | `{ listing, check_in, check_out }` |
| `rf:start` | A visitor pressed the start button | `{ survey }` |
| `rf:complete` | A visitor finished a survey and is now a lead | `{ survey }` |
| `rf:lead` | A contact form or a listing enquiry was sent | `{ form }`, or `{ form: "listing", listing }` |
| `rf:error` | Something could not be shown or sent | `{ code, message }` |

```js
document.addEventListener('rf:complete', (event) => {
  // For example, count it in Google Analytics.
  gtag('event', 'generate_lead', { survey: event.detail.survey });
});
```

## Keys

Keys are made on the **Developers** page of the [dashboard](https://dashboard.realtorflow.ca). There are three kinds:

| Kind | Starts with | Where it goes | What it does |
| --- | --- | --- | --- |
| Website key | `rf_pub_` | In [the pasted line](https://api.realtorflow.ca/#script), in a page | Shows listings, booking requests, surveys, forms and the newsletter sign-up on the websites listed on it. Nothing else. |
| Test key | `rf_test_` | On your server, while you build | Every call answers. Nothing is saved, sent or counted. |
| Live key | `rf_live_` | On your server | The real thing. |

- A live or test key is shown **once**, when it is made. Only a fingerprint of it is kept, so a lost key cannot be shown again: revoke it and make a new one.
- An account can have up to 10 keys in use. Give each one a name that says where it is used, and revoke the ones that are not.
- **Never put a live or test key in a page or in an app's front end.** Anyone can read it there. The API does not answer pages on other websites, apart from the pasted line's own calls.

> The tool kit is switched on for each account by Realtor Flow. If the Developers page says it is not on yet, write to jarvis@realtorflow.ca.

## Test mode

A test key runs the same checks on what you send as a live key, and refuses the same mistakes with the same codes. The difference is what happens next:

| Call | With a test key |
| --- | --- |
| Reading the account, usage, surveys and listings | The real answers from the account. |
| Adding, changing or deleting a listing | Checked and answered as if done. Nothing is saved and no photo is fetched. |
| Adding a sign-up or unsubscribing someone | Checked and answered as if done. The list does not change. |
| Booking terms, requests and status changes | Checked like the real thing, nights already held included. Nothing is saved and no email goes out. |
| AI visibility files and AI traffic | The real answers: building the files changes nothing. |
| [Write an article](https://api.realtorflow.ca/#post-articles-draft), [translate](https://api.realtorflow.ca/#post-translate) | A fixed sample article; each text back marked `[fr]` (or the language asked for). No AI runs. |
| [Send a video](https://api.realtorflow.ca/#post-videos) | A sample video with an id that starts `test_`. Its status is `writing` for about four seconds, then `ready` with a fixed sample article. No AI runs. |
| [List videos](https://api.realtorflow.ca/#get-videos) | Always an empty list. |
| [Draft a survey](https://api.realtorflow.ca/#post-surveys-draft) | One of the ready-made surveys, not one written from your words. No AI runs. |
| Creating, changing or deleting a survey | Checked and answered as if done. Nothing is saved. |
| [Send a finished survey](https://api.realtorflow.ca/#post-survey-responses) | Scored for real against the survey, draft or live, so you can build against true results. No lead is saved and nobody is written to. |
| [Send a lead](https://api.realtorflow.ca/#post-leads) | Checked and answered. No lead is saved and no email goes out. |
| [List](https://api.realtorflow.ca/#get-leads) and [get](https://api.realtorflow.ca/#get-lead) leads | The account's real leads: reading changes nothing. |
| [Change a lead](https://api.realtorflow.ca/#patch-lead), [stop its follow-ups](https://api.realtorflow.ca/#post-lead-follow-ups-stop) | The lead as it would be afterwards, marked. Nothing is saved and no follow-up is stopped. |
| [Upload a photo](https://api.realtorflow.ca/#post-media) | A file is read to check it is a photo and the sample has its real size; a web address is not fetched. Nothing is stored. Reading the library is real; changing alt text and deleting answer as if done but change nothing. |

A sample answer carries `"test": true`, and every answer has the header `X-RF-Mode` set to `test` or `live`.

## Calls and answers

Base address:

```
https://api.realtorflow.ca/v1
```

- Send the key in a header: `Authorization: Bearer <key>`.
- Send JSON with `Content-Type: application/json`. The most a call can carry is 2 MB.
- Answers are JSON. Times are in UTC, written like `2026-11-12T15:04:05.000Z`.
- A list comes back as `{ "object": "list", "data": [...], "page": 1, "has_more": false }`.

A refusal always has the same shape: a sentence a person can read and a short code a program can act on, with the HTTP status that fits. Branch on `code`. The sentence is written to be shown to a person as it is.

```json
{
  "error": "A lead needs a \"name\" and a valid \"email\".",
  "code": "bad_lead"
}
```

The examples on this page read the key from `RF_KEY`. The PHP ones use WordPress's own functions; outside WordPress, any HTTP library does the same.

## Limits and usage

### How fast

Each key can make 120 calls a minute. Calls that start work have a lower ceiling of their own:

| Call | A minute, per key |
| --- | --- |
| Any call | 120 |
| Send a finished survey, send a lead, add a sign-up, unsubscribe | 60 each |
| Send a video, create or change a survey, add or change a listing, booking terms, requests and status changes | 30 each |
| Draft a survey, write an article, translate | 20 each |

Every answer says where the key stands in the headers `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the count starts again). Past the ceiling the answer is `429` with the code `slow_down`.

The pasted line is counted for each visitor instead: 120 calls a minute, 10 finished surveys, 6 contact forms, 10 booking requests, 6 newsletter sign-ups and 120 counted page views in ten minutes. A real visitor never reaches these.

### How much

Work that is written by AI is counted against a monthly allowance: one for each video article, each survey draft, each article written from a draft and each translation call. Everything else (reading, saving surveys and listings, taking responses, taking leads) is not counted.

- Months are calendar months in UTC. [The month's count](https://api.realtorflow.ca/#get-usage) says what is used and what is left.
- Work is counted when it comes back. Work that fails is not counted.
- The same video sent twice is counted once.
- When the allowance is used up, the answer is `429` with the code `limit_reached`, and nothing is started.

Allowances are set for each account. For a larger one, write to jarvis@realtorflow.ca.

## Account

### The account

`GET /v1/account`

Who the key belongs to, which tools are switched on and how much of the month's allowance is left. The call to make first: it says whether the key works.

```bash
curl https://api.realtorflow.ca/v1/account \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "account",
  "name": "Harbour Realty",
  "mode": "live",
  "key": "Website server",
  "language": "en",
  "tools": {
    "videos": true,
    "surveys": true,
    "leads": true,
    "listings": true,
    "bookings": true,
    "newsletter": true,
    "ai_visibility": true,
    "ai_traffic": true,
    "articles": true,
    "translation": true,
    "media": true
  },
  "usage": {
    "resets_at": "2026-12-01T00:00:00.000Z",
    "units": [
      {
        "unit": "video_article",
        "label": "Video articles",
        "used": 3,
        "in_progress": 1,
        "limit": 25,
        "left": 21
      },
      {
        "unit": "survey_draft",
        "label": "Surveys drafted by AI",
        "used": 2,
        "in_progress": 0,
        "limit": 50,
        "left": 48
      }
    ]
  }
}
```

In the answer:

- `name` (string): The account's business name.
- `mode` (string): `live` or `test`: the kind of key that made the call.
- `key` (string): The name the key was given on the Developers page.
- `language` (string): The account's language. Articles and drafted surveys are written in it.
- `tools` (object): Which tools are switched on for the account.
- `usage` (object): The same as [the month's count](https://api.realtorflow.ca/#get-usage).

Can be refused with: `no_key` (401), `bad_key` (401), `key_revoked` (401), `not_switched_on` (403).

### The month's count

`GET /v1/usage`

What has been counted this calendar month (UTC) and what is left.

```bash
curl https://api.realtorflow.ca/v1/usage \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "usage",
  "resets_at": "2026-12-01T00:00:00.000Z",
  "units": [
    {
      "unit": "video_article",
      "label": "Video articles",
      "used": 3,
      "in_progress": 1,
      "limit": 25,
      "left": 21
    },
    {
      "unit": "survey_draft",
      "label": "Surveys drafted by AI",
      "used": 2,
      "in_progress": 0,
      "limit": 50,
      "left": 48
    }
  ]
}
```

In the answer:

- `resets_at` (string): When the count starts again.
- `units[].unit` (string): `video_article` or `survey_draft`.
- `units[].used` (number): Work that came back and was counted.
- `units[].in_progress` (number): Work under way. It holds a place until it comes back.
- `units[].limit` (number): The account's allowance for the month.
- `units[].left` (number): What can still be asked for.

## Videos

A YouTube video becomes a page that search engines and AI assistants can read: an article, questions and answers, the transcript and the markup that ties them to the video.

The article is written from what is said in the video. The title and description you send, and the business details on the account, are read for context. Nothing is looked up and nothing is added from elsewhere; quotations are kept only when the transcript holds them word for word.

The tool kit does not fetch the transcript from YouTube: you send it. A channel's owner can download a video's captions as a `.srt` or `.vtt` file in YouTube Studio, and that file can be sent as it is. The article is written in the account's language, from the first hour or so of speech (60,000 characters).

1. **Send the video**: [Send a video](https://api.realtorflow.ca/#post-videos) with its transcript. The answer is `202` with the status `writing`.

2. **Ask until it is ready**: [Get the video](https://api.realtorflow.ca/#get-video) every few seconds until `status` is `ready`.

3. **Put it on your page**: Use `headline` as the heading, `search.title` and `search.description` in the page's head, then `embed_html`, `html` and `json_ld` in the body.

```html
<head>
  <title>{search.title}</title>
  <meta name="description" content="{search.description}">
  <script type="application/ld+json">{json_ld}</script>
</head>
<body>
  <h1>{headline}</h1>
  {embed_html}
  {html}
</body>
```

The HTML carries no styles of its own. Dress it with these class names: `rf-video-article` (the whole thing), `rf-summary`, `rf-intro`, `rf-points`, `rf-section`, `rf-quote`, `rf-closing`, `rf-faq`, `rf-transcript`, `rf-time` (the time links in the transcript) and `rf-video` (the player).

### Send a video

`POST /v1/videos`

Send the address of a YouTube video and the words spoken in it. The answer comes back at once with the status `writing`; the article follows, usually in under two minutes.

Sending the same video again returns the same answer and is not counted twice. To have the article written again, send `rewrite: true`: that is counted as a new article.

Send:

- `youtube_url` (string, required): The address of the video on YouTube.
- `transcript` (string, required): The words spoken in the video: plain text, or the contents of a captions file (`.srt` or `.vtt`) as it is. At least 400 characters of speech. Not needed when the account already holds a transcript for this video.
- `title` (string): The video's title. Up to 200 characters.
- `description` (string): The video's description on YouTube. It is read for context.
- `page_url` (string): The full address of the page the article will be on. It goes into the markup, so send it if you know it.
- `duration` (number): The video's length in seconds.
- `published_at` (string): When the video was published, as a date.
- `rewrite` (boolean): `true` to have an article that already exists written again.

```bash
curl -X POST https://api.realtorflow.ca/v1/videos \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "title": "Harbour district walk-through",
    "page_url": "https://www.example.com/videos/before-you-make-an-offer",
    "transcript": "Welcome to the harbour district. Today we walk through what to look at before you make an offer on a home here. First, look at the roof and ask how old it is. Second, read the condo minutes for the last two years. Third, ask what the seller is leaving behind. The best time to visit is the early morning, when the street is quiet. Thank you for watching, and write to us with your questions about buying in the harbour district."
  }'
```

Answer (`202`):

```json
{
  "id": "5d0c1c0e-8f0a-4a57-9f0e-2b7f6a1d3c11",
  "object": "video",
  "status": "writing",
  "youtube_id": "dQw4w9WgXcQ",
  "youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "title": "Harbour district walk-through",
  "page_url": "https://www.example.com/videos/before-you-make-an-offer",
  "headline": null,
  "summary": null,
  "created_at": "2026-11-12T15:04:05.000Z",
  "updated_at": "2026-11-12T15:04:05.000Z"
}
```

Can be refused with: `bad_youtube_url` (400), `no_transcript` (400), `transcript_too_short` (422), `transcript_too_long` (413), `bad_page_url` (400), `limit_reached` (429), `ai_off` (503), `tool_off` (403).

### Get a video

`GET /v1/videos/{id}`

Ask until `status` is `ready`. Every few seconds is enough. Until then the answer has the short form shown under [Send a video](https://api.realtorflow.ca/#post-videos); the article and its markup are added when it is `ready`. A video that is `failed` carries a `note` that says why; send it again to retry, and nothing was counted.

In the address:

- `transcript` (string): Send `0` to leave the transcript out of `html`. It stays in `transcript` and in the markup.

```bash
curl https://api.realtorflow.ca/v1/videos/5d0c1c0e-8f0a-4a57-9f0e-2b7f6a1d3c11 \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "5d0c1c0e-8f0a-4a57-9f0e-2b7f6a1d3c11",
  "object": "video",
  "status": "ready",
  "youtube_id": "dQw4w9WgXcQ",
  "youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "title": "Harbour district walk-through",
  "page_url": "https://www.example.com/videos/before-you-make-an-offer",
  "headline": "What to Check Before You Make an Offer in the Harbour District",
  "summary": "Three things to look at before you make an offer on a home in the harbour district: the roof, the condo minutes and what the seller leaves behind.",
  "created_at": "2026-11-12T15:04:05.000Z",
  "updated_at": "2026-11-12T15:05:12.000Z",
  "search": {
    "title": "Before You Make an Offer in the Harbour District",
    "description": "The roof, the condo minutes and what stays with the home: what to check before you make an offer in the harbour district."
  },
  "topic": null,
  "tags": [
    "harbour district",
    "making an offer",
    "buying a home"
  ],
  "article": {
    "intro": "Before you make an offer on a home in the harbour district, three checks are worth the time.",
    "points": [
      "Ask how old the roof is.",
      "Read the condo minutes for the last two years.",
      "Ask what the seller is leaving behind."
    ],
    "sections": [
      {
        "heading": "What should you look at first?",
        "text": "Start with the roof and ask how old it is. ..."
      }
    ],
    "quotes": [
      {
        "text": "The best time to visit is the early morning, when the street is quiet.",
        "who": ""
      }
    ],
    "faq": [
      {
        "question": "When is the best time to visit?",
        "answer": "The early morning, when the street is quiet."
      }
    ],
    "closing": "Write to us with your questions about buying in the harbour district."
  },
  "check": {
    "numbers": [],
    "quotes": []
  },
  "html": "<div class=\"rf-video-article\">\n<p class=\"rf-summary\">Three things to look at ...</p>\n...\n</div>",
  "embed_html": "<div class=\"rf-video\" style=\"...\"><iframe src=\"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ\" ...></iframe></div>",
  "json_ld": [
    {
      "@context": "https://schema.org",
      "@type": "VideoObject",
      "name": "What to Check Before You Make an Offer in the Harbour District",
      "...": "..."
    },
    {
      "@context": "https://schema.org",
      "@type": "FAQPage",
      "...": "..."
    }
  ],
  "transcript": [
    {
      "t": 0,
      "h": "",
      "text": "Welcome to the harbour district. Today we walk through ..."
    }
  ]
}
```

In the answer:

- `id` (string): The video's id on this account.
- `status` (string): `writing`, `ready` or `failed`. (`new` for a video that has no article and none on the way.)
- `youtube_id` (string): The video's id on YouTube.
- `title` (string): The title you sent.
- `page_url` (string or null): The address you said the article will be on.
- `headline` (string or null): The headline written for the article. Use it as the page's `<h1>`.
- `summary` (string or null): Two or three sentences on what the video covers.
- `search.title` (string): Written for the page's `<title>`.
- `search.description` (string): Written for the page's meta description.
- `topic` (string or null): The topic it was filed under, when the account has set topics.
- `tags` (array): A few subject tags.
- `article` (object): The article in parts: `intro`, `points` (the takeaways), `sections` (each a `heading` and `text`), `quotes` (each `text` and `who`), `faq` (each `question` and `answer`) and `closing`.
- `check` (object): `numbers` and `quotes` in the article that the transcript does not hold word for word. Empty lists mean there is nothing to look at. Read these before you publish.
- `html` (string): The whole article as plain HTML: no styles, no scripts, class names that start with `rf-`.
- `embed_html` (string): The YouTube player, set to load no YouTube cookies until a visitor presses play.
- `json_ld` (array): The markup search engines and AI assistants read: a `VideoObject` and, when the article has questions, a `FAQPage`.
- `transcript` (array): The transcript in paragraphs: `t` (seconds from the start, when known), `h` (a heading, when there is one) and `text`.

Can be refused with: `not_found` (404).

### List videos

`GET /v1/videos`

The account's videos, newest first, in the short form (no article). A test key always gets an empty list.

In the address:

- `limit` (number): How many to a page: 1 to 100. The default is 20.
- `page` (number): Which page, from 1.

```bash
curl "https://api.realtorflow.ca/v1/videos?limit=10" \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "list",
  "data": [
    {
      "id": "5d0c1c0e-8f0a-4a57-9f0e-2b7f6a1d3c11",
      "object": "video",
      "status": "ready",
      "youtube_id": "dQw4w9WgXcQ",
      "youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
      "title": "Harbour district walk-through",
      "page_url": "https://www.example.com/videos/before-you-make-an-offer",
      "headline": "What to Check Before You Make an Offer in the Harbour District",
      "summary": "Three things to look at before you make an offer on a home in the harbour district: the roof, the condo minutes and what the seller leaves behind.",
      "created_at": "2026-11-12T15:04:05.000Z",
      "updated_at": "2026-11-12T15:04:05.000Z"
    }
  ],
  "page": 1,
  "has_more": false
}
```

### Delete a video

`DELETE /v1/videos/{id}`

Removes a video that was added through the tool kit, with its article. A video the account added in its own dashboard is removed there.

```bash
curl -X DELETE https://api.realtorflow.ca/v1/videos/5d0c1c0e-8f0a-4a57-9f0e-2b7f6a1d3c11 \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "5d0c1c0e-8f0a-4a57-9f0e-2b7f6a1d3c11",
  "object": "video",
  "deleted": true
}
```

Can be refused with: `not_found` (404), `not_yours_to_delete` (409).

## Surveys

A short quiz that tells an agent who is ready now. Every person who finishes becomes a lead with a score.

### The shape of a question

```json
{
  "text": "When would you like to move?",
  "type": "single",
  "options": [
    {
      "label": "Within 3 months",
      "points": 35
    },
    {
      "label": "In 3 to 6 months",
      "points": 25
    },
    {
      "label": "In 6 to 12 months",
      "points": 10
    },
    {
      "label": "Just looking for now",
      "points": 0
    }
  ]
}
```

| Field | Type | What it is |
| --- | --- | --- |
| `text` | string | The question. Up to 200 characters. |
| `help` | string | An optional line under the question. |
| `type` | string | `single` (one answer, the default) or `multi` (several can be chosen). |
| `options` | array | 2 to 8 answers, each a `label` (up to 120 characters) and its `points` (0 to 100). |
| `id` | string | Given to every question and answer when the survey is saved. Send them back when you change a survey. |

### How a survey is scored

- A `single` question gives the points of the answer chosen. A `multi` question gives the points of every answer chosen.
- The total decides the lead's temperature: **hot** at 80 or more, **warm** at 50 or more, otherwise **nurture**.
- So the strongest answers should add up to 100. A drafted survey always does. When yours does not, the survey says so in `warnings`.

Two more fields come back on every question and answer, `maps` and `value`. They match answers to listings on sites hosted by Realtor Flow. Leave them out.

### Draft a survey from a description

`POST /v1/surveys/draft`

Say who the survey is for and what you want to find out. A complete survey comes back: questions, answers, points that add up to 100 and the three result messages. The call answers when the draft is written, which can take up to a minute.

Nothing is saved. Look the draft over, then send its `survey` to [Create a survey](https://api.realtorflow.ca/#post-surveys). To change a draft, send it back as `survey` with a new `prompt` ("make it shorter", "add a question about schools"). To carry a longer conversation, send `messages` instead of `prompt`: a list of `{ "role": "user" | "assistant", "text": "..." }` that ends with one from the user.

Each draft that comes back is counted as one survey draft. A draft that fails is not counted.

Send:

- `prompt` (string, required): What you want, in your own words. Up to 2,000 characters. Left out only when `messages` is sent.
- `survey` (object): A draft to change, as it came back from this call.
- `messages` (array): The whole conversation so far, instead of `prompt`. Up to 24 messages.

```bash
curl -X POST https://api.realtorflow.ca/v1/surveys/draft \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A survey for first-time buyers in Halifax. I want to know how soon they plan to buy and where they are with financing."
  }'
```

Answer (`200`):

```json
{
  "object": "survey_draft",
  "reply": "Here is a three-question survey for first-time buyers in Halifax. Timing and financing carry most of the points.",
  "survey": {
    "title": "Are you ready to buy in Halifax?",
    "intro": "Three quick questions about your timing and your financing.",
    "questions": [
      {
        "text": "When would you like to move?",
        "type": "single",
        "options": [
          {
            "label": "Within 3 months",
            "points": 35
          },
          {
            "label": "In 3 to 6 months",
            "points": 25
          },
          {
            "label": "In 6 to 12 months",
            "points": 10
          },
          {
            "label": "Just looking for now",
            "points": 0
          }
        ]
      },
      {
        "...": "two more questions"
      }
    ],
    "results": {
      "hot": {
        "heading": "You are ready to buy",
        "text": "Your timing and financing are in place. I will be in touch shortly to line up viewings."
      },
      "warm": {
        "heading": "You are getting close",
        "text": "A step or two is still open, usually financing or timing. I will follow up with what to do next."
      },
      "nurture": {
        "heading": "You are at the start",
        "text": "There is no rush. Reach out whenever you want to talk it through."
      }
    }
  },
  "problem": ""
}
```

In the answer:

- `reply` (string): A sentence or two for the person: what was written or changed.
- `survey` (object): The draft: `title`, `intro`, `questions` and `results`.
- `problem` (string): What stops the draft from going live, in words. Empty when it is complete.

Can be refused with: `no_prompt` (400), `draft_failed` (502), `limit_reached` (429), `try_later` (503), `ai_off` (503), `tool_off` (403).

### Create a survey

`POST /v1/surveys`

Saves a survey, written by you or as a draft handed back. It is a draft until its `status` is `live`. An account holds up to 50 surveys.

Send:

- `title` (string, required): The survey's name, shown at its top. Up to 140 characters.
- `intro` (string): A sentence or two under the title. Up to 600 characters.
- `questions` (array, required): Up to 12 questions. See [the shape of a question](https://api.realtorflow.ca/#survey-shape).
- `results` (object): What a person reads at the end: `hot`, `warm` and `nurture`, each a `heading` and `text`.
- `form` (object): The contact step: `button_label`, `contact_heading`, `contact_text`, `ask_phone` and `require_phone`.
- `status` (string): `draft` (the default), `live` or `archived`. A live survey has to be complete.
- `slug` (string): The short name used in the tag and in addresses. Made from the title when left out.

```bash
curl -X POST https://api.realtorflow.ca/v1/surveys \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Are you ready to buy in Halifax?",
    "intro": "Three quick questions about your timing and your financing.",
    "questions": [
      {
        "text": "When would you like to move?",
        "type": "single",
        "options": [
          {
            "label": "Within 3 months",
            "points": 35
          },
          {
            "label": "In 3 to 6 months",
            "points": 25
          },
          {
            "label": "In 6 to 12 months",
            "points": 10
          },
          {
            "label": "Just looking for now",
            "points": 0
          }
        ]
      },
      {
        "text": "Where are you with financing?",
        "type": "single",
        "options": [
          {
            "label": "Pre-approved for a mortgage",
            "points": 35
          },
          {
            "label": "Paying cash",
            "points": 35
          },
          {
            "label": "Talking to a lender",
            "points": 20
          },
          {
            "label": "Not started",
            "points": 0
          }
        ]
      },
      {
        "text": "Do you have a home to sell first?",
        "type": "single",
        "options": [
          {
            "label": "No",
            "points": 30
          },
          {
            "label": "Yes, and it is on the market",
            "points": 20
          },
          {
            "label": "Yes, not listed yet",
            "points": 5
          }
        ]
      }
    ],
    "results": {
      "hot": {
        "heading": "You are ready to buy",
        "text": "Your timing and financing are in place. I will be in touch shortly to line up viewings."
      },
      "warm": {
        "heading": "You are getting close",
        "text": "A step or two is still open, usually financing or timing. I will follow up with what to do next."
      },
      "nurture": {
        "heading": "You are at the start",
        "text": "There is no rush. Reach out whenever you want to talk it through."
      }
    },
    "status": "live"
  }'
```

Answer (`201`):

```json
{
  "id": "0f6f4f0a-3c58-4b0e-9b7e-6f6c2f2a7d55",
  "object": "survey",
  "slug": "ready-to-buy",
  "status": "live",
  "title": "Are you ready to buy in Halifax?",
  "intro": "Three quick questions about your timing and your financing.",
  "questions": [
    {
      "id": "r53jk9om",
      "text": "When would you like to move?",
      "help": "",
      "type": "single",
      "maps": "",
      "options": [
        {
          "id": "y179w03u",
          "label": "Within 3 months",
          "points": 35,
          "value": ""
        },
        {
          "id": "711toz20",
          "label": "In 3 to 6 months",
          "points": 25,
          "value": ""
        },
        {
          "id": "p0d3n7ra",
          "label": "In 6 to 12 months",
          "points": 10,
          "value": ""
        },
        {
          "id": "b6t1w9ze",
          "label": "Just looking for now",
          "points": 0,
          "value": ""
        }
      ]
    },
    {
      "...": "two more questions"
    }
  ],
  "results": {
    "hot": {
      "heading": "You are ready to buy",
      "text": "Your timing and financing are in place. I will be in touch shortly to line up viewings."
    },
    "warm": {
      "heading": "You are getting close",
      "text": "A step or two is still open, usually financing or timing. I will follow up with what to do next."
    },
    "nurture": {
      "heading": "You are at the start",
      "text": "There is no rush. Reach out whenever you want to talk it through."
    }
  },
  "form": {
    "button_label": "Start",
    "contact_heading": "Almost done. Where can I reach you?",
    "contact_text": "Enter your details to see your result.",
    "ask_phone": true,
    "require_phone": false
  },
  "max_score": 100,
  "bands": {
    "hot": 80,
    "warm": 50
  },
  "stats": {
    "starts": 41,
    "responses": 28,
    "hot": 9,
    "warm": 12,
    "nurture": 7
  },
  "embed_html": "<div data-rf-survey=\"ready-to-buy\"></div>",
  "created_at": "2026-11-12T15:04:05.000Z",
  "updated_at": "2026-11-12T15:04:05.000Z"
}
```

Can be refused with: `bad_survey` (400), `too_many_surveys` (409), `tool_off` (403).

### List surveys

`GET /v1/surveys`

Every survey on the account, each in full with its count of starts and finished responses.

```bash
curl https://api.realtorflow.ca/v1/surveys \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "list",
  "data": [
    {
      "id": "0f6f4f0a-3c58-4b0e-9b7e-6f6c2f2a7d55",
      "object": "survey",
      "slug": "ready-to-buy",
      "status": "live",
      "title": "Are you ready to buy in Halifax?",
      "intro": "Three quick questions about your timing and your financing.",
      "questions": [
        {
          "id": "r53jk9om",
          "text": "When would you like to move?",
          "help": "",
          "type": "single",
          "maps": "",
          "options": [
            {
              "id": "y179w03u",
              "label": "Within 3 months",
              "points": 35,
              "value": ""
            },
            {
              "id": "711toz20",
              "label": "In 3 to 6 months",
              "points": 25,
              "value": ""
            },
            {
              "id": "p0d3n7ra",
              "label": "In 6 to 12 months",
              "points": 10,
              "value": ""
            },
            {
              "id": "b6t1w9ze",
              "label": "Just looking for now",
              "points": 0,
              "value": ""
            }
          ]
        },
        {
          "...": "two more questions"
        }
      ],
      "results": {
        "hot": {
          "heading": "You are ready to buy",
          "text": "Your timing and financing are in place. I will be in touch shortly to line up viewings."
        },
        "warm": {
          "heading": "You are getting close",
          "text": "A step or two is still open, usually financing or timing. I will follow up with what to do next."
        },
        "nurture": {
          "heading": "You are at the start",
          "text": "There is no rush. Reach out whenever you want to talk it through."
        }
      },
      "form": {
        "button_label": "Start",
        "contact_heading": "Almost done. Where can I reach you?",
        "contact_text": "Enter your details to see your result.",
        "ask_phone": true,
        "require_phone": false
      },
      "max_score": 100,
      "bands": {
        "hot": 80,
        "warm": 50
      },
      "stats": {
        "starts": 41,
        "responses": 28,
        "hot": 9,
        "warm": 12,
        "nurture": 7
      },
      "embed_html": "<div data-rf-survey=\"ready-to-buy\"></div>",
      "created_at": "2026-11-12T15:04:05.000Z",
      "updated_at": "2026-11-12T15:04:05.000Z"
    }
  ],
  "page": 1,
  "has_more": false
}
```

### Get a survey

`GET /v1/surveys/{id}`

By its id or by its slug. This is the owner's view, points included: call it from your server, never from a page.

```bash
curl https://api.realtorflow.ca/v1/surveys/ready-to-buy \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "0f6f4f0a-3c58-4b0e-9b7e-6f6c2f2a7d55",
  "object": "survey",
  "slug": "ready-to-buy",
  "status": "live",
  "title": "Are you ready to buy in Halifax?",
  "intro": "Three quick questions about your timing and your financing.",
  "questions": [
    {
      "id": "r53jk9om",
      "text": "When would you like to move?",
      "help": "",
      "type": "single",
      "maps": "",
      "options": [
        {
          "id": "y179w03u",
          "label": "Within 3 months",
          "points": 35,
          "value": ""
        },
        {
          "id": "711toz20",
          "label": "In 3 to 6 months",
          "points": 25,
          "value": ""
        },
        {
          "id": "p0d3n7ra",
          "label": "In 6 to 12 months",
          "points": 10,
          "value": ""
        },
        {
          "id": "b6t1w9ze",
          "label": "Just looking for now",
          "points": 0,
          "value": ""
        }
      ]
    },
    {
      "...": "two more questions"
    }
  ],
  "results": {
    "hot": {
      "heading": "You are ready to buy",
      "text": "Your timing and financing are in place. I will be in touch shortly to line up viewings."
    },
    "warm": {
      "heading": "You are getting close",
      "text": "A step or two is still open, usually financing or timing. I will follow up with what to do next."
    },
    "nurture": {
      "heading": "You are at the start",
      "text": "There is no rush. Reach out whenever you want to talk it through."
    }
  },
  "form": {
    "button_label": "Start",
    "contact_heading": "Almost done. Where can I reach you?",
    "contact_text": "Enter your details to see your result.",
    "ask_phone": true,
    "require_phone": false
  },
  "max_score": 100,
  "bands": {
    "hot": 80,
    "warm": 50
  },
  "stats": {
    "starts": 41,
    "responses": 28,
    "hot": 9,
    "warm": 12,
    "nurture": 7
  },
  "embed_html": "<div data-rf-survey=\"ready-to-buy\"></div>",
  "created_at": "2026-11-12T15:04:05.000Z",
  "updated_at": "2026-11-12T15:04:05.000Z"
}
```

In the answer:

- `slug` (string): The short name used in the tag and in addresses.
- `status` (string): `draft`, `live` or `archived`.
- `questions` (array): See [the shape of a question](https://api.realtorflow.ca/#survey-shape). Every question and answer has an `id`.
- `results` (object): The three result messages.
- `form` (object): The wording of the contact step and whether a phone number is asked for.
- `max_score` (number): What the strongest answers add up to. It should be 100; when it is not, `warnings` says so.
- `bands` (object): Where hot and warm begin: 80 and 50.
- `stats` (object): How many people started, how many finished, and how the finished ones were sorted.
- `embed_html` (string): The tag that shows this survey on a page that has [the pasted line](https://api.realtorflow.ca/#script).

Can be refused with: `not_found` (404).

### Change a survey

`PATCH /v1/surveys/{id}`

Send only what changes. `questions` replaces the whole list: keep the `id` of each question and answer you keep, so the answers already collected still line up.

Send:

- `title` (string): The survey's name, shown at its top. Up to 140 characters.
- `intro` (string): A sentence or two under the title. Up to 600 characters.
- `questions` (array): Up to 12 questions. See [the shape of a question](https://api.realtorflow.ca/#survey-shape).
- `results` (object): What a person reads at the end: `hot`, `warm` and `nurture`, each a `heading` and `text`.
- `form` (object): The contact step: `button_label`, `contact_heading`, `contact_text`, `ask_phone` and `require_phone`.
- `status` (string): `draft` (the default), `live` or `archived`. A live survey has to be complete.

```bash
curl -X PATCH https://api.realtorflow.ca/v1/surveys/ready-to-buy \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "archived"
  }'
```

Answer (`200`):

```json
{
  "id": "0f6f4f0a-3c58-4b0e-9b7e-6f6c2f2a7d55",
  "object": "survey",
  "slug": "ready-to-buy",
  "status": "archived",
  "title": "Are you ready to buy in Halifax?",
  "intro": "Three quick questions about your timing and your financing.",
  "questions": [
    {
      "id": "r53jk9om",
      "text": "When would you like to move?",
      "help": "",
      "type": "single",
      "maps": "",
      "options": [
        {
          "id": "y179w03u",
          "label": "Within 3 months",
          "points": 35,
          "value": ""
        },
        {
          "id": "711toz20",
          "label": "In 3 to 6 months",
          "points": 25,
          "value": ""
        },
        {
          "id": "p0d3n7ra",
          "label": "In 6 to 12 months",
          "points": 10,
          "value": ""
        },
        {
          "id": "b6t1w9ze",
          "label": "Just looking for now",
          "points": 0,
          "value": ""
        }
      ]
    },
    {
      "...": "two more questions"
    }
  ],
  "results": {
    "hot": {
      "heading": "You are ready to buy",
      "text": "Your timing and financing are in place. I will be in touch shortly to line up viewings."
    },
    "warm": {
      "heading": "You are getting close",
      "text": "A step or two is still open, usually financing or timing. I will follow up with what to do next."
    },
    "nurture": {
      "heading": "You are at the start",
      "text": "There is no rush. Reach out whenever you want to talk it through."
    }
  },
  "form": {
    "button_label": "Start",
    "contact_heading": "Almost done. Where can I reach you?",
    "contact_text": "Enter your details to see your result.",
    "ask_phone": true,
    "require_phone": false
  },
  "max_score": 100,
  "bands": {
    "hot": 80,
    "warm": 50
  },
  "stats": {
    "starts": 41,
    "responses": 28,
    "hot": 9,
    "warm": 12,
    "nurture": 7
  },
  "embed_html": "<div data-rf-survey=\"ready-to-buy\"></div>",
  "created_at": "2026-11-12T15:04:05.000Z",
  "updated_at": "2026-11-12T15:04:05.000Z"
}
```

Can be refused with: `bad_survey` (400), `not_found` (404).

### Delete a survey

`DELETE /v1/surveys/{id}`

The leads it brought in stay in the inbox.

```bash
curl -X DELETE https://api.realtorflow.ca/v1/surveys/ready-to-buy \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "0f6f4f0a-3c58-4b0e-9b7e-6f6c2f2a7d55",
  "object": "survey",
  "deleted": true
}
```

Can be refused with: `not_found` (404).

### Send a finished survey

`POST /v1/surveys/{id}/responses`

For a site that draws the questions itself instead of using the pasted line. Send what the person chose and how to reach them. The score is worked out on our side, the lead lands in the account's inbox with its answers, the alert email goes out and any follow-up the account has set up starts.

Show the person `result`. Keep `score` and `temperature` on your server: they are for the agent, not for the visitor.

Send:

- `answers` (object, required): Each question's `id` with the `id` of the answer chosen, or a list of ids where several can be chosen.
- `name` (string, required): The person's name.
- `email` (string, required): A valid email address.
- `phone` (string): Required when the survey's form has `require_phone` set.
- `page_url` (string): The page the survey was taken on.
- `utm_source` (string): Also `utm_medium` and `utm_campaign`: where the visitor came from, kept with the lead.

```bash
curl -X POST https://api.realtorflow.ca/v1/surveys/ready-to-buy/responses \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "answers": {
      "r53jk9om": "y179w03u",
      "wunt43tg": "i5psby4o",
      "k2m8xq4d": "q3f7j2nb"
    },
    "name": "Dana Whitfield",
    "email": "dana@example.com",
    "phone": "902-555-0142",
    "page_url": "https://www.example.com/ready-to-buy"
  }'
```

Answer (`201`):

```json
{
  "object": "survey_response",
  "lead_id": "7c1d7f52-2a3e-4d8a-8a55-0d7c3f1e9b20",
  "score": 100,
  "temperature": "hot",
  "result": {
    "heading": "You are ready to buy",
    "text": "Your timing and financing are in place. I will be in touch shortly to line up viewings.",
    "button": null
  },
  "created_at": "2026-11-12T15:09:41.000Z"
}
```

In the answer:

- `lead_id` (string): The lead this response became.
- `score` (number): The points of the answers chosen.
- `temperature` (string): `hot` at 80 points or more, `warm` at 50 or more, otherwise `nurture`.
- `result` (object): The message for this person: `heading` and `text`.

Can be refused with: `bad_response` (400), `survey_not_live` (409), `not_found` (404).

## Media

The account's photo library: upload a photo once and it is sized for every screen and served from Realtor Flow, ready for any page and for listings.

The library is the one in the dashboard: photos uploaded there are read here, and photos uploaded here can be picked there.

### Upload a photo

`POST /v1/media`

Adds a photo to the account's media library: send the file itself as a form upload, or a web address to fetch it from. It is turned into WebP at several widths (480, 960, 1600 and 2400 pixels, never wider than the original) and served from Realtor Flow for good, so the addresses work on any website.

The answer has the addresses of every size, a `srcset`, and `html`: an `<img>` ready to paste, with the width and height set so the page does not jump while it loads. Write `alt` as a short description of what the photo shows: it is read out to people who cannot see it and read by search engines.

JPEG, PNG, WebP, AVIF, HEIC, GIF or TIFF; up to 20 MB as a file, 15 MB from a web address. Nothing here uses AI, so photos are not counted against the month's allowance.

Send:

- `file` (file): The photo, as a `multipart/form-data` upload with this field name. Send this or `url`.
- `url` (string): A web address to fetch the photo from (JSON body). Send this or `file`.
- `alt` (string): What the photo shows, in a short sentence. Up to 300 characters.

```bash
curl -X POST https://api.realtorflow.ca/v1/media \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://www.example.com/photos/harbour-at-dusk.jpg",
    "alt": "The harbour at dusk, seen from the condo balcony"
  }'
```

Answer (`201`):

```json
{
  "id": "a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48",
  "object": "media",
  "filename": "harbour-at-dusk.jpg",
  "width": 2000,
  "height": 1200,
  "bytes": 1843921,
  "alt": "The harbour at dusk, seen from the condo balcony",
  "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp",
  "sizes": [
    {
      "width": 480,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp"
    },
    {
      "width": 960,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp"
    },
    {
      "width": 1600,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp"
    },
    {
      "width": 2000,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp"
    }
  ],
  "srcset": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp 480w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp 960w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp 1600w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp 2000w",
  "html": "<img src=\"https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp\" srcset=\"https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp 480w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp 960w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp 1600w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp 2000w\" sizes=\"(max-width: 960px) 100vw, 960px\" width=\"2000\" height=\"1200\" alt=\"The harbour at dusk, seen from the condo balcony\" loading=\"lazy\" decoding=\"async\">",
  "created_at": "2026-11-13T19:42:10.000Z"
}
```

Can be refused with: `no_photo` (400), `bad_photo` (400), `bad_upload` (400), `file_too_large` (413), `bad_alt` (400), `media_full` (409).

To send the file itself, use a form upload instead of JSON:

curl:

```bash
curl -X POST https://api.realtorflow.ca/v1/media \
  -H "Authorization: Bearer $RF_KEY" \
  -F "file=@harbour-at-dusk.jpg" \
  -F "alt=The harbour at dusk, seen from the condo balcony"
```

JavaScript (Node 18 or later):

```js
import { readFile } from 'node:fs/promises';

const form = new FormData();
form.append('file', new Blob([await readFile('harbour-at-dusk.jpg')]), 'harbour-at-dusk.jpg');
form.append('alt', 'The harbour at dusk, seen from the condo balcony');
const res = await fetch('https://api.realtorflow.ca/v1/media', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.RF_KEY}` },
  body: form,
});
const media = await res.json();
```

To put a library photo on a listing, send `{ "media": "<id>" }` in the listing's `photos`.

### List photos

`GET /v1/media`

The account's media library, newest first: photos uploaded here, in the dashboard, or fetched for listings.

In the address:

- `limit` (number): Up to 200. The default is 50.
- `page` (number): Which page of results, from 1.

```bash
curl https://api.realtorflow.ca/v1/media \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "list",
  "data": [
    {
      "id": "a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48",
      "object": "media",
      "filename": "harbour-at-dusk.jpg",
      "width": 2000,
      "height": 1200,
      "bytes": 1843921,
      "alt": "The harbour at dusk, seen from the condo balcony",
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp",
      "sizes": [
        {
          "width": 480,
          "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp"
        },
        {
          "width": 960,
          "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp"
        },
        {
          "width": 1600,
          "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp"
        },
        {
          "width": 2000,
          "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp"
        }
      ],
      "srcset": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp 480w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp 960w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp 1600w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp 2000w",
      "html": "<img src=\"https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp\" srcset=\"https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp 480w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp 960w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp 1600w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp 2000w\" sizes=\"(max-width: 960px) 100vw, 960px\" width=\"2000\" height=\"1200\" alt=\"The harbour at dusk, seen from the condo balcony\" loading=\"lazy\" decoding=\"async\">",
      "created_at": "2026-11-13T19:42:10.000Z"
    }
  ],
  "page": 1,
  "has_more": false
}
```

Can be refused with: .

### Get a photo

`GET /v1/media/{id}`

One photo by its `id`, with `used_in`: the listings, pages, articles and other places on the account that show it.

```bash
curl https://api.realtorflow.ca/v1/media/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48 \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48",
  "object": "media",
  "filename": "harbour-at-dusk.jpg",
  "width": 2000,
  "height": 1200,
  "bytes": 1843921,
  "alt": "The harbour at dusk, seen from the condo balcony",
  "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp",
  "sizes": [
    {
      "width": 480,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp"
    },
    {
      "width": 960,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp"
    },
    {
      "width": 1600,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp"
    },
    {
      "width": 2000,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp"
    }
  ],
  "srcset": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp 480w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp 960w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp 1600w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp 2000w",
  "html": "<img src=\"https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp\" srcset=\"https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp 480w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp 960w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp 1600w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp 2000w\" sizes=\"(max-width: 960px) 100vw, 960px\" width=\"2000\" height=\"1200\" alt=\"The harbour at dusk, seen from the condo balcony\" loading=\"lazy\" decoding=\"async\">",
  "created_at": "2026-11-13T19:42:10.000Z",
  "used_in": [
    {
      "kind": "listing",
      "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
      "name": "Waterfront condo"
    }
  ]
}
```

Can be refused with: `not_found` (404).

### Change a photo's alt text

`PATCH /v1/media/{id}`

Changes the words that describe the photo. A listing keeps the alt text it was given when the photo was added to it.

Send:

- `alt` (string, required): Up to 300 characters. An empty string clears it.

```bash
curl -X PATCH https://api.realtorflow.ca/v1/media/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48 \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "alt": "The harbour at dusk from the balcony of unit 1204"
  }'
```

Answer (`200`):

```json
{
  "id": "a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48",
  "object": "media",
  "filename": "harbour-at-dusk.jpg",
  "width": 2000,
  "height": 1200,
  "bytes": 1843921,
  "alt": "The harbour at dusk from the balcony of unit 1204",
  "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp",
  "sizes": [
    {
      "width": 480,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp"
    },
    {
      "width": 960,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp"
    },
    {
      "width": 1600,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp"
    },
    {
      "width": 2000,
      "url": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp"
    }
  ],
  "srcset": "https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp 480w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp 960w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp 1600w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp 2000w",
  "html": "<img src=\"https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp\" srcset=\"https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/480.webp 480w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/960.webp 960w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/1600.webp 1600w, https://api.realtorflow.ca/media/4f0e8a2c-6b1d-4c3e-9a7f-2d5b8c1e0f63/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48/2000.webp 2000w\" sizes=\"(max-width: 960px) 100vw, 960px\" width=\"2000\" height=\"1200\" alt=\"The harbour at dusk, seen from the condo balcony\" loading=\"lazy\" decoding=\"async\">",
  "created_at": "2026-11-13T19:42:10.000Z"
}
```

Can be refused with: `nothing_to_change` (400), `bad_alt` (400), `not_found` (404).

### Delete a photo

`DELETE /v1/media/{id}`

Removes the photo and every size of it; its addresses stop working. Refused while anything on the account still shows it, so no page is left with a missing photo.

```bash
curl -X DELETE https://api.realtorflow.ca/v1/media/a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48 \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "a3d9e6f1-2c4b-4e8a-b7d5-9f1c3e2a6b48",
  "object": "media",
  "deleted": true
}
```

Can be refused with: `media_in_use` (409), `not_found` (404).

## Listings

The account's homes for sale and for rent, the same ones a Realtor Flow site shows. Keep them here from your own system, and show them with the pasted line or with your own pages.

A listing added here also appears in the account's dashboard, and one added in the dashboard is read here.

### List listings

`GET /v1/listings`

The account's listings, featured ones first, then the newest. Each comes in the short form, with its first photo.

In the address:

- `status` (string): Only listings with this status.
- `listing_type` (string): `sale` or `rent`.
- `limit` (number): Up to 100. The default is 20.
- `page` (number): Which page of results, from 1.

```bash
curl "https://api.realtorflow.ca/v1/listings?status=active&limit=20" \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "list",
  "data": [
    {
      "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
      "object": "listing",
      "slug": "brick-duplex-near-the-river",
      "status": "active",
      "title": "Brick duplex near the river",
      "listing_type": "sale",
      "property_type": "Duplex",
      "price": 899000,
      "currency": "CAD",
      "bedrooms": 5,
      "bathrooms": 2,
      "area": 2100,
      "area_unit": "sqft",
      "city": "Halifax",
      "region": "NS",
      "is_featured": false,
      "photos": [
        {
          "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/1600.webp",
          "width": 1600,
          "height": 1067,
          "alt": "Front of the duplex",
          "sizes": [
            {
              "width": 480,
              "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/480.webp"
            },
            {
              "width": 960,
              "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/960.webp"
            },
            {
              "width": 1600,
              "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/1600.webp"
            }
          ]
        }
      ],
      "created_at": "2026-11-13T14:34:59.409Z",
      "updated_at": "2026-11-13T14:34:59.409Z"
    }
  ],
  "page": 1,
  "has_more": false
}
```

Can be refused with: `tool_off` (403).

### Get a listing

`GET /v1/listings/{id}`

One listing in full, by its `id` or its `slug`, with its search markup. Put `json_ld` in a `<script type="application/ld+json">` on the listing's page.

In the address:

- `page_url` (string): The address of the page the listing is on, so the markup carries it.

```bash
curl "https://api.realtorflow.ca/v1/listings/brick-duplex-near-the-river?page_url=https://www.example.com/homes/brick-duplex" \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
  "object": "listing",
  "slug": "brick-duplex-near-the-river",
  "status": "active",
  "title": "Brick duplex near the river",
  "listing_type": "sale",
  "property_type": "Duplex",
  "price": 899000,
  "currency": "CAD",
  "bedrooms": 5,
  "bathrooms": 2,
  "area": 2100,
  "area_unit": "sqft",
  "city": "Halifax",
  "region": "NS",
  "is_featured": false,
  "photos": [
    {
      "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/1600.webp",
      "width": 1600,
      "height": 1067,
      "alt": "Front of the duplex",
      "sizes": [
        {
          "width": 480,
          "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/480.webp"
        },
        {
          "width": 960,
          "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/960.webp"
        },
        {
          "width": 1600,
          "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/1600.webp"
        }
      ]
    }
  ],
  "created_at": "2026-11-13T14:34:59.409Z",
  "updated_at": "2026-11-13T14:34:59.409Z",
  "description": "Two full units on a quiet street, each with its own entrance.\n\nA detached garage and a private terrace.",
  "lot_size": null,
  "year_built": 1928,
  "address": "1234 Example Street",
  "country": "CA",
  "mls_number": null,
  "features": [
    "Garage",
    "Terrace"
  ],
  "meta_title": null,
  "meta_description": null,
  "json_ld": {
    "@context": "https://schema.org",
    "@type": "RealEstateListing",
    "name": "Brick duplex near the river",
    "url": "https://www.example.com/homes/brick-duplex",
    "mainEntityOfPage": "https://www.example.com/homes/brick-duplex",
    "datePosted": "2026-11-13T14:34:59.409Z",
    "dateModified": "2026-11-13T14:34:59.409Z",
    "mainEntity": {
      "@type": "Accommodation",
      "name": "Brick duplex near the river",
      "address": {
        "@type": "PostalAddress",
        "streetAddress": "1234 Example Street",
        "addressLocality": "Halifax",
        "addressRegion": "NS",
        "addressCountry": "CA"
      },
      "numberOfBedrooms": 5,
      "numberOfBathroomsTotal": 2,
      "floorSize": {
        "@type": "QuantitativeValue",
        "value": 2100,
        "unitCode": "FTK"
      },
      "yearBuilt": 1928,
      "...": "..."
    },
    "provider": {
      "@type": "RealEstateAgent",
      "name": "Harbour Realty"
    },
    "image": [
      "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/1600.webp"
    ],
    "offers": {
      "@type": "Offer",
      "price": 899000,
      "priceCurrency": "CAD"
    }
  }
}
```

In the answer:

- `status` (string): `active` (shown), `pending`, `sold`, `draft` or `archived` (never shown).
- `listing_type` (string): `sale` or `rent`. For a rental, `price` is per month.
- `property_type` (string): House, Condo, Townhouse, Duplex, Triplex, Multiplex, Cottage, Land, Commercial or Other.
- `price` (number): In `currency`. `null` means price on request.
- `area` (number): Living area in `area_unit`: `sqft` or `m2`, as the account is set.
- `photos` (array): Each photo stored at several widths: `url` is the largest, `sizes` lists them all for a `srcset`. The addresses work on any website.
- `json_ld` (object): The listing's search markup (`RealEstateListing`) for the page it is on. Ask with `page_url` to have that address in it.

Can be refused with: `not_found` (404), `tool_off` (403).

### Add a listing

`POST /v1/listings`

Photos are sent as web addresses. Each one is fetched once, stored and sized like a photo uploaded in the dashboard, so it no longer depends on where it came from. A call with many photos can take some seconds.

Nothing here uses AI, so listings are not counted against the month's allowance.

Send:

- `title` (string, required): Up to 200 characters.
- `description` (string): Plain text. A blank line starts a new paragraph. Up to 12,000 characters.
- `listing_type` (string): `sale` (the default) or `rent`.
- `property_type` (string): One of the types above, in any capitals.
- `price` (number): Leave it out for price on request.
- `currency` (string): `CAD` (the default), `USD` or `EUR`.
- `bedrooms` (number): Also `bathrooms`, `area`, `lot_size` and `year_built`.
- `address` (string): Also `city`, `region` and `country`.
- `features` (array): Short lines such as "Garage". Up to 40.
- `photos` (array): Up to 30 photos: web addresses, `{ "url", "alt" }`, or `{ "media", "alt" }` for a photo already in the [media library](https://api.realtorflow.ca/#media). A web address is fetched once and stored (JPEG, PNG or WebP, up to 15 MB). A library photo is used as it is and stays in the library if the listing drops it later.
- `status` (string): `active` (the default here), `pending`, `sold`, `draft` or `archived`.
- `mls_number` (string): Shown only when sent.

```bash
curl -X POST https://api.realtorflow.ca/v1/listings \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Brick duplex near the river",
    "listing_type": "sale",
    "property_type": "Duplex",
    "price": 899000,
    "bedrooms": 5,
    "bathrooms": 2,
    "area": 2100,
    "address": "1234 Example Street",
    "city": "Halifax",
    "region": "NS",
    "features": [
      "Garage",
      "Terrace"
    ],
    "photos": [
      {
        "url": "https://www.example.com/photos/duplex-front.jpg",
        "alt": "Front of the duplex"
      }
    ]
  }'
```

Answer (`201`):

```json
{
  "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
  "object": "listing",
  "slug": "brick-duplex-near-the-river",
  "status": "active",
  "title": "Brick duplex near the river",
  "listing_type": "sale",
  "property_type": "Duplex",
  "price": 899000,
  "currency": "CAD",
  "bedrooms": 5,
  "bathrooms": 2,
  "area": 2100,
  "area_unit": "sqft",
  "city": "Halifax",
  "region": "NS",
  "is_featured": false,
  "photos": [
    {
      "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/1600.webp",
      "width": 1600,
      "height": 1067,
      "alt": "Front of the duplex",
      "sizes": [
        {
          "width": 480,
          "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/480.webp"
        },
        {
          "width": 960,
          "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/960.webp"
        },
        {
          "width": 1600,
          "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/1600.webp"
        }
      ]
    }
  ],
  "created_at": "2026-11-13T14:34:59.409Z",
  "updated_at": "2026-11-13T14:34:59.409Z",
  "description": "Two full units on a quiet street, each with its own entrance.\n\nA detached garage and a private terrace.",
  "lot_size": null,
  "year_built": 1928,
  "address": "1234 Example Street",
  "country": "CA",
  "mls_number": null,
  "features": [
    "Garage",
    "Terrace"
  ],
  "meta_title": null,
  "meta_description": null,
  "json_ld": {
    "@context": "https://schema.org",
    "@type": "RealEstateListing",
    "name": "Brick duplex near the river",
    "datePosted": "2026-11-13T14:34:59.409Z",
    "dateModified": "2026-11-13T14:34:59.409Z",
    "mainEntity": {
      "@type": "Accommodation",
      "name": "Brick duplex near the river",
      "address": {
        "@type": "PostalAddress",
        "streetAddress": "1234 Example Street",
        "addressLocality": "Halifax",
        "addressRegion": "NS",
        "addressCountry": "CA"
      },
      "numberOfBedrooms": 5,
      "numberOfBathroomsTotal": 2,
      "floorSize": {
        "@type": "QuantitativeValue",
        "value": 2100,
        "unitCode": "FTK"
      },
      "yearBuilt": 1928,
      "...": "..."
    },
    "provider": {
      "@type": "RealEstateAgent",
      "name": "Harbour Realty"
    },
    "image": [
      "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/1600.webp"
    ],
    "offers": {
      "@type": "Offer",
      "price": 899000,
      "priceCurrency": "CAD"
    }
  }
}
```

Can be refused with: `bad_listing` (400), `bad_photo` (400), `too_many_photos` (400), `too_many_listings` (409), `tool_off` (403).

### Change a listing

`PATCH /v1/listings/{id}`

Send only what changes. `photos` replaces the whole set; the photos it no longer uses are removed. Without `photos`, the listing keeps the ones it has. Marking a home sold is `{ "status": "sold" }`.

Send:

- `title` (string): Up to 200 characters.
- `description` (string): Plain text. A blank line starts a new paragraph. Up to 12,000 characters.
- `listing_type` (string): `sale` (the default) or `rent`.
- `property_type` (string): One of the types above, in any capitals.
- `price` (number): Leave it out for price on request.
- `currency` (string): `CAD` (the default), `USD` or `EUR`.
- `bedrooms` (number): Also `bathrooms`, `area`, `lot_size` and `year_built`.
- `address` (string): Also `city`, `region` and `country`.
- `features` (array): Short lines such as "Garage". Up to 40.
- `photos` (array): Up to 30 photos: web addresses, `{ "url", "alt" }`, or `{ "media", "alt" }` for a photo already in the [media library](https://api.realtorflow.ca/#media). A web address is fetched once and stored (JPEG, PNG or WebP, up to 15 MB). A library photo is used as it is and stays in the library if the listing drops it later.
- `status` (string): `active` (the default here), `pending`, `sold`, `draft` or `archived`.
- `mls_number` (string): Shown only when sent.

```bash
curl -X PATCH https://api.realtorflow.ca/v1/listings/brick-duplex-near-the-river \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "price": 879000,
    "status": "pending"
  }'
```

Answer (`200`):

```json
{
  "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
  "object": "listing",
  "slug": "brick-duplex-near-the-river",
  "status": "pending",
  "title": "Brick duplex near the river",
  "listing_type": "sale",
  "property_type": "Duplex",
  "price": 879000,
  "currency": "CAD",
  "bedrooms": 5,
  "bathrooms": 2,
  "area": 2100,
  "area_unit": "sqft",
  "city": "Halifax",
  "region": "NS",
  "is_featured": false,
  "photos": [
    {
      "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/1600.webp",
      "width": 1600,
      "height": 1067,
      "alt": "Front of the duplex",
      "sizes": [
        {
          "width": 480,
          "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/480.webp"
        },
        {
          "width": 960,
          "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/960.webp"
        },
        {
          "width": 1600,
          "url": "https://api.realtorflow.ca/media/3b8f6a2e-7c1d-4e5a-9f20-6d1c8b4a7e13/a41c9e7b-2f6d-4b8e-91c3-5e7a0d2f6b84/1600.webp"
        }
      ]
    }
  ],
  "created_at": "2026-11-13T14:34:59.409Z",
  "updated_at": "2026-11-13T14:34:59.409Z",
  "description": "Two full units on a quiet street, each with its own entrance.\n\nA detached garage and a private terrace.",
  "lot_size": null,
  "year_built": 1928,
  "address": "1234 Example Street",
  "country": "CA",
  "mls_number": null,
  "features": [
    "Garage",
    "Terrace"
  ],
  "meta_title": null,
  "meta_description": null,
  "json_ld": "..."
}
```

Can be refused with: `not_found` (404), `bad_listing` (400), `bad_photo` (400), `too_many_photos` (400), `tool_off` (403).

### Delete a listing

`DELETE /v1/listings/{id}`

The listing and its photos are removed. Leads that asked about it stay in the inbox.

```bash
curl -X DELETE https://api.realtorflow.ca/v1/listings/brick-duplex-near-the-river \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
  "object": "listing",
  "deleted": true
}
```

Can be refused with: `not_found` (404), `tool_off` (403).

## Articles and translation

Writing for your own pages, with the same rules Realtor Flow sites follow: only the facts you gave, every figure checked.

### Write an article from a draft

`POST /v1/articles/draft`

Send a rough draft: notes, bullet points, a transcript, an old blog post. A clear article comes back: a headline, a short answer up top, key points, sections with question headings, common questions and a closing line, plus a search title and description, ready HTML and its markup.

Only what is in the draft and in the account's business details is used. No figure, price, rule, name or quotation is added; a thin draft makes a short article. `gaps` lists what a reader would expect that the draft does not give, for the writer to add. `check` lists any figure or quotation in the article that is not in the draft word for word: read those before publishing.

Nothing is published or saved. The call answers when the article is written, which can take up to a minute. One article is counted against the month's allowance; a draft that fails is not counted.

Send:

- `source` (string, required): The draft. 200 to 20,000 characters, in any of the five languages; the article is written in the account's language.
- `title` (string): A working title, kept when it is a good one.
- `page_url` (string): Where the article will be, so the markup carries its address.

```bash
curl -X POST https://api.realtorflow.ca/v1/articles/draft \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "What to check before an offer",
    "source": "Before you make an offer in the harbour district, look at the roof and ask how old it is. Read the condo minutes for the last two years, because they show what repairs are coming. Ask what the seller is leaving behind, such as appliances and window coverings.",
    "page_url": "https://www.example.com/blog/before-an-offer"
  }'
```

Answer (`200`):

```json
{
  "object": "article_draft",
  "title": "What to check before you make an offer in the harbour district",
  "excerpt": "Three things to check before an offer: the roof, the condo minutes and what the seller is leaving behind.",
  "meta_title": "What to check before an offer in the harbour district",
  "meta_description": "Before an offer in the harbour district, check the roof, read two years of condo minutes and ask what the seller is leaving behind.",
  "body": {
    "intro": "...",
    "points": [
      "..."
    ],
    "sections": [
      {
        "heading": "How old is the roof?",
        "text": "..."
      }
    ],
    "faq": [
      {
        "question": "...",
        "answer": "..."
      }
    ],
    "closing": "...",
    "button_label": "",
    "button_link": ""
  },
  "html": "<div class=\"rf-article\">\n<div class=\"rf-intro\"><p>...</p></div>\n<div class=\"rf-points\"><h2>In short</h2><ul><li>...</li></ul></div>\n<section class=\"rf-section\"><h2>How old is the roof?</h2><p>...</p></section>\n...\n</div>",
  "json_ld": [
    {
      "@context": "https://schema.org",
      "@type": "Article",
      "headline": "...",
      "url": "https://www.example.com/blog/before-an-offer",
      "...": "..."
    },
    {
      "@context": "https://schema.org",
      "@type": "FAQPage",
      "...": "..."
    }
  ],
  "gaps": [
    "The draft gives no prices for the area."
  ],
  "check": {
    "numbers": [],
    "quotes": []
  }
}
```

In the answer:

- `html` (string): The article, ready to put in a page, with no styles of its own. Class names: `rf-article`, `rf-intro`, `rf-points`, `rf-section`, `rf-faq`, `rf-closing`.
- `body` (object): The same article in parts, to lay out yourself.
- `gaps` (array): Notes for the writer only.
- `check` (object): `numbers` and `quotes` in the article that are not in the draft word for word.

Can be refused with: `draft_too_short` (400), `draft_too_long` (413), `limit_reached` (429), `try_later` (503), `ai_off` (503), `draft_failed` (502), `tool_off` (403).

### Translate

`POST /v1/translate`

Text from your site in English, French, Spanish, German or Chinese, translated as Realtor Flow sites translate theirs: faithfully, in a real estate agent's words, with the business's names kept as they are.

Every line is checked before it is handed back: figures, prices, phone numbers, email and web addresses, `{markers}` and the business's name must come through exactly, and the length must be reasonable. A line that fails comes back with `translation` set to `null` and the reason in `problem`, so it can be kept in the original language.

One translation is counted for each call, up to 20,000 characters; a call where nothing came back translated is not counted.

Send:

- `to` (string, required): `en`, `fr`, `es`, `de` or `zh`.
- `text` (string, required): One text. Or `texts`: a list of up to 200, answered in the same order.
- `from` (string): The language of the text. The default is the account's.

```bash
curl -X POST https://api.realtorflow.ca/v1/translate \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "fr",
    "texts": [
      "Three bedrooms near the harbour, listed at $649,000.",
      "Book a visit"
    ]
  }'
```

Answer (`200`):

```json
{
  "object": "translation",
  "from": "en",
  "to": "fr",
  "items": [
    {
      "text": "Three bedrooms near the harbour, listed at $649,000.",
      "translation": "Trois chambres près du port, à 649 000 $.",
      "problem": null
    },
    {
      "text": "Book a visit",
      "translation": "Planifier une visite",
      "problem": null
    }
  ],
  "translated": 2,
  "failed": 0
}
```

Can be refused with: `no_text` (400), `text_too_long` (413), `bad_language` (400), `limit_reached` (429), `try_later` (503), `ai_off` (503), `translation_failed` (502), `tool_off` (403).

## AI visibility and traffic

Being found and named by AI assistants: the files they read about the business, and how many visits they send.

### Build the AI visibility files

`POST /v1/visibility`

What AI assistants read about the business, built for your website's own address: a plain-text guide (`llms.txt`), the business as markup (`RealEstateAgent`) and the lines for `robots.txt` that welcome AI crawlers, following the account's choice about crawlers that collect pages to train models.

Everything comes from what the account already says about itself (its name, area, licence and contact details, set in the dashboard) and from the pages and listings you name. Nothing is looked up or added. Publish `llms_txt` at `/llms.txt`, put `json_ld` in a `<script type="application/ld+json">` on every page, and add `robots_txt` to your robots.txt (its sitemap line assumes `/sitemap.xml`).

Building them changes nothing, so it is not counted. Build them again when the listings or the details change.

Send:

- `origin` (string, required): The website's address, `https://www.example.com`. Only the address is kept, not a path.
- `listing_url` (string): How the website addresses a listing, with `{slug}`: `https://www.example.com/homes/{slug}`. Without it, listings are left out of the guide.
- `pages` (array): The website's main pages, up to 50: `{ "title", "url", "description" }`.

```bash
curl -X POST https://api.realtorflow.ca/v1/visibility \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "origin": "https://www.example.com",
    "listing_url": "https://www.example.com/homes/{slug}",
    "pages": [
      {
        "title": "Selling your home",
        "url": "https://www.example.com/sell",
        "description": "How I price and sell homes in Halifax."
      }
    ]
  }'
```

Answer (`200`):

```json
{
  "object": "visibility",
  "origin": "https://www.example.com",
  "llms_txt": "# Harbour Realty\n\n> The website of Harbour Realty. Area served: Halifax and Dartmouth. 12 homes for sale or rent are listed.\n\nLicensed with the Nova Scotia Real Estate Commission\n\nContact:\n\n- Phone: 902-555-0142\n- Email: hello@harbour.example\n\n## Pages\n\n- [Selling your home](https://www.example.com/sell): How I price and sell homes in Halifax.\n\n## Listings\n\n- [Waterfront condo](https://www.example.com/homes/waterfront-condo): Condo. For sale. Halifax, NS. Beds: 2. $649,000\n...",
  "listings_in_llms_txt": 12,
  "json_ld": {
    "@context": "https://schema.org",
    "@type": "RealEstateAgent",
    "name": "Harbour Realty",
    "url": "https://www.example.com",
    "telephone": "902-555-0142",
    "email": "hello@harbour.example",
    "areaServed": "Halifax and Dartmouth"
  },
  "robots_choice": "all",
  "robots_txt": "# AI crawlers are welcome on this site.\nUser-agent: OAI-SearchBot\nUser-agent: ChatGPT-User\nUser-agent: GPTBot\n...\nAllow: /\n\nUser-agent: *\nAllow: /\n\n# A plain-text guide to this site for AI assistants: https://www.example.com/llms.txt\nSitemap: https://www.example.com/sitemap.xml\n"
}
```

In the answer:

- `robots_choice` (string): `all` (every AI crawler welcome) or `no_training` (crawlers that answer questions or build AI search welcome, crawlers that collect pages to train models asked to stay away). Set in the dashboard.

Can be refused with: `bad_origin` (400), `bad_listing_url` (400), `bad_pages` (400), `tool_off` (403).

### AI traffic

`GET /v1/analytics`

The page views the pasted line counted (with `data-count="on"`) and, for a Realtor Flow site, its own: visits, visitors, where they came from and which AI assistants sent them. The same figures as the dashboard's **AI-Traffic Analytics**.

In the address:

- `days` (number): `7`, `30` (the default) or `90`.

```bash
curl "https://api.realtorflow.ca/v1/analytics?days=30" \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "analytics",
  "days": 30,
  "tz": "America/Toronto",
  "keep_days": 180,
  "totals": {
    "views": 1840,
    "visits": 1210,
    "visitors": 960,
    "ai": 64
  },
  "by_day": [
    {
      "day": "2026-11-13",
      "views": 71,
      "visits": 48,
      "ai": 3,
      "visitors": 40
    },
    {
      "...": "..."
    }
  ],
  "sources": {
    "ai": 64,
    "search": 610,
    "social": 140,
    "direct": 330,
    "other": 66
  },
  "pages": [
    {
      "path": "/homes/waterfront-condo",
      "views": 220,
      "ai": 11
    }
  ],
  "ai_referrers": [
    {
      "name": "ChatGPT",
      "n": 41
    },
    {
      "name": "Perplexity",
      "n": 15
    },
    {
      "name": "Gemini",
      "n": 8
    }
  ],
  "ai_pages": [
    {
      "path": "/neighbourhoods/verdun",
      "n": 9
    }
  ],
  "referrers": [],
  "campaigns": [],
  "crawlers": [],
  "crawled": [],
  "leads": {
    "on": true,
    "total": 22,
    "by": [
      {
        "utm_source": "chatgpt.com",
        "n": 2,
        "ai": true
      }
    ]
  }
}
```

In the answer:

- `totals.ai` (number): Visits that came from an AI assistant: a link in its answer, or its tag in the address.
- `ai_referrers` (array): Those visits by assistant.
- `crawlers` (array): AI crawlers seen on a Realtor Flow site, or reported with [Report AI crawler visits](https://api.realtorflow.ca/#post-crawls) (the WordPress plugin does). A pasted line cannot see crawlers, which do not run scripts.

Can be refused with: `tool_off` (403).

### Report AI crawler visits

`POST /v1/crawls`

For a website whose own server draws its pages: report the AI crawlers it saw (GPTBot, ClaudeBot, PerplexityBot and the others), so they appear in the account's AI traffic beside its visitors. A pasted line cannot do this, because crawlers do not run scripts. The [WordPress plugin](https://api.realtorflow.ca/#wordpress) calls this for you.

Send every visit you are unsure about: anything that is not a known AI crawler is dropped, so people are never counted here. Send them in batches, every few minutes, rather than one call per page.

Send:

- `hits` (array, required): 1 to 200 visits, each `{ "user_agent", "path", "at" }`: the visitor's user agent as your server received it, the page's path (starting with `/`), and when (ISO 8601, within the last two days; now when left out). A single visit can also be sent on its own, without `hits`.

```bash
curl -X POST https://api.realtorflow.ca/v1/crawls \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "hits": [
      {
        "user_agent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)",
        "path": "/homes/waterfront-condo",
        "at": "2026-11-13T18:02:11Z"
      },
      {
        "user_agent": "Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
        "path": "/llms.txt",
        "at": "2026-11-13T18:04:40Z"
      }
    ]
  }'
```

Answer (`200`):

```json
{
  "object": "crawls",
  "counted": 2,
  "dropped": 0,
  "crawlers": {
    "GPTBot": 1,
    "ClaudeBot": 1
  }
}
```

Can be refused with: `bad_crawls` (400), `tool_off` (403).

## Bookings

Short stays on the account's rentals: booking terms on a listing, requests from guests, and the broker's pipeline from enquiry to completed. No payment is taken.

Bookings made here are the ones in the dashboard's **Bookings & Reservations**, and the other way round.

### Take bookings on a listing

`PUT /v1/listings/{id}/booking`

Gives a listing its booking terms, or changes them. From then on, guests can ask for dates on it. `DELETE` on the same address stops it taking requests; the bookings already made stay.

Send:

- `nightly_rate` (number, required): In the listing's currency.
- `min_nights` (number, required): The shortest stay, from 1 to 90.
- `max_guests` (number, required): From 1 to 99.
- `is_on` (boolean): `false` keeps the terms but stops new requests for now.

```bash
curl -X PUT https://api.realtorflow.ca/v1/listings/beach-cottage/booking \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "nightly_rate": 180,
    "min_nights": 2,
    "max_guests": 4
  }'
```

Answer (`200`):

```json
{
  "object": "booking_terms",
  "listing": "beach-cottage",
  "nightly_rate": 180,
  "currency": "CAD",
  "min_nights": 2,
  "max_guests": 4,
  "taking_bookings": true
}
```

Can be refused with: `bad_terms` (400), `not_found` (404), `tool_off` (403).

### Check availability

`GET /v1/listings/{id}/availability`

The terms and the nights already held by confirmed or paid bookings, for a calendar on your own page. Only dates: never who booked.

```bash
curl https://api.realtorflow.ca/v1/listings/beach-cottage/availability \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "availability",
  "listing": "beach-cottage",
  "taking_bookings": true,
  "nightly_rate": 180,
  "currency": "CAD",
  "min_nights": 2,
  "max_guests": 4,
  "max_nights": 90,
  "booked": [
    {
      "check_in": "2026-12-26",
      "check_out": "2027-01-02"
    }
  ]
}
```

Can be refused with: `not_found` (404), `tool_off` (403).

### Send a booking request

`POST /v1/bookings`

A request from your own booking form. It is checked as on a Realtor Flow site (the dates, the shortest stay, the guests and the nights already held), saved as an `enquiry` with its estimate, and the broker's alert and the guest's receipt go out.

A request does not hold the nights. They are held once the broker confirms. No payment is taken anywhere in the tool kit.

Send:

- `listing` (string, required): The listing's `id` or `slug`.
- `check_in` (string, required): A date, `YYYY-MM-DD`. Not in the past, and within two years.
- `check_out` (string, required): A later date. Up to 90 nights.
- `guests` (number, required): Up to the listing's most guests.
- `name` (string, required): The guest's name.
- `email` (string, required): A valid email address. The receipt goes there.
- `phone` (string): Also `message`.

```bash
curl -X POST https://api.realtorflow.ca/v1/bookings \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "listing": "beach-cottage",
    "check_in": "2026-12-18",
    "check_out": "2026-12-21",
    "guests": 2,
    "name": "Dana Whitfield",
    "email": "dana@example.com",
    "phone": "902-555-0142",
    "message": "Arriving late on the first night."
  }'
```

Answer (`201`):

```json
{
  "id": "4e2a9c71-8b3d-4f6e-a0c5-7d19e2b6f348",
  "object": "booking",
  "status": "enquiry",
  "listing": {
    "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
    "slug": "beach-cottage",
    "title": "Beach cottage"
  },
  "check_in": "2026-12-18",
  "check_out": "2026-12-21",
  "nights": 3,
  "guests": 2,
  "name": "Dana Whitfield",
  "email": "dana@example.com",
  "phone": "902-555-0142",
  "message": "Arriving late on the first night.",
  "nightly_rate": 180,
  "currency": "CAD",
  "estimate": 540,
  "notes": null,
  "overlaps_confirmed": false,
  "created_at": "2026-11-13T17:20:05.000Z",
  "updated_at": "2026-11-13T17:20:05.000Z"
}
```

In the answer:

- `status` (string): `enquiry`, `confirmed`, `paid`, `completed` or `cancelled`.
- `estimate` (number): Nights times the nightly rate: an estimate, not a price.
- `overlaps_confirmed` (boolean): `true` when an enquiry asks for nights another booking already holds.

Can be refused with: `bad_booking` (400), `not_bookable` (404), `dates_taken` (409), `tool_off` (403).

### List bookings

`GET /v1/bookings`

The account's bookings, newest first.

In the address:

- `status` (string): Only bookings with this status.
- `limit` (number): Up to 200. The default is 50.
- `page` (number): Which page of results, from 1.

```bash
curl "https://api.realtorflow.ca/v1/bookings?status=enquiry" \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "list",
  "data": [
    {
      "id": "4e2a9c71-8b3d-4f6e-a0c5-7d19e2b6f348",
      "object": "booking",
      "status": "enquiry",
      "listing": {
        "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
        "slug": "beach-cottage",
        "title": "Beach cottage"
      },
      "check_in": "2026-12-18",
      "check_out": "2026-12-21",
      "nights": 3,
      "guests": 2,
      "name": "Dana Whitfield",
      "email": "dana@example.com",
      "phone": "902-555-0142",
      "message": "Arriving late on the first night.",
      "nightly_rate": 180,
      "currency": "CAD",
      "estimate": 540,
      "notes": null,
      "overlaps_confirmed": false,
      "created_at": "2026-11-13T17:20:05.000Z",
      "updated_at": "2026-11-13T17:20:05.000Z"
    }
  ],
  "page": 1,
  "has_more": false
}
```

Can be refused with: `bad_status` (400), `tool_off` (403).

### Get a booking

`GET /v1/bookings/{id}`

One booking by its `id`.

```bash
curl https://api.realtorflow.ca/v1/bookings/4e2a9c71-8b3d-4f6e-a0c5-7d19e2b6f348 \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "4e2a9c71-8b3d-4f6e-a0c5-7d19e2b6f348",
  "object": "booking",
  "status": "enquiry",
  "listing": {
    "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
    "slug": "beach-cottage",
    "title": "Beach cottage"
  },
  "check_in": "2026-12-18",
  "check_out": "2026-12-21",
  "nights": 3,
  "guests": 2,
  "name": "Dana Whitfield",
  "email": "dana@example.com",
  "phone": "902-555-0142",
  "message": "Arriving late on the first night.",
  "nightly_rate": 180,
  "currency": "CAD",
  "estimate": 540,
  "notes": null,
  "overlaps_confirmed": false,
  "created_at": "2026-11-13T17:20:05.000Z",
  "updated_at": "2026-11-13T17:20:05.000Z"
}
```

Can be refused with: `not_found` (404), `tool_off` (403).

### Change a booking's status

`PATCH /v1/bookings/{id}`

Moves a booking along: `enquiry`, `confirmed`, `paid` (a note that the money reached the broker some other way), `completed` or `cancelled`. Confirming, or marking paid, is refused while another booking already holds any of the nights. `notes` are the broker's own and never reach the guest.

Send:

- `status` (string): The new status.
- `notes` (string): Up to 5,000 characters.

```bash
curl -X PATCH https://api.realtorflow.ca/v1/bookings/4e2a9c71-8b3d-4f6e-a0c5-7d19e2b6f348 \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "confirmed",
    "notes": "Deposit by e-transfer."
  }'
```

Answer (`200`):

```json
{
  "id": "4e2a9c71-8b3d-4f6e-a0c5-7d19e2b6f348",
  "object": "booking",
  "status": "confirmed",
  "listing": {
    "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
    "slug": "beach-cottage",
    "title": "Beach cottage"
  },
  "check_in": "2026-12-18",
  "check_out": "2026-12-21",
  "nights": 3,
  "guests": 2,
  "name": "Dana Whitfield",
  "email": "dana@example.com",
  "phone": "902-555-0142",
  "message": "Arriving late on the first night.",
  "nightly_rate": 180,
  "currency": "CAD",
  "estimate": 540,
  "notes": "Deposit by e-transfer.",
  "overlaps_confirmed": false,
  "created_at": "2026-11-13T17:20:05.000Z",
  "updated_at": "2026-11-13T17:20:05.000Z"
}
```

Can be refused with: `bad_status` (400), `dates_taken` (409), `not_found` (404), `tool_off` (403).

## Newsletter

The account's mailing list, for its newsletter. Only people who agreed themselves, each with the words they agreed to.

The list is the one in the dashboard's **Managed Email Delivery**, where campaigns are written and sent. Sending needs the account's own sending address to be set up there.

### Add a sign-up

`POST /v1/subscribers`

Someone who signed up on your own form, with a box they ticked themselves. They join the account's mailing list, and campaigns sent from the dashboard reach them. Never send people who did not agree.

An address that bounced or complained is not added; the answer says `blocked`. Someone who had unsubscribed and signs up again is back on the list, with the new consent.

Send:

- `email` (string, required): Their email address.
- `consent_text` (string, required): The exact words beside the box they ticked. Kept with the address as the record of their consent. 10 to 300 characters.
- `name` (string): Their name.
- `page_url` (string): The page they signed up on.

```bash
curl -X POST https://api.realtorflow.ca/v1/subscribers \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "dana@example.com",
    "name": "Dana Whitfield",
    "consent_text": "Yes, send me the monthly market note from Harbour Realty.",
    "page_url": "https://www.example.com/newsletter"
  }'
```

Answer (`201`):

```json
{
  "object": "subscriber",
  "email": "dana@example.com",
  "name": "Dana Whitfield",
  "status": "subscribed",
  "consent_text": "Yes, send me the monthly market note from Harbour Realty.",
  "consent_at": "2026-11-13T16:02:11.000Z",
  "page_url": "https://www.example.com/newsletter",
  "created_at": "2026-11-13T16:02:11.000Z",
  "updated_at": "2026-11-13T16:02:11.000Z"
}
```

In the answer:

- `status` (string): `subscribed`, or `blocked` for an address that bounced or complained.
- `already` (boolean): `true` when the address was already on the list (the answer is then `200`).

Can be refused with: `bad_subscriber` (400), `no_consent` (400), `tool_off` (403).

### List sign-ups

`GET /v1/subscribers`

The mailing list, newest first, with each person's consent and when it was given.

In the address:

- `status` (string): `subscribed`, `unsubscribed` or `bounced`.
- `limit` (number): Up to 200. The default is 50.
- `page` (number): Which page of results, from 1.

```bash
curl "https://api.realtorflow.ca/v1/subscribers?status=subscribed&limit=50" \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "list",
  "data": [
    {
      "object": "subscriber",
      "email": "dana@example.com",
      "name": "Dana Whitfield",
      "status": "subscribed",
      "consent_text": "Yes, send me the monthly market note from Harbour Realty.",
      "consent_at": "2026-11-13T16:02:11.000Z",
      "page_url": "https://www.example.com/newsletter",
      "created_at": "2026-11-13T16:02:11.000Z",
      "updated_at": "2026-11-13T16:02:11.000Z"
    }
  ],
  "page": 1,
  "has_more": false
}
```

Can be refused with: `tool_off` (403).

### Unsubscribe someone

`POST /v1/subscribers/unsubscribe`

For an unsubscribe link or request handled on your side. The address leaves the list and goes on the account's do-not-mail list; any follow-up emails under way for it stop.

Send:

- `email` (string, required): The address to take off.

```bash
curl -X POST https://api.realtorflow.ca/v1/subscribers/unsubscribe \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "dana@example.com"
  }'
```

Answer (`200`):

```json
{
  "object": "subscriber",
  "email": "dana@example.com",
  "status": "unsubscribed"
}
```

Can be refused with: `bad_subscriber` (400), `tool_off` (403).

## Leads

Enquiries from any form and finished surveys: send them in, read them back with their scores and answers, move them along, and stop their follow-up emails.

These are the leads in the dashboard's lead inbox, and the other way round. Reading them needs a live or test key, from a server: a website key cannot read leads.

### Send a lead

`POST /v1/leads`

An enquiry from your own form. It lands in the account's lead inbox, the alert email goes out and any follow-up the account has set up starts, exactly as for a site hosted by Realtor Flow.

Send:

- `name` (string, required): The person's name.
- `email` (string, required): A valid email address.
- `phone` (string): A phone number.
- `message` (string): What they wrote. Up to 4,000 characters.
- `page_url` (string): The page the form is on.
- `listing` (string): For an enquiry about one of the account's listings: its `id` or `slug`. The lead is filed with the listing.
- `utm_source` (string): Also `utm_medium` and `utm_campaign`: where the visitor came from, kept with the lead.

```bash
curl -X POST https://api.realtorflow.ca/v1/leads \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Dana Whitfield",
    "email": "dana@example.com",
    "phone": "902-555-0142",
    "message": "Is the house on Tower Road still available?",
    "page_url": "https://www.example.com/contact"
  }'
```

Answer (`201`):

```json
{
  "id": "7c1d7f52-2a3e-4d8a-8a55-0d7c3f1e9b20",
  "object": "lead",
  "name": "Dana Whitfield",
  "email": "dana@example.com",
  "created_at": "2026-11-12T15:09:41.000Z"
}
```

Can be refused with: `bad_lead` (400), `not_found` (404), `tool_off` (403).

### List leads

`GET /v1/leads`

Every lead in the account's inbox, newest first, wherever it came from: a Realtor Flow site, the pasted line or your own server. Pull new ones into your own system with `since`.

A lead from a survey carries its score out of 100, how ready the person is (`hot`, `warm` or `nurture`), every answer as worded when they answered, and the note written for the agent when the survey is set to write one. `follow_ups` lists the follow-up sequences the lead is going through, with how many emails went out and when the next one is due.

Leads marked as spam are left out unless `status` asks for them.

In the address:

- `status` (string): `new`, `contacted`, `qualified`, `closed` or `spam`. Several, separated by commas.
- `kind` (string): `contact` (a form), `listing` (a form about one listing) or `quiz` (a survey). Several, separated by commas.
- `temperature` (string): Survey leads only: `hot`, `warm` or `nurture`. Several, separated by commas.
- `since` (string): Only leads that came in at or after this date and time (ISO 8601).
- `before` (string): Only leads that came in before this date and time.
- `email` (string): Only leads from this address (any case).
- `listing` (string): Only leads about this listing (its `id` or `slug`).
- `survey` (string): Only leads from this survey (its `id` or `slug`).
- `limit` (number): Up to 200. The default is 50.
- `page` (number): Which page of results, from 1.

```bash
curl "https://api.realtorflow.ca/v1/leads?status=new&kind=quiz,listing" \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "object": "list",
  "data": [
    {
      "id": "7c1d7f52-2a3e-4d8a-8a55-0d7c3f1e9b20",
      "object": "lead",
      "kind": "listing",
      "status": "new",
      "name": "Dana Whitfield",
      "email": "dana@example.com",
      "phone": "902-555-0142",
      "message": "Is the house on Tower Road still available?",
      "listing": {
        "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
        "slug": "tower-road-family-home",
        "title": "Tower Road family home"
      },
      "survey": null,
      "brief": null,
      "survey_email": null,
      "follow_ups": [
        {
          "id": "b71f0c2e-5d4a-4c38-9e61-2a8f7d3c1b90",
          "sequence": {
            "id": "e3c9a1d4-7b2f-4e8a-b5c6-1d0f9a8e7c21",
            "name": "New enquiries"
          },
          "status": "active",
          "stop_reason": null,
          "emails_sent": 1,
          "next_email_at": "2026-11-15T15:09:41.000Z",
          "started_at": "2026-11-12T15:09:41.000Z"
        }
      ],
      "page_url": "https://www.example.com/contact",
      "utm": {
        "utm_source": "newsletter"
      },
      "language": null,
      "notes": null,
      "alert_sent": true,
      "created_at": "2026-11-12T15:09:41.000Z",
      "updated_at": "2026-11-12T15:09:41.000Z"
    },
    {
      "id": "2f8e4b61-9c3a-4d7e-8b15-6a0c9e2d4f73",
      "object": "lead",
      "kind": "quiz",
      "status": "new",
      "name": "Hattie Moreau",
      "email": "hattie@example.com",
      "phone": "902-555-0177",
      "message": null,
      "listing": null,
      "survey": {
        "id": "5a7c2e91-3b8d-4f60-a1e4-9d2b6c8f0e35",
        "slug": "ready-to-buy",
        "title": "Ready to buy?",
        "score": 90,
        "temperature": "hot",
        "answers": [
          {
            "q": "When would you like to move?",
            "a": [
              "Within three months"
            ]
          },
          {
            "q": "Have you spoken to a lender?",
            "a": [
              "Yes, I am pre-approved"
            ]
          }
        ]
      },
      "brief": {
        "summary": "Pre-approved and wants to move within three months.",
        "next_step": "Call this week and offer two viewings."
      },
      "survey_email": {
        "subject": "Your result, and a next step",
        "status": "sent",
        "sent_at": "2026-11-11T10:02:13.000Z",
        "note": null
      },
      "follow_ups": [],
      "page_url": "https://www.example.com/quiz",
      "utm": null,
      "language": "en",
      "notes": null,
      "alert_sent": true,
      "created_at": "2026-11-11T10:02:05.000Z",
      "updated_at": "2026-11-11T10:02:13.000Z"
    }
  ],
  "page": 1,
  "has_more": false
}
```

Can be refused with: `bad_status` (400), `bad_kind` (400), `bad_temperature` (400), `bad_date` (400), `not_found` (404), `tool_off` (403).

### Get a lead

`GET /v1/leads/{id}`

One lead by its `id`, with everything the list gives.

```bash
curl https://api.realtorflow.ca/v1/leads/7c1d7f52-2a3e-4d8a-8a55-0d7c3f1e9b20 \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "7c1d7f52-2a3e-4d8a-8a55-0d7c3f1e9b20",
  "object": "lead",
  "kind": "listing",
  "status": "new",
  "name": "Dana Whitfield",
  "email": "dana@example.com",
  "phone": "902-555-0142",
  "message": "Is the house on Tower Road still available?",
  "listing": {
    "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
    "slug": "tower-road-family-home",
    "title": "Tower Road family home"
  },
  "survey": null,
  "brief": null,
  "survey_email": null,
  "follow_ups": [
    {
      "id": "b71f0c2e-5d4a-4c38-9e61-2a8f7d3c1b90",
      "sequence": {
        "id": "e3c9a1d4-7b2f-4e8a-b5c6-1d0f9a8e7c21",
        "name": "New enquiries"
      },
      "status": "active",
      "stop_reason": null,
      "emails_sent": 1,
      "next_email_at": "2026-11-15T15:09:41.000Z",
      "started_at": "2026-11-12T15:09:41.000Z"
    }
  ],
  "page_url": "https://www.example.com/contact",
  "utm": {
    "utm_source": "newsletter"
  },
  "language": null,
  "notes": null,
  "alert_sent": true,
  "created_at": "2026-11-12T15:09:41.000Z",
  "updated_at": "2026-11-12T15:09:41.000Z"
}
```

Can be refused with: `not_found` (404), `tool_off` (403).

### Change a lead's status or notes

`PATCH /v1/leads/{id}`

Moves a lead along (`new`, `contacted`, `qualified`, `closed`, `spam`) and keeps the agent's own notes, the same ones the dashboard shows. Notes never reach the lead.

Closing a lead, or marking it as spam, stops its follow-up emails at once.

Send:

- `status` (string): The new status.
- `notes` (string): Up to 4,000 characters. `null` clears them.

```bash
curl -X PATCH https://api.realtorflow.ca/v1/leads/7c1d7f52-2a3e-4d8a-8a55-0d7c3f1e9b20 \
  -H "Authorization: Bearer $RF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "contacted",
    "notes": "Called Tuesday. Viewing on Saturday at 10."
  }'
```

Answer (`200`):

```json
{
  "id": "7c1d7f52-2a3e-4d8a-8a55-0d7c3f1e9b20",
  "object": "lead",
  "kind": "listing",
  "status": "contacted",
  "name": "Dana Whitfield",
  "email": "dana@example.com",
  "phone": "902-555-0142",
  "message": "Is the house on Tower Road still available?",
  "listing": {
    "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
    "slug": "tower-road-family-home",
    "title": "Tower Road family home"
  },
  "survey": null,
  "brief": null,
  "survey_email": null,
  "follow_ups": [
    {
      "id": "b71f0c2e-5d4a-4c38-9e61-2a8f7d3c1b90",
      "sequence": {
        "id": "e3c9a1d4-7b2f-4e8a-b5c6-1d0f9a8e7c21",
        "name": "New enquiries"
      },
      "status": "active",
      "stop_reason": null,
      "emails_sent": 1,
      "next_email_at": "2026-11-15T15:09:41.000Z",
      "started_at": "2026-11-12T15:09:41.000Z"
    }
  ],
  "page_url": "https://www.example.com/contact",
  "utm": {
    "utm_source": "newsletter"
  },
  "language": null,
  "notes": "Called Tuesday. Viewing on Saturday at 10.",
  "alert_sent": true,
  "created_at": "2026-11-12T15:09:41.000Z",
  "updated_at": "2026-11-12T15:09:41.000Z"
}
```

Can be refused with: `nothing_to_change` (400), `bad_status` (400), `bad_notes` (400), `notes_too_long` (413), `not_found` (404), `tool_off` (403).

### Stop a lead's follow-ups

`POST /v1/leads/{id}/follow-ups/stop`

Stops every follow-up sequence the lead is going through, for example once the agent has spoken to them. The lead itself is left as it is. `stopped` says how many were stopped; calling it again stops nothing and is not an error.

```bash
curl -X POST https://api.realtorflow.ca/v1/leads/7c1d7f52-2a3e-4d8a-8a55-0d7c3f1e9b20/follow-ups/stop \
  -H "Authorization: Bearer $RF_KEY"
```

Answer (`200`):

```json
{
  "id": "7c1d7f52-2a3e-4d8a-8a55-0d7c3f1e9b20",
  "object": "lead",
  "kind": "listing",
  "status": "new",
  "name": "Dana Whitfield",
  "email": "dana@example.com",
  "phone": "902-555-0142",
  "message": "Is the house on Tower Road still available?",
  "listing": {
    "id": "9cb5d6ac-fba8-4538-898b-c3309569fa5d",
    "slug": "tower-road-family-home",
    "title": "Tower Road family home"
  },
  "survey": null,
  "brief": null,
  "survey_email": null,
  "follow_ups": [
    {
      "id": "b71f0c2e-5d4a-4c38-9e61-2a8f7d3c1b90",
      "sequence": {
        "id": "e3c9a1d4-7b2f-4e8a-b5c6-1d0f9a8e7c21",
        "name": "New enquiries"
      },
      "status": "stopped",
      "stop_reason": "stopped_by_api",
      "emails_sent": 1,
      "next_email_at": null,
      "started_at": "2026-11-12T15:09:41.000Z"
    }
  ],
  "page_url": "https://www.example.com/contact",
  "utm": {
    "utm_source": "newsletter"
  },
  "language": null,
  "notes": null,
  "alert_sent": true,
  "created_at": "2026-11-12T15:09:41.000Z",
  "updated_at": "2026-11-12T15:09:41.000Z",
  "stopped": 1
}
```

Can be refused with: `not_found` (404), `tool_off` (403).

A follow-up's `stop_reason` says why it stopped: `lead_closed`, `lead_spam`, `unsubscribed`, `failed` (the email could not be sent three times) or `stopped_by_api`.

## Errors

Every refusal is `{ "error": "...", "code": "..." }` with the status shown here.

| Code | Status | What it means |
| --- | --- | --- |
| `no_key` | 401 | No key was sent. Send it as `Authorization: Bearer <key>`. |
| `bad_key` | 401 | The key is not one of ours. Copy it again from the Developers page. |
| `key_revoked` | 401 | The key has been revoked. Make a new one on the Developers page. |
| `paused` | 403 | The account is paused. Write to Realtor Flow. |
| `not_switched_on` | 403 | The tool kit is not switched on for this account. Write to Realtor Flow. |
| `wrong_key` | 403 | The wrong kind of key for this call: a website key was used from a server, or a live or test key was put in a page. |
| `browser_only` | 403 | A website key was used without a browser. It only works from a page. |
| `domain_not_listed` | 403 | The page is on a website that is not listed on the website key. Add it on the Developers page. |
| `tool_off` | 403 | The tool this call belongs to (Videos, Surveys, Leads, Listings, Bookings and so on) is not switched on for the account. |
| `not_found` | 404 | There is nothing with that id, or no such call. |
| `bad_json` | 400 | The body is not valid JSON. Send JSON with `Content-Type: application/json`. |
| `too_large` | 413 | The body is over 2 MB. |
| `slow_down` | 429 | Too many calls in a minute. Wait and try again; the `RateLimit-Reset` header says how many seconds. |
| `limit_reached` | 429 | The month's allowance for this kind of work is used up. It starts again on the first of the month (UTC). |
| `try_later` | 503 | The account has reached the day's ceiling for AI work. It starts again the next day. |
| `ai_off` | 503 | AI writing is not available right now. Try again later. |
| `server_error` | 500 | Something went wrong on our side. Try again; if it keeps happening, write to Realtor Flow. |
| `bad_youtube_url` | 400 | `youtube_url` is missing or is not the address of a YouTube video. |
| `no_transcript` | 400 | No `transcript` was sent for a video that has none yet. |
| `transcript_too_short` | 422 | The transcript holds under 400 characters of speech, too little to write from. |
| `transcript_too_long` | 413 | The transcript is far beyond two hours of speech. |
| `bad_page_url` | 400 | `page_url` is not a full address starting with `https://`. |
| `not_yours_to_delete` | 409 | The video was added in the dashboard, not through the tool kit. Remove it there. |
| `bad_survey` | 400 | The survey sent is not complete. The sentence says which part: a missing title, a question with no wording, fewer than two answers. |
| `too_many_surveys` | 409 | The account already holds 50 surveys. Delete or reuse one. |
| `no_prompt` | 400 | Nothing to draft from. Send a `prompt`, or `messages` that end with one from the user. |
| `draft_failed` | 502 | The draft did not come back complete. Nothing was counted. Try again. |
| `survey_not_live` | 409 | Real answers were sent to a survey that is not live. Set its `status` to `live`. |
| `bad_response` | 400 | The answers do not belong to this survey, or the name, email or required phone number is missing. |
| `bad_lead` | 400 | A lead needs a `name` and a valid `email`. |
| `draft_too_short` | 400 | The draft is under 200 characters. The article only uses what is in the draft, so it needs something to work with. |
| `draft_too_long` | 413 | The draft is over 20,000 characters. |
| `no_text` | 400 | Nothing to translate. Send `text`, or `texts` (up to 200). |
| `text_too_long` | 413 | Over 20,000 characters in one call. Split it into several calls. |
| `bad_language` | 400 | `to` (or `from`) is not one of `en`, `fr`, `es`, `de` or `zh`, or both are the same. |
| `translation_failed` | 502 | The translation did not come back. Nothing was counted. Try again. |
| `bad_crawls` | 400 | `hits` is empty or holds more than 200 visits. |
| `bad_origin` | 400 | `origin` is missing or is not a web address starting with `https://`. |
| `bad_listing_url` | 400 | `listing_url` is not a full address with `{slug}` where each listing's short name goes. |
| `bad_pages` | 400 | `pages` is not a list of `{ "title", "url", "description" }`. |
| `bad_terms` | 400 | The booking terms are not right: a nightly rate above zero, and whole numbers for the shortest stay and the most guests. |
| `bad_booking` | 400 | The request is not right. The sentence says which part, in the account's language: the dates, the length of stay, the number of guests or the contact details. |
| `not_bookable` | 404 | The listing is not taking booking requests: it has no booking terms, or it is not active. |
| `dates_taken` | 409 | Some of those nights are already held by a confirmed or paid booking. |
| `bad_status` | 400 | `status` is not one this call takes. Bookings: `enquiry`, `confirmed`, `paid`, `completed`, `cancelled`. Leads: `new`, `contacted`, `qualified`, `closed`, `spam`. |
| `bad_kind` | 400 | `kind` is one or more of `contact`, `listing` and `quiz`, separated by commas. |
| `bad_temperature` | 400 | `temperature` is one or more of `hot`, `warm` and `nurture`, separated by commas. |
| `bad_date` | 400 | `since` or `before` is not a date, such as `2026-11-01` or `2026-11-01T09:00:00Z`. |
| `nothing_to_change` | 400 | The body has nothing to change: for a lead, neither `status` nor `notes`; for a photo, no `alt`. |
| `no_photo` | 400 | No photo was sent: a form upload with the field `file`, or JSON with `url`. |
| `bad_upload` | 400 | The form upload is not one photo in the field `file`. |
| `file_too_large` | 413 | The photo is over 20 MB. |
| `bad_alt` | 400 | `alt` is not text, or is over 300 characters. |
| `media_full` | 409 | The account's media library holds 5,000 photos, the most it can. Delete some first. |
| `media_in_use` | 409 | The photo is still used by a listing, page, article or something else on the account. The sentence names them. Take it out of those first. |
| `bad_notes` | 400 | `notes` is not text (send `null` to clear it). |
| `notes_too_long` | 413 | `notes` is over 4,000 characters. |
| `bad_subscriber` | 400 | A sign-up needs a valid `email`. |
| `no_consent` | 400 | No consent: the box was not ticked, or `consent_text` (the exact words the person agreed to) was not sent. |
| `bad_listing` | 400 | The listing sent is not right. The sentence says which part: a missing title, an unknown property type or status. |
| `bad_photo` | 400 | A photo could not be used: the address does not answer, is not a JPEG, PNG or WebP, is over 15 MB, or is not on the public internet. The sentence names the photo. |
| `too_many_photos` | 400 | A listing can have up to 30 photos. |
| `too_many_listings` | 409 | The account already holds 2,000 listings. Delete or archive old ones. |

## For AI tools and site builders

Building with an AI coding assistant or a site builder? Hand it one of these and it has what it needs to call the tool kit:

- [https://api.realtorflow.ca/llms.txt](https://api.realtorflow.ca/llms.txt): this whole page as plain text.
- [https://api.realtorflow.ca/openapi.json](https://api.realtorflow.ca/openapi.json): the API as an OpenAPI 3.1 file.

## Questions

### Do I have to move my website to Realtor Flow?

No. The website stays where it is. The tool kit adds listings, surveys, contact forms and video articles to it, and the work runs on Realtor Flow.

### Do I need to write code?

Not for listings, surveys, contact forms and newsletter sign-ups: paste one line into the page and put a tag where each one should appear. Video articles are reached through the API, so they need a developer or a tool that can call one.

### Which websites does the pasted line work on?

Any page where you can add your own HTML with a script tag, on a website address listed on the key. Some site builders allow custom code only on certain plans, or place it inside a frame served from another address; inside such a frame the key will not match the website.

### Where do the leads go?

Into the lead inbox of the account's Realtor Flow dashboard, each with its score and every answer. The alert email goes to the addresses the account has set.

### Can a visitor see how the answers are scored?

No. The pasted line never receives the points, and the score is not sent to the browser. It stays with the lead in the inbox.

### What does testing use up?

Nothing. A test key never reaches the AI, saves nothing, sends no email and is not counted against the allowance.

### Where does a video article get its facts?

From what is said in the video. The title, the description and the account's business details are read for context, and nothing is looked up elsewhere. Figures and quotations that the transcript does not hold word for word are listed in "check", so a person can look before publishing.

### How do I get a key?

Keys are made on the Developers page of the Realtor Flow dashboard. The tool kit is switched on for each account by Realtor Flow: write to jarvis@realtorflow.ca.
