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.

4 min readSume
All posts

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.

Identifiers available in the handler (read 2026-10-03)
IdentifierScopeUse as
svix-id headerOne event, same on every retryYour own processed-events table
evt.data.idOne userSume Idempotency-Key: clerk-welcome-<id>
evt.typeEvent kindGate: 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

All Integrations posts

Written by Sume