Skip to main content
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:
  1. call Mynth in async mode
  2. take the returned task ID as your mynthTaskId
  3. create one pending row per expected image
  4. 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.
1

Set the URL

Use your Convex HTTP action URL:
https://<your-deployment>.convex.site/webhooks/mynth
2

Choose events

Subscribe to:
  • task.image.generate.completed
  • task.image.generate.failed
3

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

Option 2: Put Convex image IDs into Mynth metadata

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.