> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mynth.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Convex full-stack image generation

> Build a simple full-stack Mynth flow with Convex actions, signed webhooks, and reactive image updates.

This guide shows a simple way to build Mynth image generation with Convex.

<Tip>
  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.
</Tip>

## Architecture

```mermaid theme={"theme":"kanagawa-dragon"}
flowchart LR
  UI["React client"] -->|useAction| A["Convex action"]
  A -->|mynth.image.generateAsync(...)| M["Mynth API"]
  A -->|create pending image rows| DB["Convex database"]
  M -->|signed webhook| H["Convex HTTP action"]
  H -->|mark images success or failed| DB
  DB -->|reactive query updates| UI
```

## 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:

```text theme={"theme":"kanagawa-dragon"}
https://<your-deployment>.convex.site/webhooks/mynth
```

## 2. Start with a simple schema

For a demo or first integration, one `images` table is enough.

```ts theme={"theme":"kanagawa-dragon"}
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

```ts theme={"theme":"kanagawa-dragon"}
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.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,
    });
  },
});
```

## 4. Create image tasks from a Convex action

The basic pattern is:

1. call `mynth.image.generateAsync()` to start the task
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

```ts theme={"theme":"kanagawa-dragon"}
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.image.generateAsync({
      prompt: args.prompt,
      model: args.model as MynthSDKTypes.ImageGenerationModelId,
      count,
    });

    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`

```ts theme={"theme":"kanagawa-dragon"}
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") {
              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;
```

## 6. Create the webhook in the Mynth dashboard

Use the dashboard webhook page for this flow.

<Steps>
  <Step title="Set the URL">
    Use your Convex HTTP action URL:

    ```text theme={"theme":"kanagawa-dragon"}
    https://<your-deployment>.convex.site/webhooks/mynth
    ```
  </Step>

  <Step title="Choose events">
    Subscribe to:

    * `task.image.generate.completed`
    * `task.image.generate.failed`
  </Step>

  <Step title="Store the webhook secret">
    Copy the webhook secret from the Mynth dashboard into Convex as `MYNTH_WEBHOOK_SECRET`.
  </Step>
</Steps>

<Check>
  Once this webhook is configured, you do not need to attach a `webhook` object to each
  `mynth.image.generateAsync(...)` request for this flow.
</Check>

## 7. Render the results reactively in React

Convex queries are reactive, so the UI updates automatically when the webhook handler patches the rows.

```tsx theme={"theme":"kanagawa-dragon"}
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:

```ts theme={"theme":"kanagawa-dragon"}
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,
});
```

Use this when you want:

* deterministic mapping without a lookup step
* row-by-row control from webhook metadata
* easier extension to more complex workflows

<Tip>
  Start with task ID plus array order if you want the easiest demo. Move to metadata-based row
  mapping when you want stricter control.
</Tip>

## 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

<Warning>
  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.
</Warning>

If you do use custom request-level webhooks, add your own verification token or equivalent validation.

## Next steps

<Columns cols={2}>
  <Card title="Use Webhooks" icon="bell" href="/guides/use-webhooks">
    Review the webhook delivery model and event payloads.
  </Card>

  <Card title="Batch Generation" icon="layers-3" href="/guides/batch-generation">
    Extend the same Convex pattern to multi-model and multi-prompt generation.
  </Card>

  <Card title="SDK Integrations" icon="plug" href="/sdk/integrations">
    See the higher-level integration overview for Convex and TanStack AI.
  </Card>

  <Card title="Tasks and Polling" icon="clock-3" href="/sdk/js-ts/tasks">
    Compare webhook-driven updates with task polling in the SDK.
  </Card>
</Columns>
