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.

4 min readSume
All posts

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 delivery rules against the Sume submit (read 2026-10-03)
Shopify behaviorValueHandler decision
Connection timeout1 secondKeep the endpoint warm
Total timeout5 secondsAbort the Sume call at 3 seconds
Non-2xx or redirectCounted as failureRegister the final URL
Retries8 over 4 hoursKey by product id
SignatureBase64 HMAC-SHA256 of raw body, client secretCompare in constant time
Delivery idX-Shopify-Webhook-IdOptional 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

All Integrations posts

Written by Sume