Clerk user.created webhook: one Sume welcome image per sign-up
Clerk webhooks are at-least-once and repeat the same svix-id. Verify with verifyWebhook, then key the Sume job by the user id so a retry creates nothing new.

To generate a welcome image for each new Clerk user, handle the user.created webhook with Clerk's verifyWebhook helper, then submit to Sume with an Idempotency-Key built from the user id. Clerk delivers at least once, so you will see the same event again after a failed or slow response; the key means that costs nothing.
Keep the route small: verify, submit in webhook mode, return. A sign-up flow should never wait on image generation.
Where the duplicate protection comes from
Clerk's webhook pages describe a Svix-based system with at-least-once delivery. Every attempt for one event carries the same svix-id header, which they recommend storing so you can skip repeats. In a Next.js route handler you verify with verifyWebhook(req) from @clerk/nextjs/webhooks, which returns the typed event, and then branch on evt.type.
Two keys can serve as the dedupe identifier. The svix-id identifies one event, while evt.data.id identifies the user. For a welcome image the intent is once per user, so the user id is the better Sume key: it also covers the case of a second user.created-like event for the same account. If you want a new image per event type, include the type in the key.
| Identifier | Scope | Use as |
|---|---|---|
| svix-id header | One event, same on every retry | Your own processed-events table |
| evt.data.id | One user | Sume Idempotency-Key: clerk-welcome-<id> |
| evt.type | Event kind | Gate: only user.created submits |
The route
Webhook mode returns a 202 right away and the finished image arrives at your Sume receiver. The prompt uses no personal data beyond what you choose to include.
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import type { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
let evt;
try {
evt = await verifyWebhook(req);
} catch {
return new Response("bad signature", { status: 400 });
}
if (evt.type !== "user.created") return new Response("ignored", { status: 200 });
const res = await fetch("https://api.sume.com/v1/images", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SUME_API_KEY}`, "Content-Type": "application/json",
"Idempotency-Key": `clerk-welcome-${evt.data.id}` },
body: JSON.stringify({ model: "sume/auto", mode: "webhook", webhook_url: process.env.SUME_HOOK_URL,
prompt: "Friendly abstract welcome illustration, soft gradients, no text" }),
signal: AbortSignal.timeout(5000),
});
return new Response("ok", { status: res.ok ? 200 : 503 });
}Handling Sume errors
Returning 503 on a Sume 429 or 5xx makes Clerk retry; because the key is stable, the retry adopts the original job if the first call got through. For a 400 or 402, answer 200 and alert yourself instead: the same request will fail again, and Clerk's retries would only add noise. Never fail a sign-up because a decorative image failed; treat the welcome image as best-effort.
Sources
Related posts
More in Integrations
- Cloudflare K2 as a buffer in front of Sume bulk runs
K2 streams hold your video requests until a consumer submits them to Sume bulk runs. How to ack, nack and park rows so a redelivered batch never runs twice.
- Cloudflare MCP portal service tokens skip per-user OAuth servers
A Cloudflare service-token session cannot finish a per-user OAuth grant, so an OAuth upstream like Sume is left out. What to use for unattended agents.
- Cloudflare MCP portals GA: where Sume hosted MCP fits
Cloudflare MCP server portals went GA on 2026-09-24. What a portal changes for a remote server like Sume, and the three checks to run first.
- Cloudflare private MCP servers vs Sume public MCP endpoint
Cloudflare portals can now reach MCP servers on a private network. Sume's hosted MCP is a public HTTPS endpoint, so here is what changes and what does not.
Written by Sume