This guide shows a simple way to build Mynth image generation with Convex.
For most production apps, create your webhook in the Mynth dashboard and verify it with
@mynthio/sdk/convex. That gives you signed webhook deliveries and keeps your request code
simpler.
Architecture
Before you start
You need:
a Convex project
@mynthio/sdk installed in the app where your Convex functions run
a Mynth API key
a webhook configured in the Mynth dashboard
This guide uses the current Convex split:
actions for third-party API calls
HTTP actions for incoming webhooks
queries for reactive UI
1. Add environment variables
Set these in your Convex deployment:
MYNTH_API_KEY
MYNTH_WEBHOOK_SECRET
Your webhook endpoint will be your Convex HTTP action URL, for example:
https://<your-deployment>.convex.site/webhooks/mynth
2. Start with a simple schema
For a demo or first integration, one images table is enough.
import { defineSchema , defineTable } from "convex/server" ;
import { v } from "convex/values" ;
export default defineSchema ({
images : defineTable ({
userId : v . string (),
mynthTaskId : v . optional ( v . string ()),
requestedModel : v . string (),
status : v . union ( v . literal ( "pending" ), v . literal ( "success" ), v . literal ( "failed" )),
imageId : v . optional ( v . string ()),
url : v . optional ( v . string ()),
error : v . optional ( v . string ()),
}). index ( "by_mynth_task" , [ "mynthTaskId" ]),
});
This is enough to support:
one prompt generating several images
a reactive gallery keyed by one Mynth task
3. Add the small Convex helpers
You need:
a query for the UI
an internal mutation to create pending rows
an internal query to fetch rows by mynthTaskId
an internal mutation to attach a mynthTaskId after task creation when needed
internal mutations to mark success or failure
import { v } from "convex/values" ;
import { internalMutation , internalQuery , query } from "./_generated/server" ;
export const listByMynthTaskId = query ({
args : { mynthTaskId : v . string () },
handler : async ( ctx , args ) => {
const identity = await ctx . auth . getUserIdentity ();
if ( ! identity ) throw new Error ( "Unauthorized" );
return await ctx . db
. query ( "images" )
. withIndex ( "by_mynth_task" , ( q ) => q . eq ( "mynthTaskId" , args . mynthTaskId ))
. collect ();
},
});
export const createPendingImages = internalMutation ({
args : {
images : v . array (
v . object ({
userId : v . string (),
mynthTaskId : v . string (),
requestedModel : v . string (),
}),
),
},
handler : async ( ctx , args ) => {
return await Promise . all (
args . images . map (( image ) =>
ctx . db . insert ( "images" , {
userId : image . userId ,
mynthTaskId : image . mynthTaskId ,
requestedModel : image . requestedModel ,
status : "pending" ,
}),
),
);
},
});
export const getByMynthTaskId = internalQuery ({
args : { mynthTaskId : v . string () },
handler : async ( ctx , args ) => {
return await ctx . db
. query ( "images" )
. withIndex ( "by_mynth_task" , ( q ) => q . eq ( "mynthTaskId" , args . mynthTaskId ))
. collect ();
},
});
export const attachMynthTaskId = internalMutation ({
args : {
ids : v . array ( v . id ( "images" )),
mynthTaskId : v . string (),
},
handler : async ( ctx , args ) => {
await Promise . all (
args . ids . map (( id ) =>
ctx . db . patch ( id , {
mynthTaskId : args . mynthTaskId ,
}),
),
);
},
});
export const markSuccess = internalMutation ({
args : {
id : v . id ( "images" ),
imageId : v . string (),
url : v . string (),
},
handler : async ( ctx , args ) => {
await ctx . db . patch ( args . id , {
status : "success" ,
imageId : args . imageId ,
url : args . url ,
});
},
});
export const markFailed = internalMutation ({
args : {
id : v . id ( "images" ),
error : v . optional ( v . string ()),
},
handler : async ( ctx , args ) => {
await ctx . db . patch ( args . id , {
status : "failed" ,
error : args . error ,
});
},
});
4. Create image tasks from a Convex action
The basic pattern is:
call Mynth in async mode
take the returned task ID as your mynthTaskId
create one pending row per expected image
return the mynthTaskId so the UI can subscribe
import Mynth , { type MynthSDKTypes } from "@mynthio/sdk" ;
import { v } from "convex/values" ;
import { action } from "./_generated/server" ;
import { internal } from "./_generated/api" ;
export const generate = action ({
args : {
prompt : v . string (),
model : v . string (),
count : v . optional ( v . number ()),
},
handler : async ( ctx , args ) => {
const identity = await ctx . auth . getUserIdentity ();
if ( ! identity ) throw new Error ( "Unauthorized" );
const count = args . count ?? 1 ;
const mynth = new Mynth ({
apiKey : process . env . MYNTH_API_KEY ! ,
});
const task = await mynth . generate (
{
prompt : args . prompt ,
model : args . model as MynthSDKTypes . ImageGenerationModelId ,
count ,
},
{ mode : "async" },
);
await ctx . runMutation ( internal . images . createPendingImages , {
images : Array . from ({ length : count }, () => ({
userId : identity . subject ,
mynthTaskId : task . id ,
requestedModel : args . model ,
})),
});
return { mynthTaskId : task . id };
},
});
5. Register a signed webhook in convex/http.ts
Now create a Convex HTTP action that receives webhook deliveries and updates your rows.
The easiest way is @mynthio/sdk/convex, which verifies:
X-Mynth-Event
X-Mynth-Signature
the signed raw request body using MYNTH_WEBHOOK_SECRET
import { mynthWebhookAction } from "@mynthio/sdk/convex" ;
import { httpRouter } from "convex/server" ;
import { httpAction } from "./_generated/server" ;
import { internal } from "./_generated/api" ;
const http = httpRouter ();
http . route ({
path : "/webhooks/mynth" ,
method : "POST" ,
handler : httpAction (
mynthWebhookAction ({
imageTaskCompleted : async ( payload , { context }) => {
const rows = await context . runQuery ( internal . images . getByMynthTaskId , {
mynthTaskId : payload . task . id ,
});
await Promise . all (
payload . result . images . map (( image , index ) => {
const row = rows [ index ];
if ( ! row ) return Promise . resolve ();
if ( image . status === "succeeded" ) {
return context . runMutation ( internal . images . markSuccess , {
id : row . _id ,
imageId : image . id ,
url : image . url ,
});
}
return context . runMutation ( internal . images . markFailed , {
id : row . _id ,
error : image . error ,
});
}),
);
},
imageTaskFailed : async ( payload , { context }) => {
const rows = await context . runQuery ( internal . images . getByMynthTaskId , {
mynthTaskId : payload . task . id ,
});
await Promise . all (
rows . map (( row ) =>
context . runMutation ( internal . images . markFailed , {
id : row . _id ,
error : "Task failed" ,
}),
),
);
},
}),
),
});
export default http ;
6. Create the webhook in the Mynth dashboard
Use the dashboard webhook page for this flow.
Set the URL
Use your Convex HTTP action URL: https://<your-deployment>.convex.site/webhooks/mynth
Choose events
Subscribe to:
task.image.generate.completed
task.image.generate.failed
Store the webhook secret
Copy the webhook secret from the Mynth dashboard into Convex as MYNTH_WEBHOOK_SECRET.
Once this webhook is configured, you do not need to attach a webhook object to each
mynth.generate(...) request for this flow.
7. Render the results reactively in React
Convex queries are reactive, so the UI updates automatically when the webhook handler patches the rows.
import { useAction , useQuery } from "convex/react" ;
import { api } from "../convex/_generated/api" ;
import { useState } from "react" ;
export function ImageDemo () {
const generateImages = useAction ( api . imagesActions . generate );
const [ mynthTaskId , setMynthTaskId ] = useState < string | null >( null );
const images = useQuery ( api . images . listByMynthTaskId , mynthTaskId ? { mynthTaskId } : "skip" );
return (
< div >
< button
onClick = { async () => {
const result = await generateImages ({
prompt : "Hero illustration for a fintech landing page" ,
model : "google/gemini-3.1-flash-image" ,
count : 2 ,
});
setMynthTaskId ( result . mynthTaskId );
}}
>
Generate
</ button >
< div >
{ images ?. map (( image ) =>
image . status === "success" ? (
< img key = { image . _id } src = { image . url } alt = "Generated image" />
) : image . status === "failed" ? (
< p key = { image . _id }> Failed </ p >
) : (
< p key = { image . _id }> Pending... </ p >
),
)}
</ div >
</ div >
);
}
That is the whole loop:
action starts async work
UI subscribes by mynthTaskId
webhook updates rows
Convex pushes the latest state into the UI
Two ways to map webhook results back to rows
You have two reasonable options here.
Option 1: Look up rows by mynthTaskId and match by order
This is the simpler starting point.
Pattern:
create pending rows when you start the task
webhook looks up rows with getByMynthTaskId(mynthTaskId)
match payload.result.images[index] to rows[index]
Use this when you want:
the smallest amount of setup
a fast prototype
one task creating a known number of rows
This is a more explicit mapping strategy.
Pattern:
create your Convex image rows first
collect the created row IDs
send those IDs in metadata
after Mynth returns, patch those rows with mynthTaskId
in the webhook handler, read the IDs from payload.request.metadata
update rows by ID directly
Example request shape:
const imageRowIds = await ctx . runMutation ( internal . images . createPendingImages , {
images : Array . from ({ length : count }, () => ({
userId : identity . subject ,
requestedModel : args . model ,
})),
});
const task = await mynth . generate (
{
prompt : args . prompt ,
model : args . model as MynthSDKTypes . ImageGenerationModelId ,
count ,
metadata : {
imageRowIds ,
},
},
{ mode : "async" },
);
await ctx . runMutation ( internal . images . attachMynthTaskId , {
ids : imageRowIds ,
mynthTaskId : task . id ,
});
Use this when you want:
deterministic mapping without a lookup step
row-by-row control from webhook metadata
easier extension to more complex workflows
Start with task ID plus array order if you want the easiest demo. Move to metadata-based row
mapping when you want stricter control.
When to use custom request-level webhooks
For this Convex pattern, dashboard-created webhooks are the recommended default.
Use request-level custom webhooks only when you need:
a different destination per request
a tenant-specific destination chosen at runtime
a temporary webhook endpoint for a specific workflow
Request-level custom webhooks are not signed. Because mynthWebhookAction() expects signed
deliveries, it is the right choice for dashboard-created webhooks, not unsigned custom endpoints.
If you do use custom request-level webhooks, add your own verification token or equivalent validation.
Next steps
Use Webhooks Review the webhook delivery model and event payloads.
Batch Generation Extend the same Convex pattern to multi-model and multi-prompt generation.
SDK Integrations See the higher-level integration overview for Convex and TanStack AI.
Tasks and Polling Compare webhook-driven updates with task polling in the SDK.