Skip to main content
Build a full-stack image generation flow with Convex: start tasks from a Convex action, persist rows in the Convex database, update them from a signed Mynth webhook, and render results with reactive queries. Use this when you want durable backend state and live UI updates without polling. For a plain Node server or scripts, use the JavaScript SDK directly. For the webhook helper API alone, see Convex integration.

Architecture

Before you start

You need:
  • a Convex project with auth configured (ctx.auth.getUserIdentity())
  • a Mynth API key
  • a dashboard webhook (created in a later step)
Install the SDK where your Convex functions run:
bun add @mynthio/sdk
This guide uses the Convex split:
  • actions for third-party API calls (generateAsync)
  • HTTP actions for incoming webhooks
  • queries for reactive UI

1. Environment variables

Set these on your Convex deployment:
VariablePurpose
MYNTH_API_KEYServer-side Mynth API key
MYNTH_WEBHOOK_SECRETSignature secret from the Mynth dashboard
Your webhook URL will be your Convex HTTP action, for example:
https://<your-deployment>.convex.site/webhooks/mynth

2. Schema

One images table is enough for a first integration:
// convex/schema.ts
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 supports one prompt generating several images and a reactive gallery keyed by Mynth task ID.

3. Database helpers

Put queries and mutations in convex/images.ts:
// convex/images.ts
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");

    const rows = await ctx.db
      .query("images")
      .withIndex("by_mynth_task", (q) => q.eq("mynthTaskId", args.mynthTaskId))
      .collect();

    return rows.filter((row) => row.userId === identity.subject);
  },
});

export const createPendingImages = internalMutation({
  args: {
    images: v.array(
      v.object({
        userId: v.string(),
        mynthTaskId: v.optional(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,
    });
  },
});
listByMynthTaskId is public and scoped to the signed-in user. getByMynthTaskId is internal for the webhook handler.

4. Start generation from a Convex action

Create convex/imagesActions.ts. Pattern:
  1. Call mynth.image.generateAsync() (returns a TaskAsync immediately).
  2. Create one pending row per expected image, keyed by task.id.
  3. Return mynthTaskId so the UI can subscribe.
// convex/imagesActions.ts
import Mynth, { MynthAPIError, 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 apiKey = process.env.MYNTH_API_KEY;
    if (!apiKey) throw new Error("MYNTH_API_KEY is not set");

    const count = args.count ?? 1;
    const mynth = new Mynth({ apiKey });

    let task;
    try {
      task = await mynth.image.generateAsync({
        prompt: args.prompt,
        model: args.model as MynthSDKTypes.ImageGenerationModelId,
        count,
      });
    } catch (error) {
      if (error instanceof MynthAPIError) {
        throw new Error(`Mynth request failed (${error.status}): ${error.message}`);
      }
      throw error;
    }

    await ctx.runMutation(internal.images.createPendingImages, {
      images: Array.from({ length: count }, () => ({
        userId: identity.subject,
        mynthTaskId: task.id,
        requestedModel: args.model,
      })),
    });

    return { mynthTaskId: task.id };
  },
});
Create pending rows after generateAsync succeeds so a failed API call does not leave orphan rows. With a dashboard-managed webhook (next steps), you do not pass webhook on each request.

5. Handle signed webhooks in convex/http.ts

@mynthio/sdk/convex provides mynthWebhookAction(), which:
  • verifies X-Mynth-Signature (HMAC-SHA256) using MYNTH_WEBHOOK_SECRET
  • requires X-Mynth-Event
  • routes on payload.event to your handlers
  • passes Convex context so you can run queries and mutations
// convex/http.ts
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 === "success") {
              // mynth_url is always present; url may be null for destination-only delivery
              return context.runMutation(internal.images.markSuccess, {
                id: row._id,
                imageId: image.id,
                url: image.mynth_url,
              });
            }

            return context.runMutation(internal.images.markFailed, {
              id: row._id,
              error: image.error.code,
            });
          }),
        );
      },

      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;
HandlerEvent
imageTaskCompletedtask.image.generate.completed
imageTaskFailedtask.image.generate.failed
imageTaskCompleted can still include per-image failures (image.status === "failed" with error.code). imageTaskFailed means the whole task failed — there is no result payload. Pass { webhookSecret: "..." } as the second argument to mynthWebhookAction if you prefer not to use the env var.

6. Create the webhook in the Mynth dashboard

Use the dashboard webhooks page:
1

Set the URL

https://<your-deployment>.convex.site/webhooks/mynth
2

Choose events

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

Store the secret

Copy the webhook secret into Convex as MYNTH_WEBHOOK_SECRET.
With a dashboard-managed webhook, do not attach a webhook object on each generateAsync(...) call for this flow.

7. Render results in React

Convex queries are reactive. After the action returns a task ID, subscribe with useQuery; the UI updates when the webhook patches 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 [error, setError] = useState<string | null>(null);
  const [isGenerating, setIsGenerating] = useState(false);

  const images = useQuery(
    api.images.listByMynthTaskId,
    mynthTaskId ? { mynthTaskId } : "skip",
  );

  return (
    <div>
      <button
        disabled={isGenerating}
        onClick={async () => {
          setError(null);
          setIsGenerating(true);
          try {
            const result = await generateImages({
              prompt: "Hero illustration for a fintech landing page",
              model: "google/gemini-3.1-flash-image",
              count: 2,
            });
            setMynthTaskId(result.mynthTaskId);
          } catch (err) {
            setError(err instanceof Error ? err.message : "Generation failed");
          } finally {
            setIsGenerating(false);
          }
        }}
      >
        {isGenerating ? "Starting…" : "Generate"}
      </button>

      {error ? <p>{error}</p> : null}

      <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{image.error ? `: ${image.error}` : ""}</p>
          ) : (
            <p key={image._id}>Pending…</p>
          ),
        )}
      </div>
    </div>
  );
}
Loop summary:
  1. Action starts async work and returns mynthTaskId.
  2. UI subscribes by that ID.
  3. Webhook updates rows (success or failure per image, or whole-task failure).
  4. Convex pushes the latest state into the UI.

Map webhook results to rows

Option 1: Match by mynthTaskId and array order

Used above. Create pending rows when the task starts, look them up with getByMynthTaskId, and match payload.result.images[index] to rows[index]. Best for prototypes and one task with a known image count.

Option 2: Put Convex row IDs in metadata

Create rows first, send their IDs in metadata, attach mynthTaskId after the API call, then update by ID in the webhook from payload.request.metadata:
const imageRowIds = await ctx.runMutation(internal.images.createPendingImages, {
  images: Array.from({ length: count }, () => ({
    userId: identity.subject,
    requestedModel: args.model,
  })),
});

const task = await mynth.image.generateAsync({
  prompt: args.prompt,
  model: args.model as MynthSDKTypes.ImageGenerationModelId,
  count,
  metadata: {
    imageRowIds,
  },
});

await ctx.runMutation(internal.images.attachMynthTaskId, {
  ids: imageRowIds,
  mynthTaskId: task.id,
});
In the completed handler, read payload.request.metadata.imageRowIds and patch each ID directly instead of looking up by task and index. Use this when you need deterministic row-level mapping. If generateAsync fails after rows were created, clean up or mark those rows failed yourself. Metadata is returned on the task and in webhook payloads. Max size: 2 KB — see Use Metadata.

Request-level custom webhooks

Dashboard-managed webhooks are the default for this Convex pattern. Use request-level custom webhooks only when you need a different destination per request, a tenant-specific URL, or a temporary endpoint:
webhook: {
  custom: [{ url: "https://example.com/api/mynth-webhook?token=abc123" }],
}
Request-level custom webhooks are not signed. mynthWebhookAction() expects signed dashboard deliveries (X-Mynth-Signature + MYNTH_WEBHOOK_SECRET). For custom URLs, add your own verification token in the path or query string and handle the raw payload yourself.
Details: Use Webhooks.

Next steps

Convex integration

mynthWebhookAction handlers and event map.

Use Webhooks

Delivery model, signatures, and event payloads.

Batch Generation

Multi-model and multi-prompt generation patterns.

Tasks and Polling

Compare webhooks with SDK task polling.