What distinguishes digital products from physical ones is the file delivery system. When a customer purchases your digital product, Etsy handles the secure delivery of your files. This chapter covers everything you need to know about managing digital files through the API.
Etsy’s digital delivery system works as follows:
# etsy_client/files.py
"""Digital file management for Etsy listings."""
from typing import Dict, Any, List, BinaryIO, Optional
from pathlib import Path
import mimetypes
from .client import EtsyClient
from .exceptions import ValidationError
class DigitalFileOperations:
"""Operations for managing digital download files."""
# Etsy digital file constraints
MAX_FILES_PER_LISTING = 5
MAX_FILE_SIZE = 20 * 1024 * 1024 # 20MB per file
MAX_TOTAL_SIZE = 100 * 1024 * 1024 # 100MB total per listing
# Allowed file types for digital downloads
ALLOWED_EXTENSIONS = {
# Documents
'.pdf', '.doc', '.docx', '.txt', '.rtf',
# Images
'.jpg', '.jpeg', '.png', '.gif', '.svg', '.tiff', '.bmp',
# Design files
'.psd', '.ai', '.eps', '.indd',
# Archives
'.zip', '.rar',
# Spreadsheets
'.xls', '.xlsx', '.csv',
# Presentations
'.ppt', '.pptx',
# Audio
'.mp3', '.wav', '.aac',
# Video
'.mp4', '.mov', '.avi',
# Fonts
'.ttf', '.otf', '.woff', '.woff2',
# Code
'.html', '.css', '.js', '.json',
}
def __init__(self, client: EtsyClient):
self.client = client
def _validate_file(self, file_path: Path) -> None:
"""Validate a file before upload."""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
# Check extension
if file_path.suffix.lower() not in self.ALLOWED_EXTENSIONS:
raise ValidationError(
f"File type not allowed: {file_path.suffix}. "
f"Allowed types: {', '.join(self.ALLOWED_EXTENSIONS)}"
)
# Check size
size = file_path.stat().st_size
if size > self.MAX_FILE_SIZE:
raise ValidationError(
f"File too large: {size / 1024 / 1024:.1f}MB. "
f"Max: {self.MAX_FILE_SIZE / 1024 / 1024}MB"
)
def get_listing_files(
self,
shop_id: int,
listing_id: int
) -> Dict[str, Any]:
"""
Get all digital files attached to a listing.
Args:
shop_id: The shop ID
listing_id: The listing ID
Returns:
List of file data including IDs and metadata
"""
return self.client.get(
f"/application/shops/{shop_id}/listings/{listing_id}/files"
)
def upload_listing_file(
self,
shop_id: int,
listing_id: int,
file_path: str,
name: Optional[str] = None,
rank: int = 1
) -> Dict[str, Any]:
"""
Upload a digital file to a listing.
Args:
shop_id: The shop ID
listing_id: The listing ID
file_path: Path to the file to upload
name: Display name for the file (optional)
rank: File order (1-5)
Returns:
Uploaded file data
"""
path = Path(file_path)
self._validate_file(path)
# Determine filename
display_name = name or path.name
# Get mime type
mime_type, _ = mimetypes.guess_type(str(path))
mime_type = mime_type or 'application/octet-stream'
with open(path, 'rb') as f:
files = {
'file': (display_name, f, mime_type)
}
data = {
'name': display_name,
'rank': rank
}
return self.client.post(
f"/application/shops/{shop_id}/listings/{listing_id}/files",
files=files,
data=data
)
def delete_listing_file(
self,
shop_id: int,
listing_id: int,
file_id: int
) -> Dict[str, Any]:
"""
Delete a digital file from a listing.
Args:
shop_id: The shop ID
listing_id: The listing ID
file_id: The file ID to delete
Returns:
Deletion confirmation
"""
return self.client.delete(
f"/application/shops/{shop_id}/listings/{listing_id}/files/{file_id}"
)
def replace_listing_file(
self,
shop_id: int,
listing_id: int,
file_id: int,
new_file_path: str,
name: Optional[str] = None
) -> Dict[str, Any]:
"""
Replace an existing file with a new version.
Args:
shop_id: The shop ID
listing_id: The listing ID
file_id: The file ID to replace
new_file_path: Path to the new file
name: New display name (optional)
Returns:
Updated file data
"""
# Get current file info for rank
files = self.get_listing_files(shop_id, listing_id)
current_file = None
for f in files.get('results', []):
if f['listing_file_id'] == file_id:
current_file = f
break
if not current_file:
raise ValidationError(f"File {file_id} not found")
# Delete old file
self.delete_listing_file(shop_id, listing_id, file_id)
# Upload new file with same rank
return self.upload_listing_file(
shop_id,
listing_id,
new_file_path,
name=name or current_file.get('filename'),
rank=current_file.get('rank', 1)
)
def upload_multiple_files(
self,
shop_id: int,
listing_id: int,
file_paths: List[str]
) -> List[Dict[str, Any]]:
"""
Upload multiple files to a listing.
Args:
shop_id: The shop ID
listing_id: The listing ID
file_paths: List of file paths to upload
Returns:
List of upload results
"""
if len(file_paths) > self.MAX_FILES_PER_LISTING:
raise ValidationError(
f"Too many files. Max: {self.MAX_FILES_PER_LISTING}"
)
# Check total size
total_size = sum(Path(p).stat().st_size for p in file_paths)
if total_size > self.MAX_TOTAL_SIZE:
raise ValidationError(
f"Total size too large: {total_size / 1024 / 1024:.1f}MB. "
f"Max: {self.MAX_TOTAL_SIZE / 1024 / 1024}MB"
)
results = []
for rank, path in enumerate(file_paths, 1):
try:
result = self.upload_listing_file(
shop_id, listing_id, path, rank=rank
)
results.append({
'path': path,
'success': True,
'file_id': result.get('listing_file_id'),
'filename': result.get('filename')
})
except Exception as e:
results.append({
'path': path,
'success': False,
'error': str(e)
})
return results
def get_file_info(
self,
shop_id: int,
listing_id: int,
file_id: int
) -> Optional[Dict[str, Any]]:
"""
Get information about a specific file.
Args:
shop_id: The shop ID
listing_id: The listing ID
file_id: The file ID
Returns:
File information or None if not found
"""
files = self.get_listing_files(shop_id, listing_id)
for f in files.get('results', []):
if f['listing_file_id'] == file_id:
return f
return None
class DigitalProductPackager:
"""Helper class for packaging digital products."""
def __init__(self):
self.files: List[Path] = []
def add_file(self, file_path: str) -> 'DigitalProductPackager':
"""Add a file to the package."""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
self.files.append(path)
return self
def add_directory(
self,
dir_path: str,
pattern: str = "*"
) -> 'DigitalProductPackager':
"""Add all matching files from a directory."""
path = Path(dir_path)
if not path.is_dir():
raise NotADirectoryError(f"Not a directory: {dir_path}")
for file_path in path.glob(pattern):
if file_path.is_file():
self.files.append(file_path)
return self
def create_zip(
self,
output_path: str,
compression_level: int = 6
) -> Path:
"""
Create a ZIP archive of all files.
Args:
output_path: Path for the output ZIP file
compression_level: ZIP compression (0-9)
Returns:
Path to created ZIP file
"""
import zipfile
output = Path(output_path)
with zipfile.ZipFile(
output, 'w',
compression=zipfile.ZIP_DEFLATED,
compresslevel=compression_level
) as zf:
for file_path in self.files:
# Use just the filename in the archive
zf.write(file_path, file_path.name)
return output
def total_size(self) -> int:
"""Get total size of all files in bytes."""
return sum(f.stat().st_size for f in self.files)
def summary(self) -> str:
"""Get a summary of the package contents."""
lines = [f"Package contains {len(self.files)} files:"]
for f in self.files:
size = f.stat().st_size
lines.append(f" - {f.name} ({size / 1024:.1f} KB)")
total = self.total_size()
lines.append(f"\nTotal size: {total / 1024 / 1024:.2f} MB")
return '\n'.join(lines)
# scripts/upload_single_file.py
"""Upload a single digital file to a listing."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import Config
from etsy_client import EtsyClient
from etsy_client.files import DigitalFileOperations
from etsy_client.users import get_my_shop_id
def upload_digital_file(listing_id: int, file_path: str):
"""
Upload a digital file to an existing listing.
Args:
listing_id: The listing to upload to
file_path: Path to the file
"""
client = EtsyClient(
api_key=Config.ETSY_API_KEY,
access_token=Config.ETSY_ACCESS_TOKEN
)
shop_id = get_my_shop_id(client)
file_ops = DigitalFileOperations(client)
# Check existing files
existing = file_ops.get_listing_files(shop_id, listing_id)
existing_count = len(existing.get('results', []))
print(f"Listing {listing_id} has {existing_count} existing files")
if existing_count >= 5:
print("Error: Maximum 5 files per listing")
return
# Upload file
print(f"Uploading {file_path}...")
result = file_ops.upload_listing_file(
shop_id=shop_id,
listing_id=listing_id,
file_path=file_path,
rank=existing_count + 1
)
print(f"✓ File uploaded successfully!")
print(f" File ID: {result.get('listing_file_id')}")
print(f" Filename: {result.get('filename')}")
print(f" Size: {result.get('filesize', 0) / 1024:.1f} KB")
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python upload_single_file.py <listing_id> <file_path>")
sys.exit(1)
listing_id = int(sys.argv[1])
file_path = sys.argv[2]
upload_digital_file(listing_id, file_path)
Many digital products come in multiple formats:
# scripts/upload_multi_format.py
"""Upload a digital product in multiple formats."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import Config
from etsy_client import EtsyClient
from etsy_client.files import DigitalFileOperations, DigitalProductPackager
from etsy_client.users import get_my_shop_id
def upload_multi_format_product(
listing_id: int,
base_name: str,
formats_dir: str
):
"""
Upload a product available in multiple formats.
Expected directory structure:
formats_dir/
product.pdf
product.png
product.svg
product.jpg
Args:
listing_id: The listing ID
base_name: Base product name (for display)
formats_dir: Directory containing format files
"""
client = EtsyClient(
api_key=Config.ETSY_API_KEY,
access_token=Config.ETSY_ACCESS_TOKEN
)
shop_id = get_my_shop_id(client)
file_ops = DigitalFileOperations(client)
# Find all format files
formats_path = Path(formats_dir)
if not formats_path.is_dir():
print(f"Error: {formats_dir} is not a directory")
return
# Define format priority (order for upload)
format_priority = ['.pdf', '.png', '.svg', '.jpg', '.jpeg']
files_to_upload = []
for ext in format_priority:
matching = list(formats_path.glob(f"*{ext}"))
files_to_upload.extend(matching)
# Add any remaining files
for f in formats_path.iterdir():
if f.is_file() and f not in files_to_upload:
files_to_upload.append(f)
if len(files_to_upload) > 5:
print(f"Warning: Found {len(files_to_upload)} files, but max is 5")
print("Consider creating a ZIP archive instead")
files_to_upload = files_to_upload[:5]
print(f"Uploading {len(files_to_upload)} format files...")
results = file_ops.upload_multiple_files(
shop_id,
listing_id,
[str(f) for f in files_to_upload]
)
# Print results
success_count = sum(1 for r in results if r['success'])
print(f"\n✓ Uploaded {success_count}/{len(results)} files")
for result in results:
if result['success']:
print(f" ✓ {Path(result['path']).name}")
else:
print(f" ✗ {Path(result['path']).name}: {result['error']}")
def create_and_upload_bundle(
listing_id: int,
source_dir: str,
bundle_name: str
):
"""
Create a ZIP bundle and upload it.
Useful when you have many files or want to preserve folder structure.
Args:
listing_id: The listing ID
source_dir: Directory with files to bundle
bundle_name: Name for the ZIP file
"""
client = EtsyClient(
api_key=Config.ETSY_API_KEY,
access_token=Config.ETSY_ACCESS_TOKEN
)
shop_id = get_my_shop_id(client)
file_ops = DigitalFileOperations(client)
# Create package
packager = DigitalProductPackager()
packager.add_directory(source_dir)
print(packager.summary())
# Create ZIP
zip_path = f"/tmp/{bundle_name}.zip"
packager.create_zip(zip_path)
print(f"\nCreated ZIP: {zip_path}")
print(f"ZIP size: {Path(zip_path).stat().st_size / 1024 / 1024:.2f} MB")
# Upload
print("\nUploading to Etsy...")
result = file_ops.upload_listing_file(
shop_id=shop_id,
listing_id=listing_id,
file_path=zip_path,
name=f"{bundle_name}.zip"
)
print(f"✓ Bundle uploaded!")
print(f" File ID: {result.get('listing_file_id')}")
# Cleanup
Path(zip_path).unlink()
if __name__ == "__main__":
# Example usage
# upload_multi_format_product(123456, "Wall Art Print", "./products/wall-art-001")
pass
Managing multiple versions of digital files:
# etsy_client/versioning.py
"""Version management for digital product files."""
import json
import hashlib
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, asdict
from .client import EtsyClient
from .files import DigitalFileOperations
@dataclass
class FileVersion:
"""Represents a version of a digital file."""
version: str
file_hash: str
filename: str
uploaded_at: str
etsy_file_id: Optional[int] = None
notes: str = ""
class VersionManager:
"""
Manage versions of digital product files.
Tracks file versions locally and syncs with Etsy.
"""
def __init__(
self,
client: EtsyClient,
shop_id: int,
version_file: str = "file_versions.json"
):
self.client = client
self.shop_id = shop_id
self.file_ops = DigitalFileOperations(client)
self.version_file = Path(version_file)
self._versions: Dict[int, List[FileVersion]] = {}
self._load_versions()
def _load_versions(self):
"""Load version data from file."""
if self.version_file.exists():
with open(self.version_file) as f:
data = json.load(f)
for listing_id, versions in data.items():
self._versions[int(listing_id)] = [
FileVersion(**v) for v in versions
]
def _save_versions(self):
"""Save version data to file."""
data = {}
for listing_id, versions in self._versions.items():
data[str(listing_id)] = [asdict(v) for v in versions]
with open(self.version_file, 'w') as f:
json.dump(data, f, indent=2)
@staticmethod
def _compute_hash(file_path: Path) -> str:
"""Compute SHA256 hash of a file."""
sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
sha256.update(chunk)
return sha256.hexdigest()
def get_versions(self, listing_id: int) -> List[FileVersion]:
"""Get all versions for a listing."""
return self._versions.get(listing_id, [])
def get_current_version(self, listing_id: int) -> Optional[FileVersion]:
"""Get the current (latest) version."""
versions = self.get_versions(listing_id)
return versions[-1] if versions else None
def upload_new_version(
self,
listing_id: int,
file_path: str,
version: str,
notes: str = "",
replace_existing: bool = True
) -> FileVersion:
"""
Upload a new version of a digital file.
Args:
listing_id: The listing ID
file_path: Path to the new file
version: Version string (e.g., "1.0", "2.1")
notes: Release notes for this version
replace_existing: Whether to replace the current file
Returns:
FileVersion object for the new version
"""
path = Path(file_path)
file_hash = self._compute_hash(path)
# Check if this exact file was already uploaded
for v in self.get_versions(listing_id):
if v.file_hash == file_hash:
print(f"Warning: This file was already uploaded as version {v.version}")
# Get existing files
existing = self.file_ops.get_listing_files(self.shop_id, listing_id)
existing_files = existing.get('results', [])
etsy_file_id = None
if replace_existing and existing_files:
# Replace the first (primary) file
old_file_id = existing_files[0]['listing_file_id']
result = self.file_ops.replace_listing_file(
self.shop_id, listing_id, old_file_id, file_path
)
etsy_file_id = result.get('listing_file_id')
else:
# Upload as new file
result = self.file_ops.upload_listing_file(
self.shop_id, listing_id, file_path
)
etsy_file_id = result.get('listing_file_id')
# Create version record
new_version = FileVersion(
version=version,
file_hash=file_hash,
filename=path.name,
uploaded_at=datetime.now().isoformat(),
etsy_file_id=etsy_file_id,
notes=notes
)
# Store version
if listing_id not in self._versions:
self._versions[listing_id] = []
self._versions[listing_id].append(new_version)
self._save_versions()
return new_version
def check_for_changes(
self,
listing_id: int,
local_file: str
) -> bool:
"""
Check if local file differs from uploaded version.
Args:
listing_id: The listing ID
local_file: Path to local file
Returns:
True if file has changed, False otherwise
"""
current = self.get_current_version(listing_id)
if not current:
return True # No version exists
local_hash = self._compute_hash(Path(local_file))
return local_hash != current.file_hash
def version_history(self, listing_id: int) -> str:
"""Get formatted version history."""
versions = self.get_versions(listing_id)
if not versions:
return "No versions recorded"
lines = [f"Version history for listing {listing_id}:", ""]
for v in reversed(versions):
lines.append(f"v{v.version} - {v.uploaded_at}")
lines.append(f" File: {v.filename}")
if v.notes:
lines.append(f" Notes: {v.notes}")
lines.append("")
return '\n'.join(lines)
# scripts/auto_update_files.py
"""Automatically update digital files when local files change."""
import sys
import time
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import Config
from etsy_client import EtsyClient
from etsy_client.versioning import VersionManager
from etsy_client.users import get_my_shop_id
class FileUpdateHandler(FileSystemEventHandler):
"""Handle file system events for auto-updating."""
def __init__(
self,
version_manager: VersionManager,
file_mapping: Dict[str, int]
):
"""
Initialize handler.
Args:
version_manager: VersionManager instance
file_mapping: Dict mapping file paths to listing IDs
"""
self.version_manager = version_manager
self.file_mapping = file_mapping
self._debounce = {}
def on_modified(self, event):
"""Handle file modification events."""
if event.is_directory:
return
file_path = event.src_path
# Check if this file is mapped to a listing
listing_id = self.file_mapping.get(file_path)
if not listing_id:
return
# Debounce (wait for file to finish writing)
current_time = time.time()
last_event = self._debounce.get(file_path, 0)
if current_time - last_event < 2:
return
self._debounce[file_path] = current_time
# Check if file actually changed
if self.version_manager.check_for_changes(listing_id, file_path):
print(f"\nFile changed: {file_path}")
print("Uploading new version...")
# Auto-increment version
current = self.version_manager.get_current_version(listing_id)
if current:
# Simple version increment
try:
major, minor = current.version.split('.')
new_version = f"{major}.{int(minor) + 1}"
except:
new_version = f"{current.version}.1"
else:
new_version = "1.0"
self.version_manager.upload_new_version(
listing_id,
file_path,
new_version,
notes="Auto-updated from file change"
)
print(f"✓ Uploaded version {new_version}")
def watch_files(
file_mapping: Dict[str, int],
watch_dirs: List[str] = None
):
"""
Watch files for changes and auto-update Etsy.
Args:
file_mapping: Dict mapping file paths to listing IDs
watch_dirs: Directories to watch (derived from file_mapping if not provided)
"""
client = EtsyClient(
api_key=Config.ETSY_API_KEY,
access_token=Config.ETSY_ACCESS_TOKEN
)
shop_id = get_my_shop_id(client)
version_manager = VersionManager(client, shop_id)
handler = FileUpdateHandler(version_manager, file_mapping)
observer = Observer()
# Determine directories to watch
if watch_dirs is None:
watch_dirs = list(set(
str(Path(p).parent) for p in file_mapping.keys()
))
for dir_path in watch_dirs:
observer.schedule(handler, dir_path, recursive=False)
print(f"Watching: {dir_path}")
observer.start()
print("\nFile watcher started. Press Ctrl+C to stop.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
print("\nFile watcher stopped.")
# Example configuration
EXAMPLE_MAPPING = {
"/path/to/products/planner-2026.pdf": 123456789,
"/path/to/products/wall-art-bundle.zip": 987654321,
}
if __name__ == "__main__":
# watch_files(EXAMPLE_MAPPING)
print("Configure EXAMPLE_MAPPING with your file paths and listing IDs")
# etsy_client/file_naming.py
"""File naming utilities for digital products."""
import re
from pathlib import Path
def sanitize_filename(name: str) -> str:
"""
Create a clean, customer-friendly filename.
Args:
name: Original filename
Returns:
Sanitized filename
"""
# Remove special characters
name = re.sub(r'[^\w\s.-]', '', name)
# Replace multiple spaces/underscores with single hyphen
name = re.sub(r'[\s_]+', '-', name)
# Remove multiple hyphens
name = re.sub(r'-+', '-', name)
# Trim hyphens from ends
name = name.strip('-')
return name
def create_descriptive_filename(
product_name: str,
format_type: str,
dimensions: str = None,
version: str = None
) -> str:
"""
Create a descriptive filename for customer downloads.
Args:
product_name: Name of the product
format_type: File format (PDF, PNG, etc.)
dimensions: Size/dimensions if applicable
version: Version number
Returns:
Descriptive filename
"""
parts = [sanitize_filename(product_name)]
if dimensions:
parts.append(sanitize_filename(dimensions))
if version:
parts.append(f"v{version}")
filename = '-'.join(parts)
extension = format_type.lower().lstrip('.')
return f"{filename}.{extension}"
# Example:
# create_descriptive_filename(
# "2026 Digital Planner",
# "pdf",
# "iPad-Portrait",
# "2.0"
# )
# Returns: "2026-Digital-Planner-iPad-Portrait-v2.0.pdf"
# etsy_client/file_organization.py
"""File organization for digital product businesses."""
from pathlib import Path
from typing import Dict, List
import shutil
class ProductFileOrganizer:
"""Organize digital product files for easy management."""
def __init__(self, base_dir: str):
"""
Initialize organizer.
Args:
base_dir: Base directory for all products
"""
self.base_dir = Path(base_dir)
self.base_dir.mkdir(parents=True, exist_ok=True)
def create_product_structure(
self,
product_id: str,
formats: List[str] = None
) -> Path:
"""
Create directory structure for a new product.
Args:
product_id: Unique product identifier
formats: List of format subdirectories to create
Returns:
Path to product directory
"""
product_dir = self.base_dir / product_id
product_dir.mkdir(exist_ok=True)
# Standard subdirectories
(product_dir / 'source').mkdir(exist_ok=True) # Original/editable files
(product_dir / 'export').mkdir(exist_ok=True) # Customer-ready files
(product_dir / 'images').mkdir(exist_ok=True) # Listing images
(product_dir / 'archive').mkdir(exist_ok=True) # Old versions
# Format-specific directories
if formats:
for fmt in formats:
(product_dir / 'export' / fmt.lower()).mkdir(exist_ok=True)
# Create metadata file
metadata = {
'product_id': product_id,
'created': datetime.now().isoformat(),
'formats': formats or [],
'etsy_listing_id': None
}
import json
with open(product_dir / 'metadata.json', 'w') as f:
json.dump(metadata, f, indent=2)
return product_dir
def get_export_files(self, product_id: str) -> List[Path]:
"""Get all export-ready files for a product."""
export_dir = self.base_dir / product_id / 'export'
if not export_dir.exists():
return []
files = []
for path in export_dir.rglob('*'):
if path.is_file():
files.append(path)
return files
def archive_version(
self,
product_id: str,
version: str
):
"""Archive current export files before updating."""
product_dir = self.base_dir / product_id
export_dir = product_dir / 'export'
archive_dir = product_dir / 'archive' / version
if export_dir.exists():
shutil.copytree(export_dir, archive_dir)
With digital file management mastered, you’re ready to handle the business side: orders and transactions. The next chapter covers processing sales, tracking revenue, and managing customer transactions through the API.
| ← Chapter 5: Managing Listings | Table of Contents | Chapter 7: Orders and Transactions → |