curl and jq video step for CI with exit codes 0, 2 and 3
A bash script that submits to Sume POST /v1/videos, polls with jq, and exits 2 on a rejected submit and 3 on a failed render so a CI job can tell them apart.

A CI video step needs three outcomes it can tell apart: success, a request Sume refused, and a render that started and failed. The bash and jq script below returns 0, 2 and 3 for those, so a pipeline can retry the third and stop on the second.
Teams that scripted the removed OpenAI Videos API (the deprecations page lists it as removed on 2026-09-24) usually had a one-line curl. The port needs a loop, because Sume's POST /v1/videos answers 202 and the work finishes later.
What each exit code means
The submit either returns a polling_url or it returns an error envelope. If jq cannot find polling_url, the script prints the body to stderr and exits 2. That covers 400 unsupported_parameter, 402 insufficient_credits, 401 and 404. None of those get better by retrying the same body.
Once polling starts, the statuses are pending, in_progress, completed, failed and cancelled. The script treats the first two as keep waiting and the third as done. Everything else prints the job's error string, a plain string on this surface, and exits 3.
| Exit | Cause | CI action |
|---|---|---|
| 0 | completed, file saved | Continue |
| 2 | submit refused, no polling_url | Fix the request, do not retry |
| 3 | status failed or cancelled | Read the error, then retry once |
| other | curl or jq error | Check network and tools |
The script
It needs bash, curl and jq. Export SUME_API_KEY and optionally ROW_ID, which becomes the Idempotency-Key, so a CI retry of the same step returns the original job instead of a second billed one. The model is gemini-omni-flash-1.1 at 4 seconds, 720p and 16:9, inside its 3 to 10 second window.
#!/usr/bin/env bash
set -euo pipefail
api=https://api.sume.com/v1/videos
auth=(-H "Authorization: Bearer $SUME_API_KEY")
body='{"model":"gemini-omni-flash-1.1","prompt":"Paper boat drifting on a rain puddle","duration":4,"resolution":"720p","aspect_ratio":"16:9"}'
job=$(curl -sS --max-time 60 "${auth[@]}" -H 'Content-Type: application/json' \
-H "Idempotency-Key: ${ROW_ID:-shell-demo-1}" -d "$body" "$api")
url=$(jq -er '.polling_url' <<<"$job") || { echo "$job" >&2; exit 2; }
while :; do
job=$(curl -sS --max-time 30 "${auth[@]}" "$url")
case $(jq -r .status <<<"$job") in
pending|in_progress) sleep 10 ;;
completed) break ;;
*) jq -r '.error // "failed"' <<<"$job" >&2; exit 3 ;;
esac
done
curl -sS --max-time 120 -o "${OUT:-clip.mp4}" "$(jq -r '.unsigned_urls[0]' <<<"$job")"
echo "saved ${OUT:-clip.mp4}, cost $(jq -c .usage <<<"$job")"
Details that bite
Use set -euo pipefail, but remember that a failed command inside a command substitution can still end the script before your message prints. The submit uses jq -e with an explicit fallback so you see the error body first.
Give every curl a --max-time. CI runners kill hung steps with no output, and a bounded timeout produces an error you can read. Do not put the API key on the curl command line with -u or in a logged variable; the script reads it from the environment into a header array.
A callback_url on the submit plus a webhook receiver is better for long renders, but CI has no public endpoint, so polling is the right default here.
- Set ROW_ID from the pipeline run and step, never random.
- Add a total deadline around the loop with timeout(1) if your runner has it.
- Upload clip.mp4 as a build artifact, not into the repo.
Wiring it into a pipeline
In most CI systems a step fails on any nonzero exit, so exit 3 already stops the job. If you want a retry on exit 3 only, wrap the call in a small loop in the pipeline file that tests the code, runs the script once more, and gives up on exit 2 immediately. Keep ROW_ID identical across the retry only when the first job never started; after a failed render, use a new suffix so Sume creates a fresh job rather than returning the failed one.
Keep the prompt and parameters in version control next to the script, so a failed step can be reproduced from the commit alone.
Sources
Related posts
More in Developers
- curl a 30-second Sume clip: --speed-limit beats a blunt --max-time
A fixed --max-time kills a slow but healthy 30 s video download. Use --speed-limit with --speed-time to abort only stalled transfers, plus --retry.
- Cut a 3-minute Short from a longer render with video trim
YouTube Shorts and Instagram's recommended Reel length both stop at three minutes. Cut 180 seconds from a longer clip with video trim, using start and duration.
- Debug a Sume webhook signature mismatch offline: a diagnosis script
Saved a failing Sume webhook? This Python script tells you if it was a stale timestamp, an empty secret, a mutated body or the wrong secret. Self-test included.
- Dedupe Sume webhooks with SQLite: INSERT OR IGNORE on job_id and event
Sume retries up to 10 times and Redeliver repeats events. A stdlib SQLite table keyed on job_id and event makes your video webhook handler safe to run twice.
Written by Sume