Developer Documentation
Integration guide for displaying Source MLS badges on listing pages
Contents
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
- 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.
- Licensees receive the SourceMLSURL as a field in the RESO feed data for each listing.
- 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: No-JavaScript Badge
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. |
loc
parameter. If the JWT token is invalid, a transparent pixel is displayed instead — no broken
image icons and no JavaScript needed.
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.
loc parameter value.
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)
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. |
LicenseeID or ListingID doesn't exist in
Source MLS, it will be automatically created on first badge request. This enables zero-friction integration.
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.
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 ToolExporting 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
- Request an export by sending a POST to
/api/v1/exportwith your MLS secret key, export type, time frame, and optional file format. - 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.
- Poll for status by sending a GET to
/api/v1/export/:idwith your MLS secret key until the status is"completed". See details about a webhook alternative to polling below. - Download the file using the
download_urlprovided 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 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.
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.
Valid Time Frames
| Value | Description |
|---|---|
last_24_hours | The trailing 24 hours, ending at the moment you make the request |
last_7_days | From the start of the day 7 days ago, up to now |
last_30_days | From the start of the day 30 days ago, up to now |
current_month | From the start of the current month, up to now |
last_month | The 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"
}'
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.
- The maximum span here is 31 days, not 366. This endpoint answers
synchronously; use
POST /api/v1/exportfor longer periods. customis the only acceptedtime_framevalue. Presets such aslast_7_daysreturnINVALID_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_hour | Badge requests in the trailing 60 minutes |
last_24_hours | Badge requests in the trailing 24 hours |
last_7_days | Badge requests in the trailing 7 days |
all_time | Total 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 |