2026-11-05 · 4 min read
A Caption API for Real Estate Videos
Listing walkthroughs, neighborhood tours, and agent intro videos all end up in places where sound is off: social feeds, listing pages, and email. If the narration or the agent's voiceover carries the pitch, an uncaptioned video loses it. Real estate teams usually are not video editors, so captioning tends to either get skipped or become a per-listing chore. An API turns it into a step that just happens.
Where captions pay off in real estate video
- Listing videos shared on social autoplay muted, so the first caption line is the hook.
- Voiceover details like price, square footage, and neighborhood names stay visible on mute.
- Agent intro videos work in email signatures and DMs where recipients will not unmute.
- One captioned master works across MLS-adjacent sites, portals, and social without per-platform caption setup.
Submit a listing video
caption.sh accepts a JSON POST with a source_url for the video and returns a job_id. If your listing videos live in a shared drive or a video host, hand the API a reachable URL and it fetches the file itself. A two-step signed-URL upload is also documented for files that are not publicly reachable.
curl -X POST https://api.caption.sh/v1/videos \
-H "Authorization: Bearer $CAPTION_API_KEY" \
-H "Content-Type: application/json" \
-d '{"source_url": "https://example.com/talk.mp4"}'
Captioning costs $0.10 per minute, prepaid. A two-minute walkthrough is twenty cents, so captioning every listing video is a rounding error next to the shoot.
Get the captioned video back
Jobs are asynchronous: submit, keep the job_id, and poll until the job is done or failed, with a timeout so nothing hangs. The transcription throughput ceiling is shared, so on a busy Monday of new listings, a 429 means wait and retry.
import time
import requests
KEY = "your_api_key"
H = {"Authorization": f"Bearer {KEY}"}
job = requests.post(
"https://api.caption.sh/v1/videos",
headers=H,
json={"source_url": "https://example.com/talk.mp4"},
).json()
job_id = job["job_id"]
# A 429 means the transcription throughput ceiling is reached. Wait and retry.
deadline = time.time() + 1800
status = None
while time.time() < deadline:
r = requests.get(f"https://api.caption.sh/v1/jobs/{job_id}", headers=H)
status = r.json()["status"]
if status in ("done", "error"):
break
time.sleep(10)
if status == "done":
r = requests.get(f"https://api.caption.sh/v1/jobs/{job_id}/result", headers=H, allow_redirects=False)
print(r.headers["Location"]) # signed URL; results are stored for 24 hours, then deleted
elif status == "error":
raise SystemExit("captioning failed")
else:
raise SystemExit("timed out waiting for the job")
The result endpoint redirects to a signed download URL, and results are stored for 24 hours, then deleted, so the completion step should save the captioned file to your own storage. The API documents no webhook surface, which is why the poll loop is the pattern.
Rolling it into a team's listing process
The natural place is wherever the video already goes after the shoot: when the raw file lands, submit a caption job, and publish the captioned render to the listing page and social channels. Because the captions are burned in, they look identical on every site the video touches.