Guide to Etsy Webhooks: Features and Best Practices
Etsy webhooks tell your app when something happens in a shop it is authorised for: an order is paid, cancelled, shipped or delivered, a listing changes, or a product's inventory runs out. If you sync Etsy orders into a fulfilment system or keep listings and stock consistent across channels, webhooks replace repeated polling of the Open API.
This guide covers how Etsy webhooks are configured and signed, the events and their payloads, the retry schedule and portal tools, and the practices that keep an Etsy receiver reliable in production.
What are Etsy webhooks?
Etsy webhooks are part of the Etsy Open API v3 and are available to both commercial and personal apps. You configure them in Etsy's Webhook portal, one endpoint at a time, by choosing a callback URL and the events to subscribe to. When an event fires, Etsy sends a JSON POST to that URL.
The payloads are deliberately small. Each one names the event, the shop, and a resource_url that your app calls with its OAuth token to get the order, listing or product.
Every delivery is signed. Three headers, webhook-id, webhook-timestamp and webhook-signature, carry a delivery ID, the time and a base64 HMAC-SHA256 of the ID, timestamp and body. The construction matches the Standard Webhooks specification, although Etsy does not mention it by name.
Etsy webhook features
| Feature | Details |
|---|---|
| Availability | Commercial and personal apps |
| Configuration | Webhook portal only. No subscription API is documented |
| Subscription model | Callback URL plus the events you choose, per endpoint |
| Headers | webhook-id, webhook-timestamp, webhook-signature |
| Scheme | HMAC-SHA256, base64 |
| Signed content | {webhook-id}.{webhook-timestamp}.{raw body} |
| Secret | One per endpoint, whsec_ followed by base64, shown masked in the portal |
| Timestamp check | Etsy suggests rejecting timestamps more than 300 seconds out, in your receiver |
| Retries | Immediately, then 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h |
| Manual recovery | Retry a message, or "Recover" all failed messages since a date |
| Testing | Testing tab per endpoint in the portal |
| Response window | Not documented |
| Source IPs | Not published |
Common events
Etsy documents 11 events across three domains. The event_type field names the one that fired.
| Domain | Event | Fires when |
|---|---|---|
| Order | order.paid | An order receives payment |
| Order | order.canceled | A seller cancels an order |
| Order | order.shipped | Shipping information is created for a product on a receipt |
| Order | order.delivered | An order is marked as delivered |
| Listing | listing.created | A listing is created |
| Listing | listing.updated | A listing's title, description, personalisation, taxonomy, images, attributes or state changes |
| Listing | listing.deleted | A listing is deleted |
| Inventory | inventory.created | A product is created |
| Inventory | inventory.updated | A product's price, readiness state, SKU or state changes |
| Inventory | inventory.deleted | A product is deleted |
| Inventory | inventory.out_of_stock | A product's quantity reaches 0 |
In Etsy's terms, a listing is a container (a t-shirt) and a product is a purchasable variant of it (a small blue t-shirt). Etsy says more events are coming and announces them in the etsy/open-api GitHub discussions.
Every payload has event_type, resource_url and shop_id. Listing events add listing_id, and inventory events add listing_id and product_id. This is Etsy's order.paid example, with its placeholders:
{
"event_type": "order.paid",
"resource_url": "https://api.etsy.com/v3/application/shops/{YOUR_SHOP_ID}/receipts/{RECEIPT_ID}",
"shop_id": "{YOUR_SHOP_ID}"
}
resource_url points at the matching Open API endpoint: getShopReceipt for orders, getListing with ?includes=Images,Personalization for listings, and getListingProduct for inventory. Etsy's examples are all placeholders, so they do not show whether the IDs arrive as JSON strings or numbers. Accept either.
There is no event ID or timestamp in the body; both are in the headers.
Setting up Etsy webhooks
- In the Etsy Developer Portal, open Manage your apps.
- Open the dropdown for your app and choose Go to Webhook portal.
- Click + Add Endpoint, enter your callback URL, choose the events, and click Create.
- Open the endpoint's details and reveal its signing secret. Store it, including the
whsec_prefix, for example asETSY_WEBHOOK_SECRET. - Use the endpoint's Testing tab to send test events.
Your app also needs an OAuth 2.0 access token with the right scopes to call each resource_url.
From the portal you can disable an endpoint (it stops receiving events and can be re-enabled) or delete it (permanent, including its data). The endpoint's detail view shows delivery stats, every message attempt and recent payloads.
Securing Etsy webhooks
Etsy's steps are:
- Build
signed_contentaswebhook-id + "." + webhook-timestamp + "." + raw_.body - Remove the
whsec_prefix from the secret and base64-decode the rest. Those bytes are the HMAC key. - Compute an HMAC-SHA256 of
signed_contentand base64-encode it. - Accept the request if any entry in
webhook-signaturematches.
Etsy does not show what a webhook-signature value looks like. In the Standard Webhooks format that this construction follows, the header is a space-separated list of entries such as v1,<base64 signature>, so the code below strips a v1, prefix before comparing.
const crypto = require('crypto');
const express = require('express');
function verifyEtsySignature(rawBody, headers, secret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) {
return false;
}
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest('base64');
const computed = Buffer.from(expected, 'utf8');
// Optional: Etsy suggests rejecting timestamps more than 300 seconds from now.
// if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
return signatureHeader.split(' ').some((entry) => {
const signature = entry.includes(',') ? entry.slice(entry.indexOf(',') + 1) : entry;
const received = Buffer.from(signature, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
});
}
const app = express();
app.post('/webhooks/etsy', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyEtsySignature(req.body, req.headers, process.env.ETSY_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on the webhook-id header, then fetch event.resource_url asynchronously.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
import os
def verify_etsy_signature(raw_body: bytes, headers, secret: str) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
return False
key = base64.b64decode(secret[len("whsec_"):])
signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
for entry in signature_header.split(" "):
signature = entry.split(",", 1)[1] if "," in entry else entry
if hmac.compare_digest(signature.encode("utf-8"), expected):
return True
return False
# In FastAPI: raw_body = await request.body()
# verify_etsy_signature(raw_body, request.headers, os.environ["ETSY_WEBHOOK_SECRET"])
Because the scheme is Standard Webhooks, the official standardwebhooks libraries should also work with Etsy's headers and whsec_ secret.
Etsy publishes no test vector. To check your code, use the secret whsec_, the webhook-id msg_, the webhook-timestamp 1760000000, and this exact body:
{"event_type":"order.paid","resource_url":"https://api.etsy.com/v3/application/shops/12345678/receipts/987654321","shop_id":12345678}
The expected webhook-signature entry is:
v1,1wcMDLEGptAxntOl/aKjEoAONWJP1W9IWPeAb3Tucec=
Leave any timestamp check off while you run this test.
In Hookdeck, paste the signing secret into the source exactly as the portal shows it, with the whsec_ prefix. Hookdeck verifies it the Standard Webhooks way, and a secret pasted without the prefix will not match. Hookdeck does not apply a timestamp window, so retried and replayed events keep passing verification; Etsy's 300-second check is something you can add in your own receiver.
Make Etsy webhooks production-ready. Hookdeck Event Gateway verifies
webhook-signature, can deduplicate onwebhook-id, and queues every order and inventory event until your service has fetched what it needs from Etsy.
Etsy webhook limitations and pain points
Thin payloads mean an API call per event
The Problem: Every event needs a follow-up request before you can act on it.
Why It Happens: Payloads carry only IDs and a resource_url. The order, listing or product has to be fetched with your app's OAuth token, which counts against your API limits and fails if the token has expired.
Workarounds:
- Acknowledge the webhook first, then fetch
resource_urlin a background job. - Refresh OAuth tokens before they expire, and retry the fetch on
401after refreshing. - Batch or rate-limit fetches during busy periods.
How Hookdeck Can Help: Hookdeck acknowledges Etsy at once and controls the rate at which events reach your service, so a burst of orders becomes a steady flow your fetch workers can handle.
Duplicates and replays
The Problem: The same event can arrive more than once.
Why It Happens: Etsy retries failed deliveries, and you can retry or "Recover" messages by hand from the portal. webhook-id stays the same across retries of one message.
Workarounds:
- Store each processed
webhook-idand skip repeats. - Make the handler safe to run twice, since
resource_urlalways returns current state.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries on the webhook-id header before they reach your service.
Signature format details are left to you
The Problem: A verifier written strictly from Etsy's page can reject every delivery.
Why It Happens: Etsy describes the computation but not the header's contents. It does not mention the v1, prefix or the delimiter between entries, and it never names Standard Webhooks, so a literal comparison of the whole entry with the computed signature fails.
Workarounds:
- Strip the version prefix and split on spaces, as in the code above, or use a Standard Webhooks library.
- Test against the portal's Testing tab before going live.
How Hookdeck Can Help: Hookdeck's Etsy source handles the Standard Webhooks header format, so your service only has to trust Hookdeck.
Retries end after about a day and a half
The Problem: A long outage loses events.
Why It Happens: Etsy makes eight attempts, with the last one roughly 27 and a half hours after the first if all fail. Disabling an endpoint also stops pending attempts. The success criterion and timeout are not documented.
Workarounds:
- Use the portal's "Recover" to resend all failed messages since a date once you are back.
- Reconcile orders from the Open API after an outage.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as Etsy sends it and stores it, then retries against your service on a schedule you control, and you can replay any event from its history.
No API for subscriptions
The Problem: You cannot script endpoint setup across environments or shops.
Why It Happens: Etsy documents only the Webhook portal for creating, disabling and deleting endpoints.
Workarounds:
- Keep a written record of each endpoint's URL, events and secret.
- Use separate endpoints for staging and production.
How Hookdeck Can Help: Point one Etsy endpoint at Hookdeck and change routing, filtering and destinations in Hookdeck instead of in the portal.
Best practices
Verify against the raw body
The signature covers "the exact bytes/string of the HTTP body before any JSON parsing". Read it with express.raw(), Buffer.from(await request.arrayBuffer()) in a Next.js route handler, or await request.body() in FastAPI.
Keep the whole secret
Store the secret with its whsec_ prefix and decode it in code. Stripping the prefix when copying it is a common cause of failed verification.
Treat the webhook as a signal
Use the payload to decide what to fetch, and act on what resource_url returns. That way duplicates and out-of-order deliveries cannot leave you with stale data.
Accept string or number IDs
Etsy does not document the JSON types of shop_id, listing_id and product_id. Convert them to strings before using them as keys.
Acknowledge fast and process asynchronously
Verify, record the webhook-id, return 200, and fetch from the API afterwards. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
Etsy signs webhook-id, webhook-timestamp and the raw body with a whsec_ secret, the Standard Webhooks way, without saying so. Once verification passes, most of the work is in the follow-up: calling resource_url with a valid OAuth token, deduplicating on webhook-id, and recovering anything that failed while you were down.
Hookdeck Event Gateway sits in front of your receiver, checks every Etsy signature, can drop duplicate deliveries, and holds each event until your service has processed it, so an outage does not leave orders unsynced.
Get started with Hookdeck for free and handle Etsy webhooks reliably in minutes.