Calling an AI video API looks simple in a demo:
- Send a prompt.
- Wait for a response.
- Show the video.
In production, the model rarely returns a video in the original HTTP request. It creates a remote job, gives you an external task ID, moves through provider-specific states, and eventually returns either a media URL or an error. Your application has to keep user state, billing state, moderation state, and provider state consistent while that happens.
This article presents a provider-neutral reference architecture for products such as MICT, an AI image and short-video workspace. The code samples are illustrative rather than a verbatim description of MICT's production implementation. The useful part is not a particular API call; it is the set of boundaries around that call.
The real workflow is a state machine
A reliable generation request has more stages than “loading” and “done”:
request
-> authenticate
-> validate model settings
-> moderate input
-> calculate cost
-> create and persist local task
-> reserve or deduct credits
-> create provider task
-> attach external task ID
-> poll provider
-> normalize provider state
-> moderate output
-> complete or fail
-> refund when required
Treating this as an explicit state machine gives every layer a shared vocabulary. A compact internal set is usually enough:
type GenerationStatus =
| "running"
| "processing"
| "completed"
| "failed";
Providers can keep their own vocabulary at the edge:
function mapProviderState(state: string): GenerationStatus {
switch (state) {
case "waiting":
case "queuing":
return "running";
case "generating":
return "processing";
case "success":
return "completed";
case "fail":
return "failed";
default:
return "running";
}
}
The important design choice is that the rest of the product never branches on every provider's spelling. The adapter does that once.
Keep local and external task IDs separate
Create and persist your own task before calling the provider.
const taskId = createLocalTaskId();
await persistGeneration({
taskId,
userId,
status: "running",
});
const externalTask = await provider.createTask(providerModel, input);
await attachExternalTaskId({
taskId,
externalTaskId: externalTask.id,
});
The local ID belongs to your product. Use it for:
- authorization checks;
- generation history;
- credit transactions;
- support references;
- analytics;
- idempotency.
The external ID belongs to the provider. Use it only when querying or canceling the remote job.
This separation matters when providers change, when one provider is temporarily replaced, or when a support ticket refers to a local job that never received an external task ID.
Validate settings before money moves
Different video models accept different combinations of mode, duration, resolution, aspect ratio, output count, and audio settings. Do not let the client invent those combinations.
Keep capabilities in a server-side model configuration:
type ModelConfig = {
modes: Array<"t2v" | "i2v">;
durations: number[];
resolutions: string[];
aspectRatios: string[];
calculateCredits(input: {
duration: number;
resolution: string;
outputCount: number;
}): number;
};
Then reject unsupported values before moderation, billing, or provider submission:
if (!config.modes.includes(mode)) {
throw new RequestError("Unsupported generation mode");
}
if (!config.resolutions.includes(resolution)) {
throw new RequestError("Unsupported resolution");
}
Client-side validation is useful for feedback. Server-side validation is the boundary that protects your bill and your data.
Put moderation before generation
Input moderation should happen before the provider task is created and before credits are charged.
const moderation = await moderateRequest({
userId,
model,
mode,
prompt,
imageUrl,
});
if (!moderation.allowed) {
return {
ok: false,
code: moderation.code,
creditsCharged: false,
};
}
This avoids paying the upstream provider for a request your own policy would reject. It also lets the UI distinguish a policy decision from a technical failure.
Output moderation is a separate gate. A provider can successfully generate media that your application should not release. The output should not become completed until that gate passes.
provider success
-> extract result URL
-> moderate result
-> completed
provider success
-> extract result URL
-> output blocked
-> failed with a policy-safe message
Do not collapse input moderation, provider policy errors, and output moderation into one generic “generation failed” toast. They have different retry rules and support paths.
Make credit operations idempotent
The most damaging bugs in an AI generation product are often financial state bugs:
- the provider rejected the request but credits stayed deducted;
- polling ran twice and refunded twice;
- a database write failed after the provider task started;
- the browser retried a request after a network timeout.
Every charge should have a stable source key:
await decreaseCredits({
userId,
amount: creditCost,
sourceType: "generation_charge",
sourceId: taskId,
});
A refund should reference that same source:
await refundCreditsBySource({
sourceType: "generation_charge",
sourceId: taskId,
transactionType: "refund",
});
The database should enforce uniqueness for the relevant source or transaction key. Application-level if (!refunded) checks are helpful, but they are not enough under concurrent polling.
You need at least two refund paths:
- The provider task could not be created.
- The provider accepted the task, but it later reached a terminal failure.
try {
const external = await provider.createTask(modelId, input);
await attachExternalTaskId({ taskId, externalTaskId: external.id });
} catch (error) {
await refundByGenerationSource(taskId);
throw error;
}
For terminal failure during polling:
if (status === "failed") {
await refundByGenerationSource(taskId);
await markGenerationFailed(taskId, publicErrorMessage);
}
When refunds are idempotent, repeated polling becomes much less frightening.
Polling should be boring
WebSockets can improve perceived responsiveness, but polling is often the most robust first implementation for provider jobs that take seconds or minutes.
The status endpoint should:
- authenticate the request;
- load the local generation;
- verify that it belongs to the user;
- return immediately for local terminal states;
- query the provider only for non-terminal jobs;
- normalize the result;
- update local state;
- return a small, stable response.
type StatusResponse = {
status: GenerationStatus;
progress?: number;
resultUrl?: string;
errorCode?: string;
errorMessage?: string;
};
The client does not need the provider's raw payload. Raw payloads leak implementation details and make frontend behavior depend on unstable third-party schemas.
A reasonable browser loop looks like this:
async function waitForGeneration(taskId: string) {
while (true) {
const result = await getStatus(taskId);
if (result.status === "completed") return result;
if (result.status === "failed") throw new Error(result.errorMessage);
await delay(5000);
}
}
Production code should also stop polling when the component unmounts, pause or slow down in a hidden tab, and apply a maximum client-side wait. The job can continue on the server even if the browser stops asking.
Parse provider results defensively
Even one provider may return different result shapes across models:
{ "resultUrls": ["https://..."] }
{ "video_url": "https://..." }
{ "output": [{ "url": "https://..." }] }
Keep result extraction inside the adapter and accept only a non-empty string:
function extractResultUrl(raw: string): string | undefined {
const result = JSON.parse(raw);
const candidates = [
result.resultUrls?.[0],
result.urls?.[0],
result.output?.[0]?.url,
result.videoUrl,
result.video_url,
];
return candidates.find(
(value): value is string =>
typeof value === "string" && value.length > 0
);
}
A provider saying success without a usable result URL is not a completed user experience. Keep the job non-terminal or move it to a clearly diagnosed failure path.
Do not let a late success overwrite a failure
Asynchronous systems can produce awkward orderings:
- a job appears successful;
- output moderation starts;
- another process marks the generation failed;
- a late update tries to mark it completed.
Use a conditional update:
update generations
set status = 'completed', result_url = $1
where task_id = $2
and status <> 'failed';
If the update affects zero rows, reload the current state and return that state. Terminal safety decisions should win over late success events.
Preserve error categories
Users need different actions for different errors:
| Error category | User action |
|---|---|
| insufficient credits | add credits or choose cheaper settings |
| provider unavailable | retry later |
| input policy blocked | revise the request |
| provider policy blocked | revise the request |
| output policy blocked | contact support only if the decision seems wrong |
| unknown technical failure | retry or contact support with the task ID |
Return stable error codes and separately maintain a user-safe message:
throw new RequestError(
"We could not start this generation right now.",
"provider_unavailable",
{ creditsCharged: false }
);
Do not expose upstream account balances, internal model routes, or raw policy messages in the browser.
What to test
A happy-path video is not enough. Test the state transitions:
- invalid model settings do not charge credits;
- input moderation rejection does not charge credits;
- provider creation failure refunds once;
- provider terminal failure refunds once;
- repeated status polling does not duplicate refunds;
- one user cannot read another user's task;
- provider success without a URL does not become completed;
- output moderation failure cannot be overwritten by a late success;
- terminal local states do not keep querying the provider;
- unknown provider states stay recoverable.
The best tests target invariants:
one accepted generation <= one charge
one failed charged generation <= one refund
completed => usable result URL
failed-by-policy => result URL is not released
task owner => only user allowed to read status
The larger lesson
An AI video interface is a distributed system with a creative UI attached to it. The model call is only one step. The product becomes trustworthy when every other step—validation, authorization, billing, polling, moderation, and failure recovery—has an explicit boundary.
Start with a small internal state machine. Keep provider vocabulary at the edge. Give every charge a stable source. Make refunds idempotent. Treat moderation as part of the lifecycle, not a preflight checkbox. Then make polling intentionally boring.
That foundation is less glamorous than a generation demo, but it is what lets the demo become a product.
Disclosure: This article was prepared for MICT, which is referenced once as the product context for this architecture. AI tools assisted with drafting and editing; the code, claims, and final text were reviewed before publication.
Top comments (0)