Etsy Connector: developer notes
The term ‘Etsy’ is a trademark of Etsy, Inc. This application uses the Etsy API but is not endorsed or certified by Etsy, Inc.
Add-on id kv_etsy, PHP namespace Tygh\Addons\KvEtsy. Built on the Kavio Marketplace Hub (Hub\*, channel independent) with one channel
driver: Client\Api (HTTP), Drivers\Etsy\Mapper (pure translation, unit tested), Drivers\Etsy\Driver (Hub\DriverInterface).
Sources (read 2026-11-07)
Section titled “Sources (read 2026-11-07)”| Source | URL | Local copy |
|---|---|---|
| Open API v3 specification 3.0.0 (the reference) | https://www.etsy.com/openapi/generated/oas/3.0.0.json | scratchpad/etsy/docs-raw/oas.json |
| Authentication (API key, OAuth 2.0, PKCE, scopes, refresh) | https://developers.etsy.com/documentation/essentials/authentication | docs-raw/essentials_authentication.txt |
| Request standards (hosts, headers, UTF-8) | https://developers.etsy.com/documentation/essentials/requests | docs-raw/essentials_requests.txt |
| Rate limits | https://developers.etsy.com/documentation/essentials/rate-limits | docs-raw/essentials_rate-limits.txt |
| Webhooks (events, payloads, signatures) | https://developers.etsy.com/documentation/essentials/webhooks | docs-raw/essentials_webhooks.txt |
| App types, commercial access criteria, required trademark sentence | https://developers.etsy.com/documentation/ | docs-raw/documentation_index.txt |
node scratchpad/etsy/show.mjs op <operationId> prints the parameters and body fields of an operation from the spec copy.
Hosts and headers
Section titled “Hosts and headers”- Host
https://api.etsy.com(requests guide:api.etsy.comandopenapi.etsy.com“are equivalent”; the spec listsopenapi.etsy.com). - Every request:
x-api-key: <keystring>:<shared secret>(authentication guide, “API Key”; specsecuritySchemes.api_key). - Scoped endpoints:
Authorization: Bearer <user id>.<token>(requests guide). Bodies are UTF-8; form bodies are sent asapplication/x-www-form-urlencoded; charset=utf-8(requests guide, “Encoding”). - Test override:
$config['kv_etsy_base_url']inconfig.local.phpreplaces the host for every call, including the token endpoint (the authorization pagehttps://www.etsy.com/oauth/connectis opened by the seller’s browser and is never called by the store).
OAuth 2.0 authorization code grant with PKCE
Section titled “OAuth 2.0 authorization code grant with PKCE”| Step | Call | Fields |
|---|---|---|
| 1 | browser GET https://www.etsy.com/oauth/connect |
response_type=code, client_id (keystring), redirect_uri (https, exact match), scope (space separated), state, code_challenge (base64url SHA-256 of the verifier), code_challenge_method=S256 |
| 2 | Etsy redirects to index.php?dispatch=kv_etsy.authorize |
code, state (or error, error_description) |
| 3 | POST /v3/public/oauth/token (form) |
grant_type=authorization_code, client_id, redirect_uri, code, code_verifier -> access_token (1 h, <user id>. prefix), refresh_token (90 days), expires_in, scope |
| refresh | POST /v3/public/oauth/token (form) |
grant_type=refresh_token, client_id, refresh_token -> new access AND new refresh token |
Implementation: Accounts::authState() = <account>.<time>.<nonce>.<HMAC> (1 hour, single use via Accounts::consumeState());
Accounts::verifier() = 43 base64url characters derived from the state with the store secret (RFC 7636: 43-128 of [A-Za-z0-9._~-]),
so no verifier is stored. Api::challenge() is checked against RFC 7636 Appendix B and the pair in Etsy’s guide (unit tests).
The refresh runs under GET_LOCK (Idempotency::lock('token-...')) and re-reads the stored set after taking the lock, because every refresh
rotates the refresh token. A refresh answered 400/401/403 (e.g. invalid_grant) PAUSES the account (auth.paused, connection check “Paused”):
Accounts::active() is false, cron schedules nothing and Hub::driver() returns null until the seller connects again.
Scopes requested (Api::SCOPES), with the endpoints that need them (spec security of each operation):
| Scope | Used by |
|---|---|
listings_r |
getListingsByShop, getListingInventory |
listings_w |
createDraftListing, updateListing, updateListingInventory, uploadListingImage, updateListingProperty |
shops_r |
getMe, getShopShippingProfiles, getShopReadinessStateDefinitions |
transactions_r |
getShopReceipts, getShopReceipt |
transactions_w |
createReceiptShipment |
listings_d, email_r, address_r are not requested (no delete; buyer e-mail access is a separate approval for commercial apps).
Endpoints
Section titled “Endpoints”| Operation (spec operationId) | Method + path | Fields used |
|---|---|---|
| ping | GET /v3/application/openapi-ping |
API key only (connection test before authorization) |
| getMe | GET /v3/application/users/me |
user_id, shop_id |
| getShop | GET /v3/application/shops/{shop_id} |
shop_name, currency_code, url |
| getSellerTaxonomyNodes | GET /v3/application/seller-taxonomy/nodes |
id, name, parent_id, children |
| getPropertiesByTaxonomyId | GET /v3/application/seller-taxonomy/nodes/{taxonomy_id}/properties |
property_id, display_name, is_required, supports_attributes, is_multivalued, possible_values[].value_id/name |
| getShopShippingProfiles | GET /v3/application/shops/{shop_id}/shipping-profiles |
shipping_profile_id, title, origin_country_iso, is_deleted |
| getShopReturnPolicies | GET /v3/application/shops/{shop_id}/policies/return |
return_policy_id, accepts_returns, return_deadline |
| getShopReadinessStateDefinitions | GET /v3/application/shops/{shop_id}/readiness-state-definitions |
readiness_state_id, readiness_state, processing_days_display_label |
| getShippingCarriers | GET /v3/application/shipping-carriers?origin_country_iso= |
name |
| createDraftListing | POST /v3/application/shops/{shop_id}/listings (form) |
required quantity, title, description, price, who_made, when_made, taxonomy_id; shipping_profile_id (required for physical), return_policy_id, readiness_state_id, tags, is_supply, type=physical, item_weight + item_weight_unit |
| updateListing | PATCH /v3/application/shops/{shop_id}/listings/{listing_id} (form) |
state=active (publish; “requires that the listing have an image”) |
| getListingsByShop | GET /v3/application/shops/{shop_id}/listings |
state, limit (max 100), offset, includes=Inventory; reads listing_id, title, state, created_timestamp, inventory.products[].sku |
| getListingInventory | GET /v3/application/listings/{listing_id}/inventory |
products[].sku/offerings/property_values, *_on_property |
| updateListingInventory | PUT /v3/application/listings/{listing_id}/inventory (JSON) |
products[]{sku, property_values[]{property_id, value_ids, values, scale_id}, offerings[]{price, quantity, is_enabled, readiness_state_id}}, price_on_property, quantity_on_property, sku_on_property, readiness_state_on_property |
| uploadListingImage | POST /v3/application/shops/{shop_id}/listings/{listing_id}/images (multipart) |
image (file), rank, alt_text (max 500) |
| updateListingProperty | PUT /v3/application/shops/{shop_id}/listings/{listing_id}/properties/{property_id} (form) |
value_ids, values |
| getShopReceipts | GET /v3/application/shops/{shop_id}/receipts |
min_last_modified, limit (max 100), offset, sort_on=updated, sort_order=asc; reads ShopReceipt incl. transactions |
| getShopReceipt | GET /v3/application/shops/{shop_id}/receipts/{receipt_id} |
reads shipments[].tracking_code before a tracking push; webhook follow-up |
| createReceiptShipment | POST /v3/application/shops/{shop_id}/receipts/{receipt_id}/tracking (JSON) |
tracking_code, carrier_name, send_bcc=false |
Receipt -> order (Mapper::order): receipt_id, status, is_paid, is_shipped, name (split at the last space, mb-safe), first_line,
second_line, city, state, zip, country_iso, buyer_email (null unless Etsy approved access: a placeholder address is used),
payment_method, message_from_buyer, is_gift, gift_message, grandtotal, total_shipping_cost, total_tax_cost, total_vat_cost,
discount_amt, created_timestamp, updated_timestamp, shipments[], transactions[] (transaction_id, sku, title, quantity,
price, listing_id, variations[].formatted_name/formatted_value). Money = amount / divisor (spec, Money schema).
Status (ShopReceipt.status enum paid, completed, open, payment processing, canceled, fully refunded, partially refunded):
paid -> created (shipped when is_shipped), completed -> shipped, canceled -> cancelled, fully refunded -> returned,
partially refunded -> created / shipped. open and payment processing are pending: never imported, never mapped to cancelled.
Rate limits
Section titled “Rate limits”QPS and QPD per API key; 429 with retry-after (seconds); headers x-limit-per-second, x-remaining-this-second, x-limit-per-day,
x-remaining-today (rate-limits guide). Api::call() waits once for a retry-after of 1-3 s; anything longer is thrown as retryable and
the job queue backs off (5 min, 15 min, 1 h, 3 h, 12 h). A run that stops on a 429 keeps what it created (see “Never twice”).
Webhooks
Section titled “Webhooks”Events order.paid, order.canceled, order.shipped, order.delivered, listing.*, inventory.*; payload event_type, resource_url,
shop_id (+ listing_id, product_id). Subscriptions are made in Etsy’s webhook portal (no API). Signature (Webhook::verify):
base64(HMAC-SHA256(base64_decode(secret without "whsec_"), webhook-id + "." + webhook-timestamp + "." + raw body)) compared with every
entry of webhook-signature; |now - webhook-timestamp| <= 300. The event is stored as a queue job keyed by webhook-id; order events are
read back with getShopReceipt only when resource_url matches https://(api|openapi).etsy.com/v3/application/shops/<our shop>/receipts/<id>
(the URL itself is never fetched).
Never twice (checklist: idempotent create, locks)
Section titled “Never twice (checklist: idempotent create, locks)”- Listings: a pending marker (SKU, title, time) is cached BEFORE createDraftListing; the listing id is saved right after it
(
Hub\ProductSync::created). A retry looks the SKU up in the cache, then in the shop index (getListingsByShop includes=Inventory, every state), then, when a create was pending, in the drafts created since the marker with the same title, and links what it finds. - Orders:
?:kv_etsy_order_maprow claimed withINSERT IGNORE(unique company + receipt) before the order is placed, per-receiptGET_LOCK,placing_atmarker, theplace_orderhook writes the order id the moment the order exists, and a notes marker[kv_etsy:<account>:<receipt>]lets an interrupted attempt adopt its order instead of placing a second one. - Tracking: the receipt is read first; a shipment with the same tracking code is not sent again (each call e-mails the buyer). A refusal of the request (400/403/404/409/422) is final for that order (“Send tracking again” on the Orders tab); authorization problems are retried.
- Import cursor: the Etsy
updated_timestampof the last receipt READ (optionorder_cursor), not the local time of the last success; failed receipts are re-read by id (getShopReceipt) once their wait is over or when “Retry” is clicked. - Listing steps: SKU/quantity, images, properties and publish are recorded per listing; a run stopped by a rate limit or timeout finishes the remaining steps on the next sync (no second create, no step twice).
- Currency: outgoing prices use the account’s own Etsy shop currency (fixed rate on the Rules page, else the CS-Cart currency table; unknown currency = no price push, logged); incoming receipt amounts are converted back to the primary currency the same way.
Field limits (mb-safe)
Section titled “Field limits (mb-safe)”Cuts use mb_substr / mb_strlen with UTF-8 (Mapper::cut). Title: allowed character class and “%, :, & and + once each” from the spec;
tags: allowed characters from the spec; styles 2 x 45 (spec, not used); alt_text 500 (spec).
Time zone
Section titled “Time zone”Etsy timestamps are Unix seconds. The order date is the receipt’s created_timestamp; dates shown in notes use date() (the shop time zone
CS-Cart sets from Settings > Appearance). “Import orders from” is a calendar day in the shop time zone (Accounts::importFrom, DateTime with
the PHP default zone). The Etsy Shop object has no time zone field (spec Shop schema), so the CS-Cart shop zone is the one used.
Unverified / flagged
Section titled “Unverified / flagged”- Title 140 characters, 13 tags of 20 characters: Etsy’s seller-facing limits (Seller Handbook); the API spec names no length. Enforced as
constants in
Mapper(TITLE_MAX, TAGS_MAX, TAG_LEN). If Etsy rejects a longer value it answers 400 and the product keeps the error. - Array encoding in form bodies (
tags,value_ids,values): the spec has noencodingobject, so the OpenAPI 3 default (style form, explode true:tags=a&tags=b) is used; the tag description says “comma-separated list”. Not verified against the live API. - Redirect URI with a query string (
index.php?dispatch=kv_etsy.authorize): Etsy requires an exact match of the registered URL; whether a registered URL may carry a query string is not documented. With SEO URLs the store’s own address is used unchanged. - createReceiptShipment access: the spec says that in regions where Etsy’s Preferred Partner Program applies only approved fulfilment apps, personal-use apps owned by the seller, or apps the seller authorized before the program launched may call it (others get 403). A 403 is shown on the order row and is not retried.
- Address fields of receipts are “contingent in some regions to a preferred partnership status” (spec, getShopReceipts). An order without a street address is still imported; the order shows what Etsy returned.
- App type for a sold add-on: each merchant registers a Seller App for their own shop (developers.etsy.com: “for your own shop use only”, approved within minutes). Whether a purchased third-party add-on run by the seller with the seller’s own app counts as “own shop use” is not stated; the alternative is a Personal App with Commercial Access (manual review). Owner item.
- Webhook signature header format: the guide shows one signature and “if more than one” entries; the entry separator and the
v1,prefix are taken from the Standard Webhooks convention and accepted both ways. - Offering quantity 0: the spec allows
minimum=0per offering; whether Etsy accepts 0 on the only enabled offering (sold out) or wants the offering disabled is not documented. The add-on sends 0 and shows Etsy’s answer. - No sandbox exists: tests run against
tests/mock-server/server.js, which follows the documented contract above. - A used refresh token stops working: the guide says a refresh returns a new refresh token; that the old one is invalidated at once is assumed (the refresh runs under a lock and re-reads the stored set, so either behaviour is safe).
- Daily request quota: the rate-limit guide documents the headers but no default numbers. Unchanged stock and prices are not sent again (the inventory is compared first), orders are imported before stock is pushed.
- Address access: when Etsy withholds the address of a receipt (see 5), the order gets the country default of the store.