Four credits caption one video up to 10 MB.
Most popular
Accounts begin with 20 complimentary credits.
API
Create a caption job, upload the raw video directly to its short-lived private Storage URL, submit it, and read the result. All examples use JSON except the signed video upload.
Base URLhttps://api.sulphur.dev/v1
AuthenticationAuthorization: Bearer sk_sulphur_…
Caption cost4 credits
Maximum video10 MiB · 10,485,760 bytes
Upload URL life2 hours
Quickstart
Create the job first. The returned idempotency key is the retry token for this exact request.
SULPHUR_API_URL="https://api.sulphur.dev"
curl -X POST "$SULPHUR_API_URL/v1/caption" \
-H "Authorization: Bearer $SULPHUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filename": "example.mp4",
"content_type": "video/mp4",
"size_bytes": 4821930
}'
Upload the raw bytes to upload_url. Do not send the Sulphur API key to this signed URL.
curl -X PUT "$UPLOAD_URL" \ -H "Content-Type: video/mp4" \ --data-binary @example.mp4
curl -X POST "$SULPHUR_API_URL/v1/caption/$JOB_ID/submit" \ -H "Authorization: Bearer $SULPHUR_API_KEY"
curl "$SULPHUR_API_URL/v1/caption/$JOB_ID" \ -H "Authorization: Bearer $SULPHUR_API_KEY"
import time
import requests
base = "https://api.sulphur.dev"
headers = {"Authorization": f"Bearer {SULPHUR_API_KEY}"}
video = open("example.mp4", "rb").read()
created = requests.post(
f"{base}/v1/caption",
headers=headers,
json={
"filename": "example.mp4",
"content_type": "video/mp4",
"size_bytes": len(video),
},
).json()
requests.put(
created["upload_url"],
headers={"Content-Type": "video/mp4"},
data=video,
).raise_for_status()
requests.post(
f"{base}/v1/caption/{created['id']}/submit", headers=headers
).raise_for_status()
while True:
job = requests.get(
f"{base}/v1/caption/{created['id']}", headers=headers
).json()
if job["status"] in {"completed", "failed", "expired", "cancelled"}:
break
time.sleep(2)
print(job)
import { open } from "node:fs/promises";
const base = "https://api.sulphur.dev";
const headers = { Authorization: `Bearer ${SULPHUR_API_KEY}` };
const file = await open("example.mp4", "r");
const video = await file.readFile();
const created = await fetch(`${base}/v1/caption`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
filename: "example.mp4",
content_type: "video/mp4",
size_bytes: video.byteLength,
}),
}).then((response) => response.json());
await fetch(created.upload_url, {
method: "PUT",
headers: { "Content-Type": "video/mp4" },
body: video,
});
await fetch(`${base}/v1/caption/${created.id}/submit`, {
method: "POST",
headers,
});
let job;
do {
await new Promise((resolve) => setTimeout(resolve, 2000));
job = await fetch(`${base}/v1/caption/${created.id}`, { headers })
.then((response) => response.json());
} while (!["completed", "failed", "expired", "cancelled"].includes(job.status));
console.log(job);
Authentication
Create and revoke keys on the Account page. Keys expire, can be limited to read or write access, and expose the raw value only once; Sulphur stores only its SHA-256 hash. Treat a key like a password, keep it server-side, use a separate key per environment, and rotate it immediately if exposed.
Authorization: Bearer sk_sulphur_…
Every API response is non-cacheable. Browser requests from another origin require that origin to be allow-listed by the service operator.
Endpoints
Reserve four credits and create a job in awaiting_upload. Returns HTTP 202.
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | yes | Base filename including a supported extension; at most 255 characters after normalization. |
content_type | string | yes | Supported video MIME type matching the filename extension. |
size_bytes | integer | yes | Exact byte count, from 1 through 10,485,760. |
settings | object | no | Partial caption settings. Unknown fields are rejected. |
webhook_url | HTTPS URL | no | Preview feature. It may return webhooks_unavailable when disabled. |
{
"id": "018f…",
"status": "awaiting_upload",
"filename": "example.mp4",
"content_type": "video/mp4",
"size_bytes": 4821930,
"queue_class": "interactive",
"idempotency_key": "idem_…",
"settings": { "caption_length": "very large", "…": "…" },
"caption": null,
"error": null,
"created_at": "2026-07-16T00:00:00Z",
"submitted_at": null,
"started_at": null,
"completed_at": null,
"credits_remaining": 16,
"upload_url": "https://…",
"upload_method": "PUT",
"upload_expires_at": "2026-07-16T02:00:00Z"
}
Retry creation with the returned value as Idempotency-Key and the identical body. Client-invented or unknown keys return unknown_idempotency_key; changed parameters return idempotency_conflict. A successful retry does not spend credits again and returns a fresh upload URL while the job still awaits upload.
Upload the raw video bytes to the absolute signed URL returned during creation. Send the exact declared Content-Type and byte count. This is a Storage request, not a request to the /v1 API, and it does not use the Sulphur API key.
The legacy route PUT /v1/caption/{job_id}/upload always returns HTTP 410 with direct_upload_required.
Verify the stored size, MIME metadata, filename extension, and container signature, then queue processing. Returns HTTP 202 and the job object. Repeating submit after a job is queued, processing, or complete is safe and returns its current state.
Return one owned job. Poll every few seconds until completed, failed, expired, or cancelled. The caption is present only for completed jobs; error is present only for failed jobs.
List jobs newest first with cursor pagination.
| Query | Default | Rules |
|---|---|---|
limit | 50 | Integer from 1 through 100. |
cursor | — | Opaque next_cursor returned by the previous page; do not parse or edit it. |
{
"items": [ { "id": "018f…", "status": "completed", "…": "…" } ],
"next_cursor": "eyJjcmVhdGVkX2F0…"
}
next_cursor is null on the final page.
Cancel a job only while it is awaiting_upload or queued. The reserved credits are refunded, temporary media is removed, and the cancelled job object is returned. Processing and terminal jobs return job_not_cancellable. Permanent terminal-job deletion is currently available only in the website.
Job schema
| Status | Meaning | Next state |
|---|---|---|
awaiting_upload | Credits reserved; signed upload is open. | queued, expired, cancelled |
queued | Upload verified; waiting for capacity. | processing, failed, cancelled |
processing | Caption generation is running. | completed, failed |
completed | Caption is available. | terminal |
failed | Processing failed and credits were refunded. | terminal |
expired | The upload window ended before submission; credits were refunded. | terminal |
cancelled | A waiting job was cancelled; credits were refunded. | terminal |
queue_class is interactive for the account’s first active jobs and batch above the interactive threshold. Batch work has per-account concurrency limits, so large submissions cannot consume all shared capacity.
The application defaults to 600 authenticated API requests per key per minute and at most 100 active jobs per account. Global, IP, credential, endpoint, and operation-cost limits also apply. Accounts are uncapped by default and can set optional daily and monthly credit caps from the Account page. Operational limits may change without creating a new API version; a rejected request returns HTTP 429 with Retry-After.
Timestamps are ISO 8601 UTC strings or null. Job IDs are UUIDs. The complete resolved settings object is returned on every job.
Caption settings
Send only overrides. Omitted fields use the defaults below.
| Field | Default | Allowed values |
|---|---|---|
caption_length | very large | very small, small, medium, large, very large |
include_watermark_info | false | boolean |
has_thinking | true | boolean |
vulgarity | low | none, low, medium, high |
uncertainty | low | none, low, medium, high |
character_names | none | none, ambiguous, single, multiple |
fluff | none | none, low, medium, high |
has_repetition | false | boolean |
speculation | low | none, low, medium, high |
temporal_detail | medium | static, low, medium, high |
visual_specificity | moderate | generic, moderate, detailed, excessive |
camera_detail | medium | none, low, medium, high |
caption_style | plain | plain, verbose, ornate, robotic |
MP4/M4V, MOV/QT, 3GP/3G2, WebM/MKV, AVI, MPEG/MPG/MPE, OGV/OGG, and FLV are accepted when the MIME type, extension, stored metadata, and detected container agree. The maximum size is 10 MiB. Codec, duration, resolution, and frame-complexity limits may be tightened for safety.
Errors
{
"error": {
"code": "invalid_upload",
"message": "The uploaded video size does not match the declared size.",
"request_id": "req_…"
}
}
Send your own X-Request-ID to correlate a request; otherwise Sulphur creates an ID for error responses. Include the request ID, job ID, and idempotency key—never the API key—when contacting support.
| HTTP | Common codes | Action |
|---|---|---|
| 400 | invalid_upload, invalid_settings, invalid_webhook_url, invalid_idempotency_key, invalid_cursor | Correct the request before retrying. |
| 401 | invalid_api_key, expired_api_key | Check or rotate the API key. |
| 403 | insufficient_scope | Create a key with the required read or write permission. |
| 402 | insufficient_credits | Add credits before creating another job. |
| 404 | job_not_found, unknown_idempotency_key | Check ownership and identifiers. |
| 409 | idempotency_conflict, upload_incomplete, invalid_upload, job_not_submittable, job_not_cancellable | Read the current job and follow the message. |
| 410 | direct_upload_required | Use the returned signed upload URL. |
| 413 | video_too_large | Upload a video no larger than 10 MiB. |
| 422 | invalid_request | Fix missing fields, types, or query limits. |
| 429 | rate_limit_exceeded, too_many_active_jobs, spend_limit_exceeded | Honor Retry-After, back off with jitter, and retry later. |
| 502 | upload_url_failed, job_creation_failed, cancellation_failed, jobs_unavailable | Retry transient failures with exponential backoff and the same idempotency key. |
| 503 | storage_unavailable, webhooks_unavailable | Retry later or remove the optional webhook URL. |
Recommended retry delays are 1, 2, 4, 8, and 16 seconds plus jitter. Do not retry validation or authorization failures unchanged.
Webhooks
Webhook input remains reserved in the v1 request schema, but production rejects it with webhooks_unavailable. Poll the job endpoint instead. Webhooks will remain disabled until per-endpoint secrets, timestamped delivery IDs, durable exponential retries, restart recovery, dead-letter handling, and replay protection are available.
Versioning
The major version is part of the path. Additive fields and new error codes may arrive without a version change; clients must ignore unknown response fields. Breaking request or response changes require a new major version.
Sulphur will provide at least 90 days’ notice before retiring a generally available API major version and will publish the replacement and final support date on this page. Emergency security or legal changes may take effect sooner. The signed upload URL is an opaque, short-lived capability and is not covered by URL-shape compatibility guarantees.
Download the public OpenAPI 3.1 specification · API changelog
New here?
Accounts begin with 20 credits.
Sulphur
Choose a video up to 10 MB. Each caption uses 4 credits and is stored securely with your caption history.
No caption jobs yet.
Caption
Defaults match the current Sulphur caption model.
Account
Optionally limit how many credits can be submitted for captions each UTC day or month. Leave a field blank for no cap. A caption is accepted only when its 4-credit charge stays within the limit.
Choose a credit pack, then continue to checkout.
Development checkout — no payment will be taken.
Keys have access to your credits and caption jobs. A new key is shown only once.
No API keys yet.
Permanently delete your account, API keys, caption history, and videos that are not shared by another authorized job.
Blog
Writing on the tools, the process, and what comes next.
No posts yet.
Support
Start with the guidance below. If that does not resolve it, send a request from your account and track the response here.
Processing failures automatically return the four reserved credits. Include the job ID if the balance did not recover.
Refresh the Account page first. For a mismatch, include the job ID, expected balance, and approximate time—never an API key.
Include the endpoint, HTTP status, error code, request ID, and job ID. The complete reference is on the API page.
Delete terminal jobs from Caption history or permanently delete the whole account from the Account page.
Signed-in requests are the fastest way to preserve account context.
Your account securely supplies the reply address and lets you track status without putting private details in an email.
Sign inNo support requests yet.
Sulphur