Skip to content

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).

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.

  • Host https://api.etsy.com (requests guide: api.etsy.com and openapi.etsy.com “are equivalent”; the spec lists openapi.etsy.com).
  • Every request: x-api-key: <keystring>:<shared secret> (authentication guide, “API Key”; spec securitySchemes.api_key).
  • Scoped endpoints: Authorization: Bearer <user id>.<token> (requests guide). Bodies are UTF-8; form bodies are sent as application/x-www-form-urlencoded; charset=utf-8 (requests guide, “Encoding”).
  • Test override: $config['kv_etsy_base_url'] in config.local.php replaces the host for every call, including the token endpoint (the authorization page https://www.etsy.com/oauth/connect is 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).

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.

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”).

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_map row claimed with INSERT IGNORE (unique company + receipt) before the order is placed, per-receipt GET_LOCK, placing_at marker, the place_order hook 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_timestamp of the last receipt READ (option order_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.

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).

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.

  1. 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.
  2. Array encoding in form bodies (tags, value_ids, values): the spec has no encoding object, 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. Offering quantity 0: the spec allows minimum=0 per 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.
  9. No sandbox exists: tests run against tests/mock-server/server.js, which follows the documented contract above.
  10. 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).
  11. 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.
  12. Address access: when Etsy withholds the address of a receipt (see 5), the order gets the country default of the store.