Etsy API v3 uses OAuth 2.0 with PKCE (Proof Key for Code Exchange) for authentication. PKCE adds an extra security layer, making the authorization flow safe even for public clients where client secrets cannot be securely stored.
┌──────────┐ ┌──────────┐
│ Your │ │ Etsy │
│ App │ │ API │
└────┬─────┘ └────┬─────┘
│ │
│ 1. Generate code_verifier & challenge │
│ │
│ 2. Redirect user to Etsy ──────────────>│
│ (with code_challenge) │
│ │
│ │ 3. User logs in
│ │ and authorizes
│ │
│<────────── 4. Redirect back ────────────│
│ (with authorization code) │
│ │
│ 5. Exchange code for tokens ───────────>│
│ (with code_verifier) │
│ │
│<────────── 6. Access & Refresh tokens ──│
│ │
│ 7. Make API requests ──────────────────>│
│ (with access_token) │
│ │
# etsy_client/auth.py
"""OAuth 2.0 with PKCE authentication for Etsy API."""
import base64
import hashlib
import secrets
import json
import time
import webbrowser
from pathlib import Path
from typing import Optional, Dict, Tuple
from urllib.parse import urlencode, parse_qs, urlparse
import requests
class EtsyOAuth:
"""
Handle OAuth 2.0 authentication with PKCE for Etsy API.
Usage:
oauth = EtsyOAuth(
api_key="your_api_key",
redirect_uri="http://localhost:3000/callback",
scopes=["listings_r", "listings_w", "transactions_r"]
)
# Get authorization URL
auth_url = oauth.get_authorization_url()
# After user authorizes, exchange code for tokens
tokens = oauth.exchange_code(authorization_code)
"""
AUTHORIZE_URL = "https://www.etsy.com/oauth/connect"
TOKEN_URL = "https://api.etsy.com/v3/public/oauth/token"
def __init__(
self,
api_key: str,
redirect_uri: str,
scopes: list[str],
token_file: Optional[str] = None
):
"""
Initialize OAuth handler.
Args:
api_key: Etsy API key (keystring)
redirect_uri: OAuth callback URL
scopes: List of required permission scopes
token_file: Path to store/load tokens (optional)
"""
self.api_key = api_key
self.redirect_uri = redirect_uri
self.scopes = scopes
self.token_file = Path(token_file) if token_file else None
# PKCE values (generated per authorization flow)
self._code_verifier: Optional[str] = None
self._code_challenge: Optional[str] = None
self._state: Optional[str] = None
# Token storage
self._tokens: Optional[Dict] = None
# Load existing tokens if available
if self.token_file and self.token_file.exists():
self._load_tokens()
@staticmethod
def _generate_code_verifier(length: int = 64) -> str:
"""Generate a cryptographically random code verifier."""
# Use URL-safe characters
return secrets.token_urlsafe(length)[:128]
@staticmethod
def _generate_code_challenge(verifier: str) -> str:
"""Generate code challenge from verifier using SHA256."""
digest = hashlib.sha256(verifier.encode('utf-8')).digest()
return base64.urlsafe_b64encode(digest).rstrip(b'=').decode('utf-8')
@staticmethod
def _generate_state() -> str:
"""Generate random state for CSRF protection."""
return secrets.token_urlsafe(32)
def _generate_pkce(self) -> Tuple[str, str]:
"""Generate PKCE code verifier and challenge pair."""
self._code_verifier = self._generate_code_verifier()
self._code_challenge = self._generate_code_challenge(self._code_verifier)
return self._code_verifier, self._code_challenge
def get_authorization_url(self) -> str:
"""
Generate the authorization URL for the user to visit.
Returns:
Authorization URL string
"""
# Generate new PKCE values
self._generate_pkce()
self._state = self._generate_state()
params = {
"response_type": "code",
"client_id": self.api_key,
"redirect_uri": self.redirect_uri,
"scope": " ".join(self.scopes),
"state": self._state,
"code_challenge": self._code_challenge,
"code_challenge_method": "S256"
}
return f"{self.AUTHORIZE_URL}?{urlencode(params)}"
def open_authorization_page(self) -> str:
"""
Open the authorization page in the default browser.
Returns:
The authorization URL that was opened
"""
auth_url = self.get_authorization_url()
webbrowser.open(auth_url)
return auth_url
def parse_callback_url(self, callback_url: str) -> Dict[str, str]:
"""
Parse the callback URL to extract authorization code.
Args:
callback_url: The full callback URL from Etsy
Returns:
Dictionary with 'code' and 'state' keys
Raises:
ValueError: If state doesn't match or error is present
"""
parsed = urlparse(callback_url)
params = parse_qs(parsed.query)
# Check for errors
if 'error' in params:
error = params['error'][0]
description = params.get('error_description', ['Unknown error'])[0]
raise ValueError(f"Authorization error: {error} - {description}")
# Verify state
returned_state = params.get('state', [None])[0]
if returned_state != self._state:
raise ValueError("State mismatch - possible CSRF attack")
return {
'code': params['code'][0],
'state': returned_state
}
def exchange_code(self, authorization_code: str) -> Dict[str, str]:
"""
Exchange authorization code for access and refresh tokens.
Args:
authorization_code: Code received from OAuth callback
Returns:
Dictionary containing access_token, refresh_token, and metadata
"""
if not self._code_verifier:
raise ValueError("No code verifier available. Call get_authorization_url first.")
data = {
"grant_type": "authorization_code",
"client_id": self.api_key,
"redirect_uri": self.redirect_uri,
"code": authorization_code,
"code_verifier": self._code_verifier
}
response = requests.post(
self.TOKEN_URL,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
if response.status_code != 200:
error_data = response.json()
raise ValueError(
f"Token exchange failed: {error_data.get('error_description', 'Unknown error')}"
)
self._tokens = response.json()
self._tokens['obtained_at'] = int(time.time())
if self.token_file:
self._save_tokens()
return self._tokens
def refresh_access_token(self) -> Dict[str, str]:
"""
Refresh the access token using the refresh token.
Returns:
Dictionary containing new access_token and metadata
"""
if not self._tokens or 'refresh_token' not in self._tokens:
raise ValueError("No refresh token available")
data = {
"grant_type": "refresh_token",
"client_id": self.api_key,
"refresh_token": self._tokens['refresh_token']
}
response = requests.post(
self.TOKEN_URL,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
if response.status_code != 200:
error_data = response.json()
raise ValueError(
f"Token refresh failed: {error_data.get('error_description', 'Unknown error')}"
)
self._tokens = response.json()
self._tokens['obtained_at'] = int(time.time())
if self.token_file:
self._save_tokens()
return self._tokens
def get_access_token(self) -> Optional[str]:
"""Get the current access token, refreshing if necessary."""
if not self._tokens:
return None
# Check if token is expired (with 5 minute buffer)
expires_in = self._tokens.get('expires_in', 3600)
obtained_at = self._tokens.get('obtained_at', 0)
expires_at = obtained_at + expires_in - 300
if time.time() > expires_at:
try:
self.refresh_access_token()
except ValueError:
return None
return self._tokens.get('access_token')
def get_refresh_token(self) -> Optional[str]:
"""Get the current refresh token."""
return self._tokens.get('refresh_token') if self._tokens else None
def is_authenticated(self) -> bool:
"""Check if valid tokens are available."""
return self.get_access_token() is not None
def _save_tokens(self):
"""Save tokens to file."""
if self.token_file and self._tokens:
self.token_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.token_file, 'w') as f:
json.dump(self._tokens, f, indent=2)
def _load_tokens(self):
"""Load tokens from file."""
if self.token_file and self.token_file.exists():
with open(self.token_file, 'r') as f:
self._tokens = json.load(f)
def clear_tokens(self):
"""Clear stored tokens."""
self._tokens = None
if self.token_file and self.token_file.exists():
self.token_file.unlink()
class TokenManager:
"""
Manage token lifecycle with automatic refresh.
Usage:
manager = TokenManager(oauth_client)
# Get valid access token (auto-refreshes if needed)
token = manager.get_valid_token()
"""
def __init__(self, oauth: EtsyOAuth, refresh_threshold: int = 300):
"""
Initialize token manager.
Args:
oauth: EtsyOAuth instance
refresh_threshold: Seconds before expiry to trigger refresh
"""
self.oauth = oauth
self.refresh_threshold = refresh_threshold
def get_valid_token(self) -> str:
"""
Get a valid access token, refreshing if necessary.
Returns:
Valid access token
Raises:
ValueError: If no valid token can be obtained
"""
token = self.oauth.get_access_token()
if not token:
raise ValueError(
"No valid token available. Please re-authorize the application."
)
return token
def ensure_authenticated(self) -> bool:
"""
Ensure the application is authenticated.
Returns:
True if authenticated, False otherwise
"""
try:
self.get_valid_token()
return True
except ValueError:
return False
Create a script to handle the initial authorization:
# scripts/authorize.py
"""Script to authorize the application with Etsy."""
import sys
from pathlib import Path
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
import threading
import time
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import Config
from etsy_client.auth import EtsyOAuth
# Configuration
REDIRECT_URI = "http://localhost:3000/callback"
TOKEN_FILE = "tokens.json"
SCOPES = [
"address_r",
"listings_r",
"listings_w",
"listings_d",
"shops_r",
"transactions_r",
"profile_r",
]
class CallbackHandler(BaseHTTPRequestHandler):
"""Handle OAuth callback requests."""
authorization_code = None
callback_url = None
def do_GET(self):
"""Handle GET request to callback URL."""
CallbackHandler.callback_url = f"http://localhost:3000{self.path}"
# Parse the code from the URL
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
if 'code' in params:
CallbackHandler.authorization_code = params['code'][0]
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(b"""
<html>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1>Authorization Successful!</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>
""")
else:
error = params.get('error', ['Unknown error'])[0]
self.send_response(400)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(f"""
<html>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1>Authorization Failed</h1>
<p>Error: {error}</p>
</body>
</html>
""".encode())
def log_message(self, format, *args):
"""Suppress default logging."""
pass
def run_callback_server(port: int = 3000, timeout: int = 120):
"""
Run a temporary server to receive the OAuth callback.
Args:
port: Port to listen on
timeout: Maximum time to wait for callback
"""
server = HTTPServer(('localhost', port), CallbackHandler)
server.timeout = timeout
print(f"Waiting for authorization callback on port {port}...")
# Handle one request
server.handle_request()
return CallbackHandler.callback_url
def authorize():
"""Run the complete authorization flow."""
print("=" * 50)
print("Etsy OAuth Authorization")
print("=" * 50)
# Initialize OAuth client
oauth = EtsyOAuth(
api_key=Config.ETSY_API_KEY,
redirect_uri=REDIRECT_URI,
scopes=SCOPES,
token_file=TOKEN_FILE
)
# Check for existing valid tokens
if oauth.is_authenticated():
print("\n✓ Already authenticated with valid tokens!")
confirm = input("Re-authorize anyway? (y/N): ")
if confirm.lower() != 'y':
return oauth
# Generate authorization URL
auth_url = oauth.get_authorization_url()
print("\nOpening authorization page in your browser...")
print(f"\nIf it doesn't open automatically, visit:\n{auth_url}\n")
# Open browser
oauth.open_authorization_page()
# Start callback server in a thread
callback_url = run_callback_server()
if not CallbackHandler.authorization_code:
print("\n✗ Authorization failed - no code received")
return None
print("\nExchanging authorization code for tokens...")
try:
# Parse callback and exchange code
callback_data = oauth.parse_callback_url(callback_url)
tokens = oauth.exchange_code(callback_data['code'])
print("\n✓ Authorization successful!")
print(f" Access token: {tokens['access_token'][:20]}...")
print(f" Expires in: {tokens['expires_in']} seconds")
print(f" Token saved to: {TOKEN_FILE}")
return oauth
except ValueError as e:
print(f"\n✗ Authorization failed: {e}")
return None
def main():
"""Main entry point."""
Config.validate()
oauth = authorize()
if oauth and oauth.is_authenticated():
print("\n" + "=" * 50)
print("Next steps:")
print("1. Your tokens are saved in tokens.json")
print("2. Update your .env file with the access token")
print("3. Run scripts/test_setup.py to verify")
print("=" * 50)
if __name__ == "__main__":
main()
For long-running applications, implement automatic token refresh:
# etsy_client/token_refresh.py
"""Automatic token refresh for Etsy API client."""
import time
import logging
import threading
from typing import Callable, Optional
from .auth import EtsyOAuth, TokenManager
logger = logging.getLogger(__name__)
class AutoRefreshingTokenManager:
"""
Token manager with automatic background refresh.
Usage:
manager = AutoRefreshingTokenManager(oauth)
manager.start()
# Use tokens
token = manager.get_token()
# Stop when done
manager.stop()
"""
def __init__(
self,
oauth: EtsyOAuth,
refresh_interval: int = 1800, # 30 minutes
on_refresh: Optional[Callable[[str], None]] = None
):
"""
Initialize auto-refreshing token manager.
Args:
oauth: EtsyOAuth instance with valid tokens
refresh_interval: How often to check/refresh tokens (seconds)
on_refresh: Callback when tokens are refreshed
"""
self.oauth = oauth
self.refresh_interval = refresh_interval
self.on_refresh = on_refresh
self._running = False
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
def start(self):
"""Start the automatic refresh background thread."""
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._refresh_loop, daemon=True)
self._thread.start()
logger.info("Token auto-refresh started")
def stop(self):
"""Stop the automatic refresh."""
self._running = False
if self._thread:
self._thread.join(timeout=5)
logger.info("Token auto-refresh stopped")
def _refresh_loop(self):
"""Background loop to refresh tokens."""
while self._running:
try:
with self._lock:
# Check if refresh is needed
if self.oauth._tokens:
expires_in = self.oauth._tokens.get('expires_in', 3600)
obtained_at = self.oauth._tokens.get('obtained_at', 0)
expires_at = obtained_at + expires_in
# Refresh if less than 10 minutes remaining
if time.time() > expires_at - 600:
logger.info("Refreshing access token...")
self.oauth.refresh_access_token()
if self.on_refresh:
self.on_refresh(self.oauth.get_access_token())
logger.info("Token refreshed successfully")
except Exception as e:
logger.error(f"Token refresh failed: {e}")
# Sleep until next check
time.sleep(self.refresh_interval)
def get_token(self) -> str:
"""Get the current access token (thread-safe)."""
with self._lock:
token = self.oauth.get_access_token()
if not token:
raise ValueError("No valid token available")
return token
class ClientWithAutoRefresh:
"""
Etsy client wrapper with automatic token refresh.
Usage:
client = ClientWithAutoRefresh(
api_key="your_key",
oauth=oauth_instance
)
# Client automatically uses fresh tokens
listings = client.get_listings()
"""
def __init__(self, api_key: str, oauth: EtsyOAuth):
from .client import EtsyClient
self.oauth = oauth
self.token_manager = AutoRefreshingTokenManager(
oauth,
on_refresh=self._on_token_refresh
)
self.client = EtsyClient(
api_key=api_key,
access_token=oauth.get_access_token()
)
self.token_manager.start()
def _on_token_refresh(self, new_token: str):
"""Update client when token is refreshed."""
self.client.set_access_token(new_token)
def __getattr__(self, name):
"""Proxy attribute access to underlying client."""
return getattr(self.client, name)
def close(self):
"""Stop token refresh and cleanup."""
self.token_manager.stop()
For command-line applications, create a user-friendly authorization helper:
# scripts/interactive_auth.py
"""Interactive authorization helper with step-by-step guidance."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import Config
from etsy_client.auth import EtsyOAuth
def interactive_authorize():
"""Guide user through authorization with manual code entry."""
print("\n" + "=" * 60)
print("Etsy API Authorization Helper")
print("=" * 60)
# Determine redirect URI
print("\nStep 1: Configure your redirect URI")
print("-" * 40)
print("In your Etsy app settings, set the callback URL to:")
print(" http://localhost:3000/callback")
print("\nOr enter a custom redirect URI:")
redirect_uri = input("Redirect URI [http://localhost:3000/callback]: ").strip()
if not redirect_uri:
redirect_uri = "http://localhost:3000/callback"
# Configure scopes
print("\nStep 2: Select permissions (scopes)")
print("-" * 40)
available_scopes = [
("listings_r", "Read listings", True),
("listings_w", "Write listings", True),
("listings_d", "Delete listings", False),
("transactions_r", "Read orders/transactions", True),
("shops_r", "Read shop information", True),
("profile_r", "Read profile", True),
("email_r", "Read email address", False),
("address_r", "Read addresses", False),
]
selected_scopes = []
print("\nSelect scopes (y/n for each):\n")
for scope, description, default in available_scopes:
default_str = "Y/n" if default else "y/N"
response = input(f" {scope} ({description}) [{default_str}]: ").strip().lower()
if response == '' and default:
selected_scopes.append(scope)
elif response == 'y':
selected_scopes.append(scope)
print(f"\nSelected scopes: {', '.join(selected_scopes)}")
# Initialize OAuth
oauth = EtsyOAuth(
api_key=Config.ETSY_API_KEY,
redirect_uri=redirect_uri,
scopes=selected_scopes,
token_file="tokens.json"
)
# Generate URL
print("\nStep 3: Authorize with Etsy")
print("-" * 40)
auth_url = oauth.get_authorization_url()
print("\nVisit this URL in your browser:")
print(f"\n{auth_url}\n")
print("After authorizing, you'll be redirected to your callback URL.")
print("Copy the FULL URL from your browser's address bar and paste it below.")
# Get callback URL
print("\nStep 4: Enter the callback URL")
print("-" * 40)
callback_url = input("\nCallback URL: ").strip()
if not callback_url:
print("No URL provided. Authorization cancelled.")
return None
# Exchange code
print("\nStep 5: Exchanging code for tokens...")
print("-" * 40)
try:
callback_data = oauth.parse_callback_url(callback_url)
tokens = oauth.exchange_code(callback_data['code'])
print("\n✓ Authorization successful!")
print(f"\nAccess Token: {tokens['access_token'][:30]}...")
print(f"Refresh Token: {tokens['refresh_token'][:30]}...")
print(f"Expires In: {tokens['expires_in']} seconds")
print("\nTokens saved to: tokens.json")
# Show .env update instructions
print("\n" + "=" * 60)
print("Update your .env file with:")
print("=" * 60)
print(f"ETSY_ACCESS_TOKEN={tokens['access_token']}")
print(f"ETSY_REFRESH_TOKEN={tokens['refresh_token']}")
return oauth
except ValueError as e:
print(f"\n✗ Error: {e}")
return None
if __name__ == "__main__":
Config.validate()
interactive_authorize()
# etsy_client/secure_storage.py
"""Secure token storage options."""
import json
import os
from pathlib import Path
from typing import Optional, Dict
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64
class SecureTokenStorage:
"""
Encrypt tokens at rest using a password-derived key.
Usage:
storage = SecureTokenStorage("tokens.encrypted", password="your_password")
storage.save(tokens)
tokens = storage.load()
"""
def __init__(self, file_path: str, password: str):
"""
Initialize secure storage.
Args:
file_path: Path to store encrypted tokens
password: Password for encryption
"""
self.file_path = Path(file_path)
self._fernet = self._create_fernet(password)
def _create_fernet(self, password: str) -> Fernet:
"""Create Fernet cipher from password."""
# Use a fixed salt (in production, store salt separately)
salt = b'etsy_api_token_salt_v1'
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
return Fernet(key)
def save(self, tokens: Dict) -> None:
"""Encrypt and save tokens."""
data = json.dumps(tokens).encode()
encrypted = self._fernet.encrypt(data)
self.file_path.parent.mkdir(parents=True, exist_ok=True)
self.file_path.write_bytes(encrypted)
def load(self) -> Optional[Dict]:
"""Load and decrypt tokens."""
if not self.file_path.exists():
return None
encrypted = self.file_path.read_bytes()
data = self._fernet.decrypt(encrypted)
return json.loads(data.decode())
def delete(self) -> None:
"""Securely delete token file."""
if self.file_path.exists():
# Overwrite with random data before deletion
size = self.file_path.stat().st_size
self.file_path.write_bytes(os.urandom(size))
self.file_path.unlink()
def validate_token_response(tokens: Dict) -> bool:
"""
Validate that a token response contains required fields.
Args:
tokens: Token response from Etsy
Returns:
True if valid, raises ValueError otherwise
"""
required_fields = ['access_token', 'refresh_token', 'expires_in']
for field in required_fields:
if field not in tokens:
raise ValueError(f"Missing required field: {field}")
if len(tokens['access_token']) < 20:
raise ValueError("Access token appears invalid")
if tokens['expires_in'] < 0:
raise ValueError("Invalid expiration time")
return True
| Issue | Cause | Solution |
|---|---|---|
| “Invalid API key” | Wrong API key | Verify key in developer portal |
| “Invalid redirect URI” | Mismatch with app settings | Update callback URL in app settings |
| “Invalid grant” | Expired authorization code | Codes expire quickly; authorize again |
| “Invalid scope” | Requesting unavailable scope | Check available scopes for your app |
| “CSRF state mismatch” | State parameter changed | Don’t modify the authorization URL |
# scripts/debug_auth.py
"""Debug authentication issues."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import Config
from etsy_client import EtsyClient
def debug_authentication():
"""Run diagnostic checks on authentication."""
print("Authentication Diagnostics")
print("=" * 50)
# Check API key
print("\n1. API Key Check")
print("-" * 30)
if Config.ETSY_API_KEY:
print(f" API Key: {Config.ETSY_API_KEY[:8]}... ({len(Config.ETSY_API_KEY)} chars)")
# Test with ping endpoint
client = EtsyClient(api_key=Config.ETSY_API_KEY)
if client.ping():
print(" ✓ API key is valid")
else:
print(" ✗ API key test failed")
else:
print(" ✗ No API key configured")
# Check access token
print("\n2. Access Token Check")
print("-" * 30)
if Config.ETSY_ACCESS_TOKEN:
print(f" Token: {Config.ETSY_ACCESS_TOKEN[:15]}... ({len(Config.ETSY_ACCESS_TOKEN)} chars)")
# Test authenticated endpoint
client = EtsyClient(
api_key=Config.ETSY_API_KEY,
access_token=Config.ETSY_ACCESS_TOKEN
)
try:
user = client.get_me()
print(f" ✓ Token valid for user: {user.get('user_id')}")
except Exception as e:
print(f" ✗ Token test failed: {e}")
else:
print(" ⚠ No access token configured")
# Check token file
print("\n3. Token File Check")
print("-" * 30)
token_file = Path("tokens.json")
if token_file.exists():
import json
with open(token_file) as f:
tokens = json.load(f)
print(f" File exists: {token_file}")
print(f" Has access_token: {'access_token' in tokens}")
print(f" Has refresh_token: {'refresh_token' in tokens}")
if 'obtained_at' in tokens and 'expires_in' in tokens:
import time
expires_at = tokens['obtained_at'] + tokens['expires_in']
remaining = expires_at - time.time()
if remaining > 0:
print(f" ✓ Token expires in {int(remaining/60)} minutes")
else:
print(f" ⚠ Token expired {int(-remaining/60)} minutes ago")
else:
print(" ⚠ No token file found")
if __name__ == "__main__":
debug_authentication()
With authentication implemented, you can now make authorized requests to the Etsy API. The next chapter explores working with shops and users, the foundation for building your digital products automation.
| ← Chapter 2: Setting Up Your Development Environment | Table of Contents | Chapter 4: Working with Shops and Users → |