SudoMock
Mockups
DocsPricing
Request a demo
Get Started

Start rendering mockups in minutes

From PSD to product mockup in one API call.

Get Free API Key→Browse Templates
Public status pagePublished API pricingProduct mockup API500 one-time API credits

Site Navigation

SudoMock

Turn reusable PSD templates, artwork, and editable text into production-ready product images through REST API, no-code workflows, native storefront apps, or AI.

SudoMock is a registered DBA
1209 Mountain Rd Pl NE, Ste N
Albuquerque, NM 87110, USA

Product

  • Pricing
  • Dashboard
  • PSD Mockups
  • Changelog
  • Start Free

Developers

  • API Overview
  • MCP Server
  • Quick Start
  • Render API
  • Upload PSD
  • Authentication
  • Error Handling
  • PSD Preparation
  • Integrations

Mockups & Tools

  • All Mockups
  • Apparel Mockups
  • Drinkware Mockups
  • Wall Art Mockups
  • Home & Living
  • By Platform
  • Search Mockups
  • Background Remover
  • Free Tools

Resources

  • All Use Cases
  • T-Shirt Mockup Generator for Print on Demand
  • Tote Bag Mockup Generator for Sustainable Brands
  • Automate Mug at Scale with SudoMock
  • Blog
  • Roadmap
  • All Alternatives
  • vs Placeit
  • vs DynamicMockups
  • vs Adobe Photoshop API
  • vs Static Mockup Generators

Company

  • About
  • Contact
  • Brand Assets
  • System Status (opens in new tab)
  • Feature Requests (opens in new tab)
Blog

Latest Articles

View all →
  • 01Etsy Webhooks: Render Listing Photos When a Listing Is Created
  • 02Etsy Video Size and Length: The Listing Video Rules and the 21 October API Change
  • 03Can You Use Mockups on Etsy? The Rule by Listing Type
  • 04Google Merchant Center Image Requirements: 500 x 500 From 2027 and Renders That Meet It

© 2026 SudoMock. All rights reserved.

PrivacyTermsRefundsCookies[email protected]
  1. Blog
  2. /
  3. Tutorial

Etsy Webhooks: Render Listing Photos When a Listing Is Created

Etsy has sent listing and inventory webhooks since 13 October 2026, signed with HMAC-SHA256 over the webhook ID, timestamp and raw body. A receiver that checks that signature, maps each SKU to your PSD template and uploads the SudoMock renders gives each new draft listing photos of your product.

Published Oct 19, 2026·6 min read·Beck

A listing.created webhook payload from Etsy, the steps a receiver runs from the signature check to uploadListingImage, and the framed print and mug photos rendered for two SKUs

Key takeaways

  • A listing event has four fields, so the photos come from your own SKU to template table.
  • createDraftListing takes no SKU, so the photos follow the SKU set by updateListingInventory.
  • Each retry keeps the same webhook-id, so one stored ID stops duplicate photos.

On this page

  • Which Etsy webhook events exist
  • What a listing event carries
  • Which listings the receiver serves
  • Verify the signature
  • Map each SKU to a template and render
  • Upload the renders to the listing
  • The whole receiver
  • Next step
  • Frequently asked questions
  • Sources

Which Etsy webhook events exist

Etsy sends webhooks for orders, listings and inventory to an endpoint you add in its Webhooks Portal, and personal and commercial apps both receive them. The listing and inventory events arrived on 13 October 2026, in Etsy's announcement New Release: Listing and Inventory Webhooks.

DomainEvents
Listinglisting.created, listing.updated, listing.deleted
Inventoryinventory.created, inventory.updated, inventory.deleted, inventory.out_of_stock
Orderorder.paid, order.canceled, order.shipped, order.delivered

Etsy's webhooks page, read on 19 October 2026, says listing.updated fires on changes to "Title, Description, Personalization, Taxonomy, Images, Attributes, and State." Images are on that list, so the photos your receiver uploads fire a listing.updated of their own. Leave listing.updated out of the photo endpoint's events and those echoes never reach it.

What a listing event carries

A listing event carries four fields and no image or design file: the event type, a URL to read the listing back, the shop and the listing. Everything the photos need comes from your own records, keyed by the SKUs on the listing.

This is the shape of Etsy's example, with sample IDs:

JSON
{
"event_type": "listing.created",
"resource_url": "https://api.etsy.com/v3/application/shops/12345678/listings/1876543210?includes=Images,Personalization",
"shop_id": "12345678",
"listing_id": "1876543210"
}

resource_url calls getListing with the listing's images and personalization attached, and inventory events carry the same fields plus product_id. Etsy's example writes the IDs as strings, while the order deliveries quoted in discussion 1509 carry shop_id as a number and event_type as ORDER_PAID where Etsy's page writes order.paid. The receiver below reads the IDs as strings and accepts either spelling of the event type.

Which listings the receiver serves

The receiver serves draft listings that your own script or a print on demand platform creates without photos. createDraftListing takes no SKU, so the SKU follows with updateListingInventory, and Etsy lists SKU among the fields whose change fires inventory.updated.

The receiver therefore listens to listing.created, inventory.created and inventory.updated, reads the listing and acts only while its state is draft, so a published listing is never touched. It renders each listing and SKU pair once, whichever of the three events brings the SKU first.

Verify the signature

Every delivery carries three headers, webhook-id, webhook-timestamp and webhook-signature, and the signature is an HMAC-SHA256 of the ID, the timestamp and the raw body joined by dots. Reject a request whose signature does not match or whose timestamp is more than five minutes away from your clock, as Etsy's own example does.

The key is the signing secret from the Webhooks Portal with its whsec_ prefix removed, base64-decoded. Compute the HMAC over the raw body before any JSON parsing, then compare it with each entry of webhook-signature. Each entry starts with a version prefix, v1,, which Etsy's page does not show and a developer confirmed in discussion 1509, so the function compares the part after the comma:

JavaScript
import crypto from 'node:crypto'
const SECRET = Buffer.from(process.env.ETSY_WEBHOOK_SECRET.split('_')[1], 'base64')
export function verify(id, timestamp, signatureHeader, rawBody) {
if (!id || !timestamp || !signatureHeader) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const expected = crypto.createHmac('sha256', SECRET).update(`${id}.${timestamp}.${rawBody}`).digest()
return signatureHeader.split(' ').some((entry) => {
const signature = Buffer.from(entry.split(',').pop(), 'base64')
return signature.length === expected.length && crypto.timingSafeEqual(signature, expected)
})
}

timingSafeEqual compares in constant time, so a wrong signature reveals nothing through the response time.

Map each SKU to a template and render

The receiver reads the listing's SKUs with getListingInventory and looks each one up in a table of its own: the design file, the PSD template and the smart object that takes the design. Each SKU becomes one render, and the image comes back as a URL in data.print_files[0].export_path.

This is the render for the print SKU, with the UUIDs of the poster template used here. Yours come from your template's upload response or its page in the dashboard:

curl -X POST https://api.sudomock.com/api/v1/renders \
-H "x-api-key: $SUDOMOCK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mockup_uuid": "091de3df-148d-4946-bbfb-ff62603a7e6a",
"smart_objects": [
{ "uuid": "44f6a757-e4c7-410e-b2b6-59a00dc9c87f",
"asset": { "url": "https://example.com/designs/botanical.png", "fit": "fit" } }
],
"export_options": { "image_format": "jpg", "image_size": 1024 }
}'
const response = await fetch('https://api.sudomock.com/api/v1/renders', {
method: 'POST',
headers: {
'x-api-key': process.env.SUDOMOCK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"mockup_uuid": "091de3df-148d-4946-bbfb-ff62603a7e6a",
"smart_objects": [
{
"uuid": "44f6a757-e4c7-410e-b2b6-59a00dc9c87f",
"asset": {
"url": "https://example.com/designs/botanical.png",
"fit": "fit"
}
}
],
"export_options": {
"image_format": "jpg",
"image_size": 1024
}
}),
});
const data = await response.json();
console.log(data);
import os
import requests
response = requests.post(
"https://api.sudomock.com/api/v1/renders",
headers={
"x-api-key": os.environ["SUDOMOCK_API_KEY"],
"Content-Type": "application/json",
},
json={
"mockup_uuid": "091de3df-148d-4946-bbfb-ff62603a7e6a",
"smart_objects": [
{
"uuid": "44f6a757-e4c7-410e-b2b6-59a00dc9c87f",
"asset": {
"url": "https://example.com/designs/botanical.png",
"fit": "fit",
},
},
],
"export_options": {
"image_format": "jpg",
"image_size": 1024,
},
},
)
print(response.json())
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
payload := []byte(`{
"mockup_uuid": "091de3df-148d-4946-bbfb-ff62603a7e6a",
"smart_objects": [
{
"uuid": "44f6a757-e4c7-410e-b2b6-59a00dc9c87f",
"asset": {
"url": "https://example.com/designs/botanical.png",
"fit": "fit"
}
}
],
"export_options": {
"image_format": "jpg",
"image_size": 1024
}
}`)
req, err := http.NewRequest("POST", "https://api.sudomock.com/api/v1/renders", bytes.NewBuffer(payload))
if err != nil {
panic(err)
}
req.Header.Set("x-api-key", os.Getenv("SUDOMOCK_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
<?php
$payload = <<<'JSON'
{
"mockup_uuid": "091de3df-148d-4946-bbfb-ff62603a7e6a",
"smart_objects": [
{
"uuid": "44f6a757-e4c7-410e-b2b6-59a00dc9c87f",
"asset": {
"url": "https://example.com/designs/botanical.png",
"fit": "fit"
}
}
],
"export_options": {
"image_format": "jpg",
"image_size": 1024
}
}
JSON;
$ch = curl_init('https://api.sudomock.com/api/v1/renders');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . getenv('SUDOMOCK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $payload,
]);
$response = curl_exec($ch);
curl_close($ch);
echo $response;

Render JPG for Etsy. Etsy's Requirements and Best Practices for Images, read on 19 October 2026, warns that "the transparent parts of the image will appear black on Etsy" and recommends photos with "a width and height of at least 2000 pixels". Etsy Listing Photo Size shows renders at each size.

We rendered both listings from one design on 19 October 2026: the print on a framed poster template, and the mug on a mug template with fit set to crop, so the design covers the visible face of the mug at its own proportions.

The same botanical night design rendered by SudoMock as a framed print on a stone shelf and on a white mug on a wooden tableTwo listing photos from one design file, 19 October 2026: SKU BOTANICAL-PRINT on the framed poster template, SKU BOTANICAL-MUG on the mug template.

Upload the renders to the listing

Etsy's uploadListingImage takes the image as a file, never as a URL, so the receiver downloads each render and posts it as multipart form data. rank sets the photo's position, with rank 1 the first photo of the listing, and alt_text takes up to 500 characters. The receiver puts its photos after the images the draft already has.

Shell
IMG=botanical-print.jpg
curl -X POST "https://openapi.etsy.com/v3/application/shops/$SHOP_ID/listings/$LISTING_ID/images" \
-H "x-api-key: $ETSY_KEYSTRING:$ETSY_SHARED_SECRET" \
-H "Authorization: Bearer $ETSY_ACCESS_TOKEN" \
-H "Content-Type: multipart/form-data" \
-F "image=@$IMG" \
-F "rank=1" \
-F "alt_text=Botanical night print in a wooden frame on a stone shelf"

The receiver's token needs the listings_r scope to read the draft and its inventory and listings_w to upload (Open API specification), and its x-api-key header joins your keystring and shared secret with a colon. A listing holds up to 20 photos since Etsy's change of 10 October 2025 (discussion 1477). Setting a draft to active "will also publish the listing on etsy.com and requires that the listing have an image set", in the specification's words, so the script that created the draft activates it once the photos are on.

The whole receiver

The receiver answers 200 as soon as the signature checks out and renders after, so the Etsy calls and renders that follow never hold up the delivery. Etsy retries a failed delivery on a fixed schedule, and webhook-id stays the same on every retry, so the receiver drops an ID it has already handled.

Etsy sends the first attempt at once and, after each failure, retries after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, as its webhooks page lists them. Save the receiver as receiver.mjs. It runs on Node.js 18 or later with Express 4:

JavaScript
import express from 'express'
import crypto from 'node:crypto'
const SECRET = Buffer.from(process.env.ETSY_WEBHOOK_SECRET.split('_')[1], 'base64')
const ETSY_API = 'https://openapi.etsy.com/v3/application'
const ETSY_HEADERS = {
'x-api-key': `${process.env.ETSY_KEYSTRING}:${process.env.ETSY_SHARED_SECRET}`,
Authorization: `Bearer ${process.env.ETSY_ACCESS_TOKEN}`,
}
// Your own table: which design and template make the photo for each SKU.
const TEMPLATES = {
'BOTANICAL-PRINT': {
mockup: '091de3df-148d-4946-bbfb-ff62603a7e6a',
smartObject: '44f6a757-e4c7-410e-b2b6-59a00dc9c87f',
design: 'https://example.com/designs/botanical.png',
fit: 'fit',
alt: 'Botanical night print in a wooden frame on a stone shelf',
},
'BOTANICAL-MUG': {
mockup: '1b1ff439-83bd-4719-808c-c7f43c9748a9',
smartObject: '18d4567d-b7a3-4268-ad6a-6a14b42e2474',
design: 'https://example.com/designs/botanical.png',
fit: 'crop',
alt: 'White mug with the botanical night design on a wooden table',
},
}
export function verify(id, timestamp, signatureHeader, rawBody) {
if (!id || !timestamp || !signatureHeader) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const expected = crypto.createHmac('sha256', SECRET).update(`${id}.${timestamp}.${rawBody}`).digest()
return signatureHeader.split(' ').some((entry) => {
const signature = Buffer.from(entry.split(',').pop(), 'base64')
return signature.length === expected.length && crypto.timingSafeEqual(signature, expected)
})
}
async function etsy(path, init = {}) {
const res = await fetch(`${ETSY_API}${path}`, { ...init, headers: ETSY_HEADERS })
if (!res.ok) throw new Error(`Etsy ${res.status} on ${path}: ${await res.text()}`)
return res.json()
}
async function renderPhoto(t) {
const res = await fetch('https://api.sudomock.com/api/v1/renders', {
method: 'POST',
headers: { 'x-api-key': process.env.SUDOMOCK_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
mockup_uuid: t.mockup,
smart_objects: [{ uuid: t.smartObject, asset: { url: t.design, fit: t.fit } }],
export_options: { image_format: 'jpg', image_size: 1024 },
}),
})
const body = await res.json()
if (!res.ok) throw new Error(`SudoMock ${res.status}: ${JSON.stringify(body)}`)
return body.data.print_files[0].export_path
}
// Webhook IDs and listing:SKU pairs already handled. Keep them in your database in production.
const handled = new Set()
async function addListingPhotos(shopId, listingId) {
const listing = await etsy(`/listings/${listingId}?includes=Images`)
if (listing.state !== 'draft') return
const inventory = await etsy(`/listings/${listingId}/inventory`)
let rank = (listing.images ?? []).length + 1
for (const sku of new Set(inventory.products.map((p) => p.sku))) {
const template = TEMPLATES[sku]
const key = `${listingId}:${sku}`
if (!template || handled.has(key)) continue
handled.add(key)
try {
const imageUrl = await renderPhoto(template)
const image = await fetch(imageUrl).then((r) => r.blob())
const form = new FormData()
form.append('image', image, `${sku}.jpg`)
form.append('rank', String(rank++))
form.append('alt_text', template.alt)
await etsy(`/shops/${shopId}/listings/${listingId}/images`, { method: 'POST', body: form })
} catch (err) {
handled.delete(key) // a later event for this draft tries the SKU again
throw err
}
}
}
const TRIGGERS = new Set(['listing.created', 'inventory.created', 'inventory.updated'])
const app = express()
app.post('/etsy/webhooks', express.raw({ type: '*/*' }), (req, res) => {
const id = req.header('webhook-id')
if (!verify(id, req.header('webhook-timestamp'), req.header('webhook-signature'), req.body)) {
return res.sendStatus(401)
}
res.sendStatus(200)
if (handled.has(id)) return
handled.add(id)
const event = JSON.parse(req.body)
// Etsy's page writes listing.created, published order deliveries carried ORDER_PAID: accept both.
const type = String(event.event_type).toLowerCase().replace('_', '.')
if (TRIGGERS.has(type)) {
addListingPhotos(event.shop_id, event.listing_id).catch(console.error)
}
})
app.listen(3000)

The handled set lives in memory, so keep it in your database once the receiver runs in production. Etsy's access token lasts one hour, so a receiver that runs for days refreshes it with the refresh token, valid for 90 days, through the refresh_token grant in Etsy's authentication guide. The SKU table is the part that is yours: one row per product your shop sells, pointing at the template that shows it, with the UUIDs of your own templates.

Next step

Add an endpoint for listing.created, inventory.created and inventory.updated in the Webhooks Portal and send a test event from the event's Testing tab. Then create one draft with createDraftListing, set its SKU with updateListingInventory and check that the photos land on the draft. The render reference lists every render option, and How to Automate Etsy Product Mockups renders photos for the listings you already have. With no template yet, the mockup templates for Etsy sellers are ready to render, and the SudoMock modules for n8n and Make run the render step without code.

Frequently asked questions

Does Etsy have webhooks?

Yes. Etsy sends order, listing and inventory events to an endpoint you add in its Webhooks Portal, and personal and commercial apps both receive them. The listing and inventory events were announced on 13 October 2026.

How do I verify an Etsy webhook signature?

Remove the whsec_ prefix from your signing secret and base64-decode the rest. Compute an HMAC-SHA256 over the webhook-id, the webhook-timestamp and the raw body joined by dots, base64-encode it and compare it with the part after the comma in each webhook-signature entry. Reject a request whose timestamp is more than five minutes away from your clock.

Does uploading a listing image trigger listing.updated?

Etsy lists Images among the fields whose changes fire listing.updated, so your own upload produces one. Leave listing.updated out of the photo endpoint's events, or skip it for listings your receiver has just changed.

Can uploadListingImage take an image URL?

No. It takes the image as a file in the image field, or, in listing_image_id, the ID of an image deleted from the listing before. Download each render from its URL and post the file.

How often does Etsy retry a failed webhook?

Etsy sends the first attempt at once and, after each failure, retries after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. The webhook-id header stays the same on every retry, so use it to drop duplicates.

Sources

  1. Webhooks | Etsy Open API v3 (opens in new tab), Etsy Open API
  2. New Release: Listing and Inventory Webhooks (opens in new tab), Etsy Open API on GitHub
  3. New Release: Webhooks for Paid Orders (opens in new tab), Etsy Open API on GitHub
  4. New: OpenAPI Now Accepts Up to 20 Photos (opens in new tab), Etsy Open API on GitHub
  5. Etsy Open API v3 specification (opens in new tab), Etsy
  6. Requirements and Best Practices for Images in Your Etsy Shop (opens in new tab), Etsy Help Center
  7. Render a PSD mockup (opens in new tab)
  8. Authentication | Etsy Open API v3 (opens in new tab), Etsy Open API
B

Beck

CTO

View all posts

On this page

  • Which Etsy webhook events exist
  • What a listing event carries
  • Which listings the receiver serves
  • Verify the signature
  • Map each SKU to a template and render
  • Upload the renders to the listing
  • The whole receiver
  • Next step
  • Frequently asked questions
  • Sources

Related articles

Framed botanical print rendered at 2679 by 2000 pixels beside the export options that made it and Etsy's photo rules it meets: both sides 2000 pixels or more, landscape first photo, JPG under 1 MB
Guide

Etsy Listing Photo Size: Etsy's Rules and Files That Meet Them

Etsy's listing photo rules quoted with their dates, the render width for each template shape, and real files measured against the 1 MB line.

Oct 8, 2026 · 7 min read

One mushroom design run through the TEMPLATES loop into four renders: black tee, white tee, 11oz mug and 24x36 framed poster
Tutorial

How to Automate Etsy Product Mockups in 2026

Step-by-step guide to automating your Etsy listing mockups with SudoMock API. Save hours of manual work.

Dec 3, 2025 · 2 min read

A SudoMock render of a white mug with a citrus print beside Etsy's listing video rules: up to 2 videos, 3 to 15 seconds, 100 MB, 1080px ideal, 2:1 or 1:2, no sound, and the Open API change of 21 October 2026
Guide

Etsy Video Size and Length: The Listing Video Rules and the 21 October API Change

Etsy's own sentences on listing video size, length, format and sound, and what a script that uploads videos must change on 21 October 2026.

Oct 18, 2026 · 7 min read

Explore by use case

See SudoMock in action for your specific product category

Etsy Mockup Generator API for Handmade Marketplace SellersPrint on Demand Mockup Automation for POD SellersT-Shirt Mockup Generator API for Print on DemandHoodie Mockup Generator APIAutomate Mug Mockups at Scale with SudoMock APIPoster Mockup Generator API for Wall Art Sellers

Your own PSD, rendered via API

Render your own PSD through the API. Start with 500 one-time credits, no card.

Start Free View Docs

Render your own PSD via API with 500 one-time credits.

Start Free · 500 credits, no card