How to connect an async API job
Submit a long-running video or AI job from a web app, keep the provider key on the server, and show the result without losing track of the request.
If your video API returns a job ID instead of a finished video, your app has not failed. The provider accepted work that will finish later. Save that ID, check the job's state from your server, and show the result only when it is ready.
This guide uses a Runpod queue-based Serverless endpoint as an example. It also applies as a design pattern to other asynchronous APIs, but their URLs, states, authentication, and result formats differ. It does not cover Runpod's load-balancing endpoints, which use a different HTTP model. Runpod endpoint types
Decide who owns each step
Your browser sends a request to your backend. For cookie-authenticated, potentially billable mutations, require an Origin exactly matching your configured application origin and an application/json content type; reject missing or mismatched origins and unsupported content types, do not grant cross-origin access, and set per-user rate and spend limits. If a supported client cannot send an Origin, give it a separately authenticated non-cookie endpoint or a reviewed CSRF-token mechanism. Authentication alone does not prevent cross-site requests. Your backend checks the signed-in user, validates the requested input, creates a job record, and submits the provider request using a server-only key. Store both your own job ID and the provider's returned ID. The browser reads your job record through an authenticated endpoint; it never needs the provider key.
A useful first version has these fields:
- Your job ID and the authenticated owner's ID.
- The provider job ID, once received.
- A submission state (
unsubmitted,submitting,unknown, oraccepted) separate from the accepted job’s provider status, plus timestamps. - A result reference or a safe error message. Store provider details in restricted logs rather than returning raw errors to the browser.
Use your own ID in browser URLs and check ownership on every read. An ID is not an authorization check. Never accept an owner ID from the browser as proof of ownership.
In CoreMVP, the existing backend boundary gives this work a place: an API route handles the request, a service coordinates it, a repository stores state, and a server-only provider client calls Runpod. This is a pattern to implement, not a claim that a Runpod integration or job queue is already included.
Submit once, then check status
For a Runpod queue-based endpoint, the server sends a POST to /v2/{endpoint_id}/run, with the provider's expected input in an input object. Runpod returns a job ID; a later GET /v2/{endpoint_id}/status/{job_id} checks it. The input fields and output shape depend on your worker. Runpod currently documents 30-minute result retention after completion for /run; arrange retrieval before it expires. The result reference itself may have a different lifetime. Runpod request guide, operation reference
A conceptual flow:
- The user selects a source video or provides the input your worker accepts. Validate size, type, duration, and access on the server as appropriate. Do not assume an arbitrary URL or storage object is safe for a worker to fetch. Runpod documents a 10 MB
/runpayload limit. For larger videos, use an owner-authorized, narrowly scoped direct-to-private-storage or resumable upload, validate the completed object and access, and check storage provider and plan limits; pass a safe reference rather than carrying video bytes through a serverless request. Runpod operation reference - Your server creates an
unsubmittedrecord, atomically claims it for one submission, and saves the returned provider ID as soon as possible. See the crash handling below before implementing this step. - Return your job ID and current status to the browser. Poll your own authenticated status endpoint at a bounded interval, with backoff and a visible timeout. The endpoint reads the saved state; the server-side reconciler checks Runpod and updates it.
- Map the provider's documented states to your UI. On completion, validate the result reference and show the video only to an authorized user. On failure, show a safe status and the available recovery action without exposing secrets.
A slow response does not prove that submission failed. Keep submission state separate from execution status:
unsubmitted: the durable record exists and no worker has claimed it. A worker may atomically transition it tosubmitting, with a unique claim token and timestamp, before any provider request. Only the worker that wins this conditional transition may call/run.submitting: the call may be in flight, or the worker may have crashed before or after sending it. Do not let another worker submit it. A stale claim transitions tounknown, never back tounsubmitted.unknown: the provider may have accepted a billable job, but its ID was not durably saved. Do not automatically resubmit, even after a timeout, malformed response, process crash, or ambiguous HTTP error. Escalate for investigation and explain the uncertainty to the user. Reconcile only a known provider ID; do not assume Runpod offers lookup by your local ID. If the outcome remains unknown, a new request requires an explicit user decision with the duplicate-cost risk explained.accepted: persist the returned provider ID and transition fromsubmittingatomically, conditional on the claim token and expected state. If the process dies after receiving the ID but before this write, the stale claim still becomesunknown. A late response must not override that state. Once the ID is saved, poll that ID; do not call/runagain.
A client-generated request key scoped to the signed-in owner and a database uniqueness constraint prevent duplicate local records from repeated clicks. They do not make the external call idempotent. Do not treat an HTTP response as proof of non-acceptance unless the provider documents that guarantee for that exact response. Runpod's /retry operation applies to a known failed or timed-out job ID; it is not a recovery method for a submission with an unknown ID. Review its billing and behavior before offering it.
A concrete server-side request
The following JavaScript shows the provider boundary, not a complete route. It assumes your server has already authenticated the user, validated the input for your worker, and atomically claimed an unsubmitted record as submitting as described above. input is illustrative: replace it with the fields your own worker requires. This example makes real, potentially billable requests if invoked, so do not run it until you have a test endpoint and have checked its pricing. Keep the environment values on the server; do not put them in browser code or logs.
// Server-only provider client. Do not call from a browser.
if (!process.env.ENDPOINT_ID || !process.env.RUNPOD_API_KEY) {
throw new Error("Server-side Runpod configuration is missing");
}
const base = `https://api.runpod.ai/v2/${encodeURIComponent(process.env.ENDPOINT_ID)}`;
const headers = {
Authorization: `Bearer ${process.env.RUNPOD_API_KEY}`,
"Content-Type": "application/json",
};
async function submit(input) {
let response;
try {
response = await fetch(`${base}/run`, {
method: "POST",
headers,
body: JSON.stringify({ input }),
signal: AbortSignal.timeout(30_000),
});
} catch {
// It may have been accepted before the connection failed.
return { kind: "uncertain" };
}
// Without a documented guarantee of non-acceptance, any error is ambiguous.
if (!response.ok) return { kind: "uncertain" };
const body = await response.json().catch(() => null);
if (!body || typeof body.id !== "string" || !body.id) {
return { kind: "uncertain" };
}
return { kind: "accepted", providerId: body.id };
}
async function check(providerId) {
let response;
try {
response = await fetch(`${base}/status/${encodeURIComponent(providerId)}`, {
headers,
signal: AbortSignal.timeout(30_000),
});
} catch {
return { kind: "unavailable" };
}
if (response.status === 429) return { kind: "rate_limited" };
// A missing or expired job is not proof that it failed.
if (response.status === 404) return { kind: "unknown" };
if (!response.ok) return { kind: "unavailable" };
const body = await response.json().catch(() => null);
switch (body?.status) {
case "IN_QUEUE": return { kind: "queued" };
case "IN_PROGRESS": return { kind: "running" };
case "COMPLETED": return { kind: "completed", output: body.output };
case "FAILED": return { kind: "failed" };
case "CANCELLED": return { kind: "cancelled" };
case "TIMED_OUT": return { kind: "timed_out" };
default: return { kind: "unknown" };
}
}For example, a successful submission returns an object containing an id and a state such as IN_QUEUE. Persist the provider ID with the conditional submitting → accepted transition before returning your own ID to the browser. On an uncertain submission, keep the record as uncertain and investigate instead of submitting again. For status checks, use bounded exponential backoff with jitter on 429; never use that rule to resubmit an ambiguous /run request. Limit concurrency; do not use a tight polling loop. A network timeout, HTTP 408, or 5xx during submission may still have created a billable job. Runpod rate-limit guidance
When checking a known job, update the record from the mapped state. A temporary error or unknown state must not overwrite a previously verified completed result. For completed, validate the worker-specific output and securely persist or copy the result before recording completion. Return only a safe status and your own job ID through the browser-facing status route, after checking the current user's ownership. Never return this sample's raw output object directly; it may contain a private provider URL.
For playback, expose an authenticated same-origin stream that checks ownership for every request, including range requests, or issue a short-lived, narrowly scoped signed URL only after an owner check. That signed URL is a temporary bearer credential: use HTTPS, keep it out of logs and analytics, and prevent unintended caching or referrer leakage. Do not return a reusable private object URL or provider credential to the browser. If the worker returns a URL, allow only expected HTTPS schemes, hosts and ports; resolve and block non-public addresses including private, loopback, link-local, and metadata addresses, and pin the connection to a validated IP while preserving TLS hostname verification. Reject redirects by default. If redirects are necessary, repeat all these checks and pinning for every destination before connecting. Do not fetch arbitrary user-controlled URLs.
Runpod distinguishes three clocks. executionTimeout limits active worker time, while job ttl begins at submission and includes queue time; TTL expiry can remove a running job and make status return 404. Completed /run results have a separate 30-minute retention period. Check current endpoint settings and choose a TTL with queue-time headroom; a browser timeout does not cancel a job. Runpod execution policies
Capture results even when the browser closes
Here is one design for a CoreMVP application deployed on Vercel with Supabase Postgres. It adds a reconciliation job; it is not an integration or scheduler that CoreMVP already ships. It also does not promise delivery if the scheduler or provider is unavailable long enough for the result to expire.
- Add a reviewed SQL migration for a
video_jobstable. Store your job ID, owner ID, unique provider ID when known, status,next_check_at, a lease token and expiry, result object key, and timestamps. Enforce owner access through the application service; do not rely on a browser-provided owner ID. Do not store API keys, signed URLs, or raw provider errors in the row. Define a uniqueness constraint on the provider ID and an index for due jobs. Follow the CoreMVP database workflow. - Add a job service under and repository functions under . Register a machine-only
GET /api/internal/runpod-reconcileHono route in and . The route verifies Vercel'sAuthorization: Bearer <CRON_SECRET>against a server-only secret before calling the service. Return 401 when it is absent or wrong. Never use a public key or a browser session as the scheduler credential. Follow Create API Endpoint and check the route's actual middleware so this machine request is not accidentally exposed or blocked. The service uses the server-only Runpod client above. - On a Vercel plan that supports the required interval, configure a once-per-minute cron in and set
CRON_SECRETin the deployment's server environment. For illustration, this is the proposed schedule, not a command to deploy:
{
"crons": [
{ "path": "/api/internal/runpod-reconcile", "schedule": "* * * * *" }
]
}Vercel's documented Hobby plan runs cron at most daily, which is too slow for Runpod's 30-minute result retention. Pro and Enterprise support a one-minute interval. Cron invokes production deployments, is subject to function limits, and can overlap or run more than once. Check current Vercel scheduling limits and cron security and behavior before choosing this option.
4. On each invocation, the repository atomically claims a bounded batch of due, nonterminal jobs using a short lease. For example, use a transaction with row locking and SKIP LOCKED, assign each row a fresh random lease token and expiry, and commit before making HTTP requests. Do not keep a database transaction open while polling or transferring video. Expired reconciliation leases on accepted jobs become eligible for a later run; submission claims follow the separate uncertainty rule above. Limit batch size and parallel requests so a run can finish within its function timeout and provider limits.
5. Claim only accepted jobs with known provider IDs; stale submitting claims become unknown and are never resubmitted. For each claimed accepted job, call /status once. If queued or running, set a later next_check_at with bounded backoff and release the lease. If Runpod returns 429 or a temporary failure, schedule a later check with jitter; do not resubmit the job. Set a maximum polling window and attempt budget, then record an operator-visible investigation state and alert rather than retrying indefinitely. On terminal failure, cancellation, or timeout, store a safe terminal state. On 404 or an unknown state, record an investigation state rather than declaring success or inventing a cause.
6. On completion, validate the worker's output. Copy the actual bytes to a new private storage object before marking the job complete. A failed transfer leaves the record eligible for another status/result capture attempt while the result remains available. Store only the private object key in Postgres. Fence every reconciliation state write and lease release—including errors, rescheduling, terminal states, and completion—on the same unexpired lease token and a valid expected state transition. Make completion conditional on the job still being nonterminal, so an older worker cannot overwrite a newer result. If competing attempts create temporary objects, publish only the object referenced by the winning database update and clean up unreferenced objects separately. Do not assume a database transaction also covers object storage.
7. An authenticated owner reads the stored status and accesses the object through the owner-checked streaming or short-lived signed URL path described above. The existing CoreMVP user-files bucket is documented for small PDF and image uploads, not arbitrary generated video. Choose and configure a private video-capable bucket and its size, MIME, and access policies for this feature rather than assuming the existing bucket accepts video. CoreMVP Storage
Size the entire worst-case polling, backlog, retries, transfer and scheduling budget below the 30-minute completed-result retention window, accounting for missed invocations and jitter. Measure throughput and backlog under realistic load; a one-minute schedule alone cannot guarantee capture, especially during outages or when capacity is unknown. Alert before the budget is exhausted.
For example, a job accepted at 10:00 can still be found by the 10:01 scheduled run after the user closes the tab. If the first status check reports IN_PROGRESS, the repository records the next check. A later COMPLETED response triggers a private object copy, and only a successful copy plus the conditional database update marks the job ready. A refresh can then display the saved result without asking Runpod again.
Vercel does not retry a failed cron invocation. A future scheduled invocation must find expired leases and unfinished work. Alert on old due jobs, missed scheduler runs, repeated failures, or capture approaching Runpod's retention limit; if an outage lasts beyond that window, the result can still be lost. Test with a fake provider and storage first: simulate a closed browser, overlapping cron runs, a worker crash after claiming, a copy failure, an expired lease, a duplicate completion, and a 429. Confirm another user cannot read the result. No paid provider call is needed for those tests.
Polling or a webhook?
Polling is the simpler first path. Stop when the job is terminal or the polling window ends; a UI timeout is not proof the provider stopped processing. A later page visit should be able to reload the saved state. Browser-only polling cannot reliably retrieve a result if the user closes the page and it expires. For that case, arrange a durable server-side check or a verified webhook before relying on the integration. A long-running background loop inside an ordinary HTTP request is not a durable job runner.
A webhook lets the provider notify your server when work completes. Runpod accepts a top-level webhook field next to input; its docs say the receiver should return HTTP 200 and that failed deliveries can be retried twice. Handle duplicate deliveries; authenticate them, then durably queue a status check for the known provider ID before acknowledging. Use the same claim and fenced state transitions as the reconciler; an unauthenticated callback must not mutate job state. Runpod webhook details
A public callback is not trustworthy merely because it contains a job ID. Before enabling webhook-driven state changes, verify the provider's current authentication or signature mechanism and implement it. If you cannot authenticate the callback, do not enable it as a state-changing path; use the scheduled reconciler instead. Match the returned job to an existing record and never trust a callback to select a different owner. Polling remains a valid choice if a reliable verification path is unavailable.
Test the complete path
Use a fake provider and fake storage first; the following are checks to implement, not claims of live testing. Confirm that:
- An accepted job survives a browser refresh and eventually shows the actual output.
- A failed job and a job that stays queued have clear, different states.
- A second account cannot read the first account's job or result.
- Repeated clicks and repeated webhook deliveries do not create duplicate local records or apply the same result twice.
- Provider credentials and reusable private result URLs do not appear in browser code, responses, screenshots, or public logs. Any intentionally issued short-lived signed URL is limited to the authorized owner and excluded from logs.
- A crash before sending, during the request, after provider acceptance, or after receiving the ID but before saving it leaves a stale claim uncertain rather than automatically resubmitted. Concurrent submitters issue at most one request per local claim.
- An expired lease holder cannot write any state or release a newer lease, including on errors and rescheduling; browser reads cannot bypass the claim.
- A result URL pointing to a private address, an unapproved port, or a redirect is rejected, and validated addresses stay pinned during retrieval.
Check the provider's result retention, output access, usage limits, and current pricing before relying on the integration. If results expire, copy or otherwise preserve them through an authorized storage path before expiry. Start with the CoreMVP backend guide, then add the job record and status path before connecting the video UI.