Shopify products/create webhook: 5 seconds, 8 retries, one Sume job
Shopify allows 5 seconds and retries 8 times over 4 hours. Verify the base64 HMAC, then key the Sume submit by product id so retries do not repeat it.

When a Shopify app receives products/create and starts a Sume image job, it has 5 seconds to answer and will see the same delivery again up to 8 times over 4 hours if it does not return a 2xx. So verify X-Shopify-Hmac-SHA256, build the Sume Idempotency-Key from the product id, submit in webhook mode and answer fast.
The product id is the right key because the intent is one hero image per product. If a retry or a duplicate arrives, Sume returns the first job instead of creating a second.
What Shopify specifies
Shopify's HTTPS webhook page states a 1-second connection timeout and a 5-second timeout for the whole request, treats any non-2xx (redirects included) as a failure, and retries 8 times over 4 hours. The signature is the base64-encoded HMAC-SHA256 of the raw request body, using the app's client secret, in the X-Shopify-Hmac-SHA256 header. X-Shopify-Webhook-Id identifies a delivery for deduplication, and X-Shopify-Event-Id ties together deliveries caused by the same merchant action.
The 5-second ceiling is tighter than a synchronous Sume call, which can block up to 30 seconds. Use mode: "webhook" so the 202 comes back immediately, and stop waiting at 3 seconds yourself. If you do time out, return a 5xx on purpose: Shopify will retry, and the key makes the retry safe.
| Shopify behavior | Value | Handler decision |
|---|---|---|
| Connection timeout | 1 second | Keep the endpoint warm |
| Total timeout | 5 seconds | Abort the Sume call at 3 seconds |
| Non-2xx or redirect | Counted as failure | Register the final URL |
| Retries | 8 over 4 hours | Key by product id |
| Signature | Base64 HMAC-SHA256 of raw body, client secret | Compare in constant time |
| Delivery id | X-Shopify-Webhook-Id | Optional extra dedupe table |
Handler
This Next.js route reads the raw body before parsing, refuses an empty client secret and uses a constant-time comparison.
import crypto from "node:crypto";
export async function POST(req: Request) {
const secret = process.env.SHOPIFY_CLIENT_SECRET ?? "";
const raw = await req.text();
const want = crypto.createHmac("sha256", secret).update(raw).digest("base64");
const got = req.headers.get("x-shopify-hmac-sha256") ?? "";
if (!secret || got.length !== want.length ||
!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
return new Response("unauthorized", { status: 401 });
}
const product = JSON.parse(raw);
try {
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": `product-${product.id}-hero` },
body: JSON.stringify({ model: "sume/auto", mode: "webhook", webhook_url: process.env.SUME_HOOK_URL,
prompt: `Studio product photo of ${product.title}, plain background` }),
signal: AbortSignal.timeout(3000),
});
return new Response("ok", { status: res.status >= 500 || res.status === 429 ? 503 : 200 });
} catch {
return new Response("retry", { status: 503 });
}
}After the image exists
Attaching the finished image to the product is a separate step in your Sume webhook receiver, not in this handler. That keeps this route inside Shopify's window and lets the Sume side retry on its own schedule: 10 attempts, 30 seconds apart. For the media mutation Shopify now expects, see the product media post.
Sources
Related posts
More in Integrations
- Shopify's video limit by plan: 250 to 100,000 files, what to keep
Shopify counts video and 3D files per store by plan: 250 on Starter up to 100,000 on Enterprise. How to decide which product clips are worth keeping.
- Shopify video_tag loop: no HLS, so keep the product clip short
Shopify's video_tag skips the HLS source when loop is on, so the MP4 downloads whole. How to cut a short looping clip and conform its size with Sume video trim.
- Shopify Winter '26 shoppable video in Shop: prepare your clips
Shopify's Winter '26 Edition adds shoppable video in Shop with AI ranking. How to prepare clips that fit Shopify's file limits, with Sume doing the cuts.
- Slack video block: why a raw MP4 URL fails and what page it needs
Slack's video block wants an embeddable player page on an unfurl domain and the links.embed:write scope, not a bare file. Serve a tiny player page.
Written by Sume