Developer Documentation

Integration guide for displaying Source MLS badges on listing pages

Overview

Source MLS verifies that listing data displayed on real estate websites originates from an authorized MLS. Data Licensees, such as IDX Vendors, display a small badge on each listing detail page indicating this fact. The verification works through a simple SourceMLSURL that flows from the MLS to data Licensees via the RESO data feed.

How It Works

  1. MLS generates a SourceMLSURL for each listing in their RESO data feed. The URL contains a JWT token that encodes the listing and licensee information.
  2. Licensees receive the SourceMLSURL as a field in the RESO feed data for each listing.
  3. Licensees display a badge on their listing detail pages using the SourceMLSURL. The badge loads a small image and confirms the impression back to Source MLS.

SourceMLSURL Format

https://sourcemls.org/api/v1/{jwt_token}/badge

The {jwt_token} is a signed JWT containing RESO Data Dictionary claims that identify the listing, licensee, and MLS organization. MLSs generate this token server-side using their organization secret key.

For Licensees

As a data Licensee, you receive a simple and unique SourceMLSURL for each listing in the RESO data feed from your MLS. To display the Source MLS badge on your listing detail pages, add the following <img> tag.

Recommended — This is the preferred badge implementation. No JavaScript required.

Place this tag on each listing detail page, using the SourceMLSURL from that listing's feed data. Replace {current-page-url} with the URL of the page displaying the badge:

<img src="{SourceMLSURL}.png?loc={current-page-url}"
     width="132"
     height="60"
     alt="Source MLS Verified" />
How Each Attribute Works
Attribute Purpose
src="{SourceMLSURL}.png?loc={current-page-url}" Loads the Source MLS badge image from the API. Appending .png requests the PNG format.
The loc parameter tells Source MLS which page is displaying the badge. Send enough info to allow us to get to the listing detail page, but avoid sending personally identifiable information. The page URL should be URL-encoded.
width="132" height="60" Sets the badge display size in pixels.
alt="Source MLS Verified" Accessibility text for screen readers.
How it works: Every time a visitor loads the page, the browser requests the badge image. Source MLS records each request as a badge impression and tracks the page URL from the loc parameter. If the JWT token is invalid, a transparent pixel is displayed instead — no broken image icons and no JavaScript needed.
Example: If the SourceMLSURL is https://sourcemls.org/api/v1/eyJhbGc.../badge and the listing page is https://example.com/listing/123, your img tag src would be https://sourcemls.org/api/v1/eyJhbGc.../badge.png?loc=https%3A%2F%2Fexample.com%2Flisting%2F123.
Important: Use the SourceMLSURL exactly as provided in the feed data. Do not modify the JWT token or construct URLs manually. URL-encode the loc parameter value.
Styling Tip: The badge image has a transparent background. If your site has a dark background, add style="background:white" to the <img> tag to ensure the badge remains readable.
Sample Badge Image

There is a sample badge image at https://sourcemls.org/sample-badge.png that you can use for testing image sizing and placement before your MLS starts providing real SourceMLSURLs in the feed.


Legacy: JavaScript Badge (Deprecated)

Deprecated — This method still works but is no longer recommended. New implementations should use the no-JavaScript method above.

This older implementation uses JavaScript to confirm badge impressions and hide invalid badges. It remains fully functional for existing integrations:

<img src="{SourceMLSURL}.png"
     width="132"
     height="60"
     alt="Source MLS Verified"
     onload="navigator.sendBeacon('{SourceMLSURL}',window.location.href)"
     onerror="this.style.display='none'" />
How Each Attribute Works
Attribute Purpose
src="{SourceMLSURL}.png" Loads the Source MLS badge image from the API.
onload="navigator.sendBeacon('{SourceMLSURL}',window.location.href)" After the badge image loads, sends a fire-and-forget POST to confirm the impression and report which page is displaying the badge.
onerror="this.style.display='none'" If the badge fails to load (network error, invalid token), hides the broken image element.

For MLSs

As an MLS Organization, you generate a simple SourceMLSURL for each listing in your RESO data feed. The URL contains a JWT token signed with your organization's secret key. It is unique for each listing and licensee combination, allowing you to track the source and location of each badge impression.

You will want to work with your RESO Vendor or data feed provider to implement this. The Source MLS system will provide you with a secret key and your RESO Unique Organization Identifier once you sign up. Share those with your RESO Vendor or data feed provider and they can take it from there using the instructions below.

JWT Token Generation

Create a JWT token with the following RESO Data Dictionary claims, signed using your organization's secret key with the HS256 algorithm.

Required Claims
Claim Type Description
SourceSystemID String Your MLS organization code. We use RESO's Unique Organization Identifier for this - see RESO UOI.
LicenseeID String Unique identifier for the licensee in your MLS system
LicenseeName String Display name for the licensee (e.g., company name)
ListingID String Unique listing identifier in your system
StandardStatus String Current listing status (Active, Pending, Sold, etc.). Use RESO Data Dictionary definition.
ModificationTimestamp ISO 8601 When the listing was last modified. Use RESO Data Dictionary definition.
Auto-Creation: If a LicenseeID or ListingID doesn't exist in Source MLS, it will be automatically created on first badge request. This enables zero-friction integration.
No Expiration Required: Tokens do not require an exp claim.

Assembling the SourceMLSURL

After generating the JWT token, construct the SourceMLSURL:

https://sourcemls.org/api/v1/{jwt_token}/badge

Include this URL as a field in the RESO feed data for each listing.

Security Note: Never expose your MLS organization's secret key in client-side code. Generate tokens on your server when populating the RESO feed.

Sample Code

JavaScript (Node.js)
const jwt = require('jsonwebtoken');

// Generate JWT token with RESO claims
const payload = {
  SourceSystemID: 'your-org-code',
  LicenseeID: 'LIC-12345',
  LicenseeName: 'ABC Realty Group',
  ListingID: 'MLS-2026-12345',
  StandardStatus: 'Active',
  ModificationTimestamp: new Date().toISOString()
};

const token = jwt.sign(payload, process.env.MLS_SECRET_KEY, { algorithm: 'HS256' });

// Assemble the SourceMLSURL
const sourceMLSURL = `https://sourcemls.org/api/v1/${token}/badge`;
Ruby
require 'jwt'

# Generate JWT token with RESO claims
payload = {
  SourceSystemID: 'your-org-code',
  LicenseeID: 'LIC-12345',
  LicenseeName: 'ABC Realty Group',
  ListingID: 'MLS-2026-12345',
  StandardStatus: 'Active',
  ModificationTimestamp: Time.current.iso8601
}

token = JWT.encode(payload, ENV['MLS_SECRET_KEY'], 'HS256')

# Assemble the SourceMLSURL
source_mls_url = "https://sourcemls.org/api/v1/#{token}/badge"
Python
import jwt
import os
from datetime import datetime

# Generate JWT token with RESO claims
payload = {
    'SourceSystemID': 'your-org-code',
    'LicenseeID': 'LIC-12345',
    'LicenseeName': 'ABC Realty Group',
    'ListingID': 'MLS-2026-12345',
    'StandardStatus': 'Active',
    'ModificationTimestamp': datetime.utcnow().isoformat() + 'Z'
}

token = jwt.encode(payload, os.environ['MLS_SECRET_KEY'], algorithm='HS256')

# Assemble the SourceMLSURL
source_mls_url = f'https://sourcemls.org/api/v1/{token}/badge'

JWT Checker Tool

Use our online JWT checker to validate and decode your tokens during development.

Open JWT Checker Tool

Exporting Data

Vendors can programmatically export usage data from Source MLS using the Export API. This enables automated reporting workflows using the same MLS secret key used to generate SourceMLSURLs.

How It Works

  1. Request an export by sending a POST to /api/v1/export with your MLS secret key, export type, time frame, and optional file format.
  2. Receive an export ID immediately in the response. Since these files can be large and take time to generate, the export is processed asynchronously in the background.
  3. Poll for status by sending a GET to /api/v1/export/:id with your MLS secret key until the status is "completed". See details about a webhook alternative to polling below.
  4. Download the file using the download_url provided in the completed status response. The link expires after 72 hours.

POST /api/v1/export

Create a new export request. Returns 202 Accepted with an export ID for polling.

Parameters
Parameter Type Required Description
mls_secret_key String Yes Your MLS organization's secret key (the same key used to sign JWT tokens for SourceMLSURLs)
export_type String Yes Type of report to export (see valid types below)
time_frame String Yes Time range for the data — a preset, or "custom" to supply your own bounds (see valid time frames below)
start_date String With custom ISO 8601 lower bound, inclusive. Required when time_frame is "custom", and rejected otherwise.
end_date String With custom ISO 8601 upper bound, exclusive. Required when time_frame is "custom", and rejected otherwise.
file_format String No Output format: "csv" (default) or "json"
webhook_url String No HTTPS URL to receive a notification when the export is ready
Valid Export Types
Value Description
analytics_overview Analytics Overview — summary of badge impressions and activity
badge_requests_detail Badge Requests Detail — individual badge request records. Includes a Licensee ID column.
licensee_summary Licensee Summary — aggregated data by licensee. Includes a Licensee ID column. Not filtered by time frame.
Licensee ID column: the Badge Requests Detail and Licensee Summary reports include a Licensee ID column carrying the licensee's key — the same LicenseeID value you supply in the JWT payload. Use it to join exported rows to the licensee records in your own system instead of matching on name or email. In the Badge Requests Detail report the cell is empty for badge requests with no associated licensee. This identifier is unique within an MLS organization, not globally, so if you integrate with more than one MLS, treat mls_code plus Licensee ID as the compound identity.
Time frames and export types: badge_requests_detail and analytics_overview filter their rows by the requested time frame. licensee_summary does not filter by date — time_frame is still required, and a custom window is accepted and recorded, but it will not narrow the rows returned.
Auto-discovery: New export types added to Source MLS are automatically available via the API. If you request an invalid type, the error response lists all currently valid types.
Valid Time Frames
Value Description
last_24_hoursThe trailing 24 hours, ending at the moment you make the request
last_7_daysFrom the start of the day 7 days ago, up to now
last_30_daysFrom the start of the day 30 days ago, up to now
current_monthFrom the start of the current month, up to now
last_monthThe full previous calendar month
custom Your own window, supplied as start_date and end_date (see below)

Preset windows are exact to the second. GET /api/v1/export/:id reports the precise start_date and end_date the export covered, whichever time frame you used.

Custom Time Frames

Set time_frame to "custom" and supply both start_date and end_date in ISO 8601 format to export an exact period — a billing month, an audit window, or a backfill.

Rule Detail
Bounds start_date is inclusive; end_date is exclusive. To cover all of July, use 2026-07-01T00:00:00Z to 2026-08-01T00:00:00Z.
Both required Supplying only one bound returns MISSING_CUSTOM_BOUNDS.
Accepted formats 2026-07-01T00:00:00Z, 2026-07-01T00:00:00.500Z, 2026-07-01T00:00:00+02:00, 2026-07-01T00:00:00, or a bare date 2026-07-01.
Timezone default A value with no timezone offset is interpreted as UTC. A bare date means midnight UTC.
Maximum span 366 days on this endpoint. Longer windows return TIME_FRAME_TOO_LARGE. (The Real-Time Stats endpoint has a lower limit of 31 days — see that section.)
Not combinable with presets Sending start_date or end_date alongside a preset such as last_7_days is rejected with UNEXPECTED_CUSTOM_BOUNDS rather than silently ignored, so you never receive a period other than the one you asked for.
Request Example
curl -X POST https://sourcemls.org/api/v1/export \
  -H "Content-Type: application/json" \
  -d '{
    "mls_secret_key": "your_secret_key_here",
    "export_type": "badge_requests_detail",
    "time_frame": "last_30_days",
    "file_format": "json"
  }'
Request Example — Custom Time Frame

Export every badge request in July 2026. The lower bound is inclusive and the upper bound is exclusive, so this covers 2026-07-01T00:00:00Z up to but not including 2026-08-01T00:00:00Z.

curl -X POST https://sourcemls.org/api/v1/export \
  -H "Content-Type: application/json" \
  -d '{
    "mls_secret_key": "your_secret_key_here",
    "export_type": "badge_requests_detail",
    "time_frame": "custom",
    "start_date": "2026-07-01T00:00:00Z",
    "end_date": "2026-08-01T00:00:00Z",
    "file_format": "csv"
  }'

Poll GET /api/v1/export/:id to confirm the exact window the export covered — the response echoes the resolved time_frame, start_date, and end_date at every status, including queued and failed.

{
  "export_id": "8f14e45f-ceea-467a-9575-1f1e0dcb2f13",
  "status": "completed",
  "export_type": "badge_requests_detail",
  "file_format": "csv",
  "time_frame": "custom",
  "start_date": "2026-07-01T00:00:00Z",
  "end_date": "2026-08-01T00:00:00Z",
  "download_url": "https://s3.amazonaws.com/...",
  "expires_at": "2026-08-31T12:00:00Z",
  "row_count": 48213,
  "file_size_bytes": 6104922
}
Response (202 Accepted)
{
  "export_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "queued",
  "export_type": "badge_requests_detail",
  "file_format": "json",
  "message": "Export queued. Poll GET /api/v1/export/:id for status, or await webhook delivery."
}

GET /api/v1/export/:id

Check the status of an export request. Pass your mls_secret_key as a query parameter. The response fields vary based on the export status.

Request Example
curl "https://sourcemls.org/api/v1/export/EXPORT_ID?mls_secret_key=your_secret_key_here"
Response — Queued or In Progress
{
  "export_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "in_progress",
  "export_type": "badge_requests_detail",
  "file_format": "json"
}
Response — Completed
{
  "export_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "completed",
  "export_type": "badge_requests_detail",
  "file_format": "json",
  "download_url": "https://s3.amazonaws.com/...",
  "expires_at": "2026-03-14T12:00:00Z",
  "row_count": 1523,
  "file_size_bytes": 245678
}

When the status is "completed", use the download_url to download the file. The download link expires at the time shown in expires_at (72 hours after generation).

Response — Failed
{
  "export_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "failed",
  "export_type": "badge_requests_detail",
  "file_format": "json",
  "error_message": "An error occurred during export generation"
}

Polling Example

A simple polling loop to wait for an export to complete:

#!/bin/bash
# Request an export
RESPONSE=$(curl -s -X POST https://sourcemls.org/api/v1/export \
  -H "Content-Type: application/json" \
  -d '{
    "mls_secret_key": "your_secret_key_here",
    "export_type": "badge_requests_detail",
    "time_frame": "last_30_days"
  }')

EXPORT_ID=$(echo $RESPONSE | jq -r '.export_id')
echo "Export ID: $EXPORT_ID"

# Poll until complete
while true; do
  STATUS_RESPONSE=$(curl -s "https://sourcemls.org/api/v1/export/$EXPORT_ID?mls_secret_key=your_secret_key_here")
  STATUS=$(echo $STATUS_RESPONSE | jq -r '.status')

  if [ "$STATUS" = "completed" ]; then
    DOWNLOAD_URL=$(echo $STATUS_RESPONSE | jq -r '.download_url')
    echo "Download URL: $DOWNLOAD_URL"
    curl -o export.csv "$DOWNLOAD_URL"
    break
  elif [ "$STATUS" = "failed" ]; then
    echo "Export failed: $(echo $STATUS_RESPONSE | jq -r '.error_message')"
    break
  fi

  echo "Status: $STATUS - waiting..."
  sleep 5
done

Webhook Alternative

Instead of polling, you can provide a webhook_url in your export request. When the export completes, Source MLS will send a POST request to your webhook URL with the export details including the download_url.

curl -X POST https://sourcemls.org/api/v1/export \
  -H "Content-Type: application/json" \
  -d '{
    "mls_secret_key": "your_secret_key_here",
    "export_type": "analytics_overview",
    "time_frame": "current_month",
    "file_format": "csv",
    "webhook_url": "https://your-server.com/webhooks/export-ready"
  }'
Security: Keep your MLS secret key confidential. Never expose it in client-side code or public repositories. Make API calls from your server only.

Real-Time Stats

Get real-time badge request counts for your organization or individual listings. These lightweight endpoints return instantly and are designed for integration with vendor dashboards. They use the same MLS secret key as the Export API.

GET /api/v1/stats

Returns badge request counts for your entire MLS organization across four time windows.

Request Example
curl "https://sourcemls.org/api/v1/stats?mls_secret_key=your_secret_key_here"
Response (200 OK)
{
  "mls_code": "M00000389",
  "mls_name": "Doorify MLS",
  "counts": {
    "last_hour": 142,
    "last_24_hours": 3850,
    "last_7_days": 24300,
    "all_time": 185420
  }
}

GET /api/v1/stats/:listing_key

Returns badge request counts for a specific listing. The :listing_key is the listing's unique identifier (the same ListingID value used in your JWT tokens).

Request Example
curl "https://sourcemls.org/api/v1/stats/OC25012345?mls_secret_key=your_secret_key_here"
Response (200 OK)
{
  "mls_code": "M00000389",
  "mls_name": "Doorify MLS",
  "listing_key": "25012345",
  "counts": {
    "last_hour": 8,
    "last_24_hours": 45,
    "last_7_days": 312,
    "all_time": 2840
  }
}
Custom Time Frame

Instead of the fixed windows, request a count for an exact period by setting time_frame=custom with ISO 8601 start_date and end_date parameters. The bound rules are identical to the Export API — inclusive lower bound, exclusive upper bound, both required together, and values without a timezone offset interpreted as UTC.

Two differences from the Export API:
  • The maximum span here is 31 days, not 366. This endpoint answers synchronously; use POST /api/v1/export for longer periods.
  • custom is the only accepted time_frame value. Presets such as last_7_days return INVALID_TIME_FRAME.

A custom window replaces the counts object with a single count — it does not add to it. Omit these parameters entirely and the response is exactly the fixed-window response shown above. If you need both, make two requests.

Request Example — Custom Time Frame
curl "https://sourcemls.org/api/v1/stats/OC25012345?mls_secret_key=your_secret_key_here&time_frame=custom&start_date=2026-07-01T00:00:00Z&end_date=2026-08-01T00:00:00Z"
Response (200 OK)
{
  "mls_code": "M00000389",
  "mls_name": "Doorify MLS",
  "listing_key": "25012345",
  "time_frame": "custom",
  "start_date": "2026-07-01T00:00:00Z",
  "end_date": "2026-08-01T00:00:00Z",
  "count": 1284
}
Count Time Windows

Returned when no time_frame parameters are supplied. A custom window replaces this object entirely.

Field Description
last_hourBadge requests in the trailing 60 minutes
last_24_hoursBadge requests in the trailing 24 hours
last_7_daysBadge requests in the trailing 7 days
all_timeTotal badge requests since the listing was first tracked

Error Handling

All API endpoints return standard HTTP status codes and a consistent JSON error format.

HTTP Status Codes

Status Code Meaning
200 Success
202 Accepted - Export queued for processing
204 No Content (sendBeacon POST success)
400 Bad Request - Required JWT claims missing
401 Unauthorized - Invalid credentials (JWT or MLS secret key)
403 Forbidden - Organization mismatch
404 Not Found - Resource doesn't exist
422 Unprocessable Entity - Invalid parameter value
500 Internal Server Error

Error Response Format

All errors return a JSON body with a consistent structure:

{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable description",
    "details": "Additional context (may be null)"
  }
}

Error Codes

Code Status Applies To Description
MISSING_SECRET_KEY 401 Export, Stats No mls_secret_key parameter provided
INVALID_SECRET_KEY 401 Export, Stats Secret key is invalid or belongs to an inactive organization
INVALID_EXPORT_TYPE 422 Export Unrecognized export type (valid types listed in response)
INVALID_TIME_FRAME 422 Export, Stats Unrecognized time frame (valid frames listed in response). On the per-listing stats endpoint, custom is the only accepted value.
MISSING_CUSTOM_BOUNDS 422 Export, Stats time_frame=custom was requested without both start_date and end_date — or a bound was supplied without time_frame=custom
UNEXPECTED_CUSTOM_BOUNDS 422 Export start_date or end_date was supplied alongside a preset time frame. Use time_frame=custom, or omit the bounds.
INVALID_DATE_FORMAT 422 Export, Stats A bound is not a valid ISO 8601 timestamp (the offending parameter is named in the response)
INVALID_DATE_RANGE 422 Export, Stats end_date is earlier than start_date. Equal bounds are valid and select nothing.
TIME_FRAME_TOO_LARGE 422 Export, Stats The custom window exceeds the endpoint's maximum span — 366 days for exports, 31 days for per-listing stats
INVALID_FILE_FORMAT 422 Export Unsupported file format (use csv or json)
EXPORT_NOT_FOUND 404 Export Export ID does not exist or belongs to another organization
LISTING_NOT_FOUND 404 Stats Listing key does not exist or does not belong to your organization

Need Help?

Can't find what you're looking for? Our support team is here to help.

Contact Support