Railway cron job that submits a Sume job and exits (Python)
Railway cron services must exit when done, skip a run while the last one is still going, and start at most every 5 minutes. Submit in webhook mode and exit.

A Railway cron service should do one thing: submit the Sume job in webhook mode, print the job id and exit with code 0. Railway starts the service on the schedule, but it expects the process to terminate, and it will not start a new run while the previous one is still alive, so a script that waits on generation can silently suppress the following runs.
Treat the cron as a trigger, not a worker. The finished asset arrives at a separate webhook receiver, and the cron's only memory is the Idempotency-Key it derives from the time bucket.
Railway's cron rules, as documented
Railway's cron page says the schedule is evaluated in UTC, the minimum gap between runs is 5 minutes, and the service must exit when its task is complete, otherwise it is treated as still running. If a run is still going when the next is due, the new run is skipped. Start times can drift by a few minutes, so do not rely on a precise second.
Each of these maps to a decision. The 5-minute floor makes a Railway cron unsuitable for sub-minute polling, which Sume does not need anyway when a webhook delivers. The skip-on-overlap rule means a hung process costs you every following run, so set a request timeout on the submit and let the process end. The drift means the key should name a bucket, such as the hour, not a minute.
| Railway rule | Documented behavior | In the Sume script |
|---|---|---|
| Time zone | UTC | Build the key from UTC |
| Frequency | At least 5 minutes apart | Hourly or daily bucket |
| Completion | Service must exit when done | Submit, print, sys.exit |
| Overlap | New run skipped while the old one runs | Request timeout of 20 seconds |
| Timing | May vary by a few minutes | Bucket the key, not the minute |
The script
Webhook mode returns a 202 immediately, so the process lives a second or two. A replay inside the same hour returns the first job, which makes a manual redeploy-triggered run harmless.
import os, sys, datetime, requests
bucket = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H")
r = requests.post(
"https://api.sume.com/v1/images",
headers={
"Authorization": f"Bearer {os.environ['SUME_API_KEY']}",
"Idempotency-Key": f"hourly-card-{bucket}",
},
json={
"model": "sume/auto",
"prompt": "Clean data-center photo, blue accent, no text",
"mode": "webhook",
"webhook_url": os.environ["SUME_HOOK_URL"],
},
timeout=20,
)
if r.status_code != 202:
print("submit failed", r.status_code, r.text[:300])
sys.exit(1)
d = r.json()["data"]
print("job", d["job"]["id"], "replay", d["idempotency_hit"])
sys.exit(0)When the submit fails
Exiting non-zero marks the run failed in Railway, but nothing retries it until the next schedule. For a transient 429 or 503, add a short in-process retry with the same key, bounded well below the interval. For a 400, fail loudly: the prompt is wrong and the next run will fail the same way.
Sources
Related posts
More in Developers
- Read a completed Sume /v1/videos poll response, field by field
What id, generation_id, polling_url, status, unsigned_urls and usage.cost mean on a finished Sume video job, and which to store.
- Read capabilities from the Video Router models list before you pin
GET /v1/video-router/models returns capabilities per model. Why the docs say to read them instead of assuming one envelope, and what differs by model.
- Read job events for a stuck narration take: a snapshot, not a stream
GET /v1/jobs/:id/events lists job.created, queued, started, generation.submitted and the terminal event. A pull snapshot for debugging a TTS or music take.
- Debug a slow Sume job with GET /v1/jobs/:id/events
A slow Sume job is queued, running, or waiting on your webhook. The events timeline separates them: job.queued, job.started, terminal, webhook.delivery.
Written by Sume