2026-10-29 · 5 min read
Designing Around a Webhook-Free Caption API: Polling Patterns That Hold Up
Every async video API answers the same question differently: how does your code find out the job finished? Some providers push a webhook to your server. caption.sh takes the simpler route: the API documents no webhook surface, and your integration polls GET /v1/jobs/{id} until the status flips. That is a deliberate trade. There is no endpoint secret to rotate, no retry logic to match, no signature verification, and no public URL to expose before you have earned the first render.
The shape of a correct poller
A polling loop needs three exits, not one: the job finished, the job failed, or your own patience ran out. The third exit is the one people forget, and it is the one that turns a stuck render into a hung worker. Give every poller a deadline that matches the content: short clips can time out in a few minutes, long lessons need more.
import time, requests, sys
KEY = "YOUR_CAPTION_API_KEY"
HEADERS = {"Authorization": f"Bearer {KEY}"}
def caption(video_url: str) -> str:
r = requests.post(
"https://api.caption.sh/v1/videos",
headers=HEADERS,
json={"source_url": video_url, "options": {"font": "inter"}},
timeout=30,
)
job_id = r.json().get("job_id")
if not job_id:
sys.exit(f"submit failed: {r.status_code} {r.text}")
deadline = time.time() + 15 * 60
while time.time() < deadline:
job = requests.get(
f"https://api.caption.sh/v1/jobs/{job_id}", headers=HEADERS, timeout=30
).json()
if job.get("status") == "done":
return job_id
if job.get("status") == "error":
sys.exit(f"job failed: {job}")
time.sleep(5)
sys.exit(f"gave up waiting for job {job_id}")
# then: GET /v1/jobs/{job_id}/result follows a 302 to a signed URL; results are stored for 24 hours, then deletedConcurrency without a push channel
Polling does not limit parallelism. Jobs are independent, so a backlog of videos can all be in flight at once, each with its own poll loop or shared scheduler. The constraint is per-key throughput: the API returns 429 when you push past it, and the correct response is to back off and retry the submit, not to abandon the work. A queue in front of the submit call, with a bounded number of active pollers, keeps the whole system predictable.
- Keep poll intervals in the seconds, not milliseconds: a 5 to 10 second cadence is plenty for renders that take a minute or more.
- Log job IDs. When a render looks stuck, the job ID is the entire debugging surface, and support conversations start with it.
- Download results promptly. Results are stored for 24 hours and then deleted, so download promptly.
The webhook-free design will not suit a system that needs sub-second completion events. For captioning, where the output is a file a human will watch, a poll that lands a few seconds after the render finishes is indistinguishable from a push, and the integration code fits on one screen.