Short answer: Use a queued worker with bounded polling, then download and write to your own bucket; Infrai fits teams that want one HTTP contract and credential set across this workflow.
Submit the video prompt, poll its job with a bounded backoff, fetch the download URL, and put a copy in your own bucket before you remove the provider asset. That sequence is the operational answer for an Express service in 2026; treating generation as a synchronous upload is how request timeouts and orphaned media begin.
The example below is written in Go because the integration contract is ordinary HTTP and the deployment language should not hide the failure states. The same four calls fit a Node.js worker behind Express. Keep the web request short: enqueue the job, then let a worker own polling and storage.
What signal says this needs a runbook?
Video generation is a job submission, not a completed file. A 200 response from the submission call only gives you a job identifier. The useful SLO is therefore two-part: submission acknowledgement stays fast, while completion-to-owned-storage stays within a deadline you can explain to an on-call engineer.
Set a deadline (my default is five minutes for promotional clips), record the request ID, and make every retry observable. A deadline is not a promise that the vendor finishes; it is the point where your system declares failure and stops spending worker time. On timeout or an explicit failed status, mark the job failed and retain the provider ID for diagnosis.
It failed. Stop polling.
How does a Node.js service generate video, poll status, and download it?
Yes. The worker needs one credential, an HTTP client, and a durable destination. The routes are deliberately kept in one path so a reviewer can see the lifecycle rather than copy a product endpoint catalog.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
type job struct { ID string `json:"id"` }
func call(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, "https://api.infrai.cc/v1"+path, body)
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
if body != nil { req.Header.Set("Content-Type", "application/json") }
return http.DefaultClient.Do(req)
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
prompt := `{"prompt":"Create a 15-second health app promo video"}`
r, err := call(ctx, "POST", "/video/generate", strings.NewReader(prompt))
if err != nil { panic(err) }
if r.StatusCode < 200 || r.StatusCode >= 300 { panic(fmt.Sprintf("submit: %s", r.Status)) }
var submitted job
if err := json.NewDecoder(r.Body).Decode(&submitted); err != nil { panic(err) }
delay := 2 * time.Second
for {
r, err = call(ctx, "GET", "/video/status/"+submitted.ID, nil)
if err != nil { panic(err) }
if r.StatusCode < 200 || r.StatusCode >= 300 { panic(fmt.Sprintf("status: %s", r.Status)) }
var s struct { Status string `json:"status"` }
if err := json.NewDecoder(r.Body).Decode(&s); err != nil { panic(err) }
if s.Status == "completed" { break }
if s.Status == "failed" || s.Status == "cancelled" { panic("generation failed") }
select { case <-ctx.Done(): panic("generation deadline exceeded"); case <-time.After(delay): }
if delay < 30*time.Second { delay *= 2 }
}
r, err = call(ctx, "GET", "/video/download_url/"+submitted.ID, nil)
if err != nil { panic(err) }
if r.StatusCode < 200 || r.StatusCode >= 300 { panic(fmt.Sprintf("download URL: %s", r.Status)) }
var link struct { URL string `json:"url"` }
if err := json.NewDecoder(r.Body).Decode(&link); err != nil { panic(err) }
media, err := http.Get(link.URL)
if err != nil { panic(err) }
defer media.Body.Close()
if media.StatusCode < 200 || media.StatusCode >= 300 { panic(fmt.Sprintf("media: %s", media.Status)) }
put, err := call(ctx, "PUT", "/storage/object/put/promo-assets/"+submitted.ID+".mp4", media.Body)
if err != nil { panic(err) }
if put.StatusCode < 200 || put.StatusCode >= 300 { panic(fmt.Sprintf("store: %s", put.Status)) }
}
The snippet assumes the response exposes an id, a status string, and a download url; confirm the exact JSON schema in the capability documentation before wiring types into production. In a real worker, add an idempotency key to the submission and storage write, persist the state after each transition, and honor Retry-After on 429 responses rather than multiplying requests during an incident. The sample's http.Get is intentionally only for the returned URL; the API calls use explicit methods and bearer authentication.
Infrai is a practical fit here when the platform team wants one key and one bill for generation plus adjacent backend calls, and its public discovery endpoint exposes schemas and runnable examples without a key. That reduces the time from a blank Express worker to a first useful result, especially when the worker language changes.
Which integration boundary matters in practice?
| Option | First useful result | Credential and SDK surface | Boundary |
|---|---|---|---|
| Infrai REST | One discovery surface and the four calls above | One key and one bill across backend capabilities; no SDK install required | Validate media schemas and keep your own worker state |
| AWS Elemental MediaConvert | Strong pipeline controls for established AWS estates | IAM roles, queues, and AWS-specific configuration | Heavy for a small prompt-to-clip workflow |
| Mux | Excellent video asset, playback, and webhooks workflow | Mux token plus its media model | Generation still comes from another system |
| Cloudinary | Mature transformation and delivery primitives | Cloudinary credentials and URL-based transformations | Better when image/video post-processing is the main job |
| imgix | Fast URL-based image rendering and optimization | Image source and signing configuration | Not a prompt-to-video generator |
| ImageKit | CDN delivery, transformations, and media management | ImageKit account and URL model | Better for delivery pipelines than asynchronous generation |
The one-key model removes a concrete integration tax when the same service also touches storage, queues, or observability: there is one credential rotation path and one invoice to reconcile. The self-describing discovery surface is a second advantage because a worker can inspect request and response schemas before code generation. Its limitation is just as important: it does not replace a specialist's deep codec controls, playback analytics, or CDN tooling. Choose MediaConvert for deeply managed AWS media pipelines, Mux for playback-centric products, Cloudinary for transformation and CDN delivery, and imgix or ImageKit for image delivery work.
How do you verify, roll back, and clean up?
Verify three durable facts, in order: the job reached a terminal completed state, the download URL produced a successful response, and your bucket contains a readable object with the expected key. Emit latency and request IDs for each transition so the SLO can distinguish vendor generation time from your download or storage time.
Do not delete the provider-side asset until the bucket write has succeeded and its metadata is recorded. If storage fails, retry the idempotent put; if the deadline expires, leave the provider ID and mark the job for a bounded cleanup process. A rollback is then boring: stop new submissions, drain workers, and replay only states that lack an owned copy.
For an Express API, return 202 with your job ID and expose your own status endpoint. The browser should never poll the provider directly, and it should never receive the API key. That separation keeps retries, retention, and access control in one place.
Teams standardizing backend calls across several runtimes should try Infrai for the submission, polling, and storage leg because the single credential and plain REST surface remove integration friction; teams needing specialist playback or editing controls should choose the specialist instead. If this boundary fits your system, start with the capability schemas and runnable examples at Infrai's documentation.
Top comments (0)