The practical question is how to build midjourney integration without turning every image request into a timeout. A user clicks Generate, the request waits for an image, the browser times out, and a refresh loses the state. That demo shape is fine for a proof of concept. It is the wrong contract for a real product.
A proper integration is an asynchronous job system. Your application accepts a request, creates a job, returns quickly, and reports completion later through polling, a webhook, or a realtime channel. The image provider is an implementation detail behind that boundary.
Nextjourney is not affiliated with Midjourney or Discord. Midjourney does not provide an official public API. Third-party wrappers and self-hosted platforms that connect to account sessions carry applicable third-party Terms of Service risk.
How to build Midjourney integration: the architecture
There are five parts worth separating, even in a small product:
| Layer | Owns | Should never own |
|---|---|---|
| Browser or app | Prompt input, job display, authenticated user session | Provider credentials or account sessions |
| Your API | Validation, tenant limits, idempotency, job records | Long-running image generation in the request thread |
| Queue | Durable scheduling and retry timing | Business authorization decisions |
| Worker | Provider adapter, upstream submission, result collection | Public browser traffic |
| Webhook or status endpoint | Completion updates and final asset state | Blind trust in unauthenticated callbacks |
That division sounds heavier than a direct API call, but it prevents the expensive problems: duplicate generations after a retry, leaked keys in frontend code, jobs stuck after a deploy, and users left with no way to find a finished image. The endpoint design in our Midjourney REST API guide is the useful baseline.
Step 1: create a job, not an image request
The first endpoint should return a job id immediately. Validate the prompt, authorize the caller, persist a record, and enqueue work. Do not wait for the upstream image result.
import crypto from "node:crypto";
app.post("/v1/image-jobs", requireUser, async (req, res) => {
const idempotencyKey = req.get("Idempotency-Key");
if (!idempotencyKey) return res.status(400).json({ error: "Idempotency-Key required" });
const existing = await jobs.findByTenantAndKey(req.user.tenantId, idempotencyKey);
if (existing) return res.status(202).json(existing.publicView());
const job = await jobs.create({
id: crypto.randomUUID(),
tenantId: req.user.tenantId,
prompt: req.body.prompt,
status: "queued",
idempotencyKey
});
await queue.add("generate-image", { jobId: job.id }, { jobId: job.id });
res.status(202).json(job.publicView());
});
Use 202 Accepted, not 200 OK, because the generation has not happened yet. The user gets a stable job id; your infrastructure gets a durable handoff. If the mobile network drops after the response, the client can look up the same job instead of firing a duplicate.
A job record needs only a few fields at first: id, tenant_id, prompt, status, provider_job_id, result_url, attempt_count, created_at, and completed_at. Add billing, moderation, and per-user quota data only when your product needs them.
Step 2: make the worker upstream-facing
The worker owns the provider-specific call. It can submit the prompt, store the provider job id, and wait for completion without holding open a browser request. Keep the adapter behind an interface so a provider change does not leak through every route.
async function processGeneration({ jobId }) {
const job = await jobs.get(jobId);
if (job.status !== "queued") return;
await jobs.update(jobId, { status: "submitting" });
const upstream = await provider.submit({ prompt: job.prompt, callbackUrl: callbackUrl(job.id) });
await jobs.update(jobId, {
status: "processing",
providerJobId: upstream.id
});
}
The worker needs access to the upstream account/session layer; the browser does not. That is the same credential boundary covered in Midjourney API authentication. Log opaque job ids and provider error classes, never raw tokens or account cookies.
For a single internal project, polling from the worker is acceptable. For multiple users, accept a provider callback when available and keep a scheduled reconciliation check for jobs that never receive one. Webhooks lower latency; reconciliation prevents a missed callback from becoming a permanently spinning UI.
Step 3: give the frontend a status contract
Your frontend needs one small state machine, not knowledge of the provider:
async function watchJob(id) {
const response = await fetch(`/v1/image-jobs/${id}`);
const job = await response.json();
if (["queued", "submitting", "processing"].includes(job.status)) {
setTimeout(() => watchJob(id), 2500);
} else if (job.status === "completed") {
showImage(job.resultUrl);
} else {
showFailure(job.errorCode);
}
}
Expose states users can understand: queued, processing, completed, and failed. Internally you can track more detail. Do not show an upstream provider's raw error message as the customer-facing explanation; it will be vague at best and leak implementation detail at worst.
If the product already has WebSockets or server-sent events, push the completed state instead of polling. Polling every two to five seconds is still a better first release than a synchronous request, especially when the image job may take longer than your proxy timeout.
Step 4: design retries before the first timeout
Not every failure deserves the same retry. A 429 means slow down. A temporary 5xx may get a bounded retry. A malformed prompt or denied authorization should fail immediately. Our Midjourney API error handling guide explains the error taxonomy in more depth; the key point is to keep retry policy at the worker rather than the browser.
Use exponential backoff with a hard ceiling and preserve the original job id through every retry:
const retryable = new Set([429, 500, 502, 503, 504]);
async function retryOrFail(job, error) {
if (retryable.has(error.status) && job.attemptCount < 4) {
const delayMs = Math.min(60_000, 1_000 * 2 ** job.attemptCount);
await queue.add("generate-image", { jobId: job.id }, { delay: delayMs });
return jobs.update(job.id, { status: "queued", attemptCount: job.attemptCount + 1 });
}
return jobs.update(job.id, { status: "failed", errorCode: classify(error) });
}
Idempotency is non-negotiable here. The worker can crash after the upstream provider accepts work but before your database stores the provider id. Carry an idempotency key downstream where supported; otherwise reconcile using your own job metadata before resubmitting.
Step 5: keep costs and access in your own control plane
An integration becomes a product when it has tenants, limits, and an audit trail. Before an upstream submission, check that the caller is authenticated, authorized for that tenant, and inside the quota you are willing to fund. On completion, record actual usage against the job instead of deducting permanently at submission time.
This is why rented wrapper access feels easy until it is not: you inherit another company's pricing, rate limits, and outage model. A self-hosted deployment path gives you control of the backend, frontend, REST API, and admin dashboard, though you still own its operations and any third-party account risk.
Ready to self-host?
Full source code — backend, frontend, REST API, admin dashboard. One-time license from $399.
See pricingBuild the boring path first
The durable integration is not the fastest demo. It is the one that survives a refresh, a provider timeout, an expired credential, and a deploy without creating duplicate jobs. Start with a queue, a job record, and a narrow frontend contract; add realtime delivery when the rest is proven.