Idempotency
Prevent duplicate resources and ensure safe retries when creating orders, products, and customers.
Idempotency ensures that retrying a failed request doesn't create duplicate resources. This guide explains which Core API operations are idempotent and how to handle retries safely.
What is idempotency?
An idempotent operation produces the same result no matter how many times you execute it:
- Idempotent: Updating a product's price to $29.99 — repeating this 10 times still results in a price of $29.99
- Not idempotent: Creating a new product — repeating this 10 times creates 10 duplicate products
HTTP methods and idempotency
GET — Always idempotent ✅
Reading data never changes state. Safe to retry unlimited times.
GET /core/v3/stores/{store_id}/products/{product_id}Retry behavior: Same product data every time.
PUT — Idempotent ✅
Updates replace the entire resource with your payload. Sending the same PUT twice produces the same result.
PUT /core/v3/stores/{store_id}/products/{product_id}
{
"name": "Classic T-Shirt",
"price": 29.99
}Retry behavior: Same product state after 1 retry or 100 retries.
PATCH — Generally idempotent ✅
Partial updates modify only specified fields. Same PATCH twice produces the same result.
PATCH /core/v3/stores/{store_id}/products/{product_id}
{
"price": 29.99
}Retry behavior: Price is $29.99 whether you send this once or multiple times.
Exception: PATCH operations that increment/decrement are NOT idempotent (not used in Core API V3).
POST — NOT idempotent ❌
Creates new resources. Each POST typically creates a new resource, even with identical payloads.
POST /core/v3/stores/{store_id}/products
{
"external_id": "prod_001",
"name": "Classic T-Shirt"
}Retry risk: If the first request succeeds but you don't receive the response (network timeout), retrying creates a duplicate.
How to handle: See strategies below.
DELETE — Idempotent ✅
Deleting the same resource twice is safe. The second DELETE returns 404 (not found), but the end result is the same: resource is deleted.
DELETE /core/v3/stores/{store_id}/products/{product_id}Retry behavior: First call deletes, subsequent calls get 404. Resource is deleted either way.
Strategies for safe POST retries
Since POST creates resources, retrying can cause duplicates. Use these strategies to prevent them.
Strategy 1: Use external_id as unique key (recommended)
Most Core API resources support external_id — your unique identifier for the resource. Yotpo uses this to prevent duplicates.
How it works
POST /core/v3/stores/{store_id}/products
{
"external_id": "prod_001",
"name": "Classic T-Shirt",
"price": 29.99
}First request: Product created with external_id: "prod_001"
Retry with same payload: Yotpo sees external_id: "prod_001" already exists and either:
- Returns the existing product (no duplicate created)
- Returns an error indicating the resource exists
Key point: Always include external_id and use a truly unique value from your system (product SKU, order ID, customer ID).
Best practices for external_id
def create_product(product_data):
# Use your system's unique ID
product_data["external_id"] = product_data["sku"] # Already unique
response = api_client.post("/products", data=product_data)
return response❌ Don't:
# Bad: Using non-unique or generated IDs
product_data["external_id"] = str(random.randint(1, 9999)) # Can collide
product_data["external_id"] = "product" # Not unique✅ Do:
# Good: Using your database ID or SKU
product_data["external_id"] = product.sku # Unique in your system
product_data["external_id"] = str(product.id) # Unique database IDStrategy 2: Check before creating
Query first to see if the resource exists, then create only if missing.
def create_product_safely(product_data):
external_id = product_data["external_id"]
# Check if product exists
existing = api_client.get(
f"/products?external_id={external_id}"
)
if existing["products"]:
# Product exists, return it
return existing["products"][0]
# Product doesn't exist, create it
response = api_client.post("/products", data=product_data)
return response.json()Pros:
- Guaranteed no duplicates
- Works even without
external_idsupport
Cons:
- Requires 2 API calls (check + create)
- Race condition possible (resource created between check and create)
Strategy 3: Handle creation errors gracefully
If a duplicate is created, detect it and use the existing one:
def create_or_get_product(product_data):
try:
# Try to create
response = api_client.post("/products", data=product_data)
return response.json()
except DuplicateError:
# Product exists, fetch it
external_id = product_data["external_id"]
existing = api_client.get(
f"/products?external_id={external_id}"
)
return existing["products"][0]Pros:
- Optimistic approach (assumes success)
- Handles duplicates if they occur
Cons:
- Requires error handling logic
- May still create duplicates if
external_idnot enforced
Strategy 4: Track processed requests (client-side)
Maintain local state to avoid retrying completed requests:
import hashlib
import json
processed_requests = set()
def make_idempotent_post(url, data):
# Generate unique request fingerprint
request_hash = hashlib.md5(
json.dumps(data, sort_keys=True).encode()
).hexdigest()
if request_hash in processed_requests:
# Already processed this request
return {"status": "duplicate", "message": "Already processed"}
# Make request
response = api_client.post(url, data=data)
# Mark as processed
processed_requests.add(request_hash)
return response.json()Pros:
- Prevents duplicate API calls entirely
- Works across all endpoints
Cons:
- Requires persistent storage (database, cache)
- Doesn't help if different systems retry the same request
Idempotency by resource type
Products
POST /core/v3/stores/{store_id}/productsIdempotency: Use external_id (your product SKU or ID)
Example:
{
"external_id": "SKU-12345",
"name": "Classic T-Shirt",
"price": 29.99
}If you retry with the same external_id, Yotpo recognizes the product exists.
Orders
POST /core/v3/stores/{store_id}/ordersIdempotency: Use external_id (your order ID)
Example:
{
"external_id": "ORDER-67890",
"order_date": "2024-01-15T10:00:00Z",
"currency": "USD",
"total_amount": 99.99
}Critical: Never retry order creation without external_id — this could trigger duplicate review request emails.
Customers
POST /core/v3/stores/{store_id}/customersIdempotency: Use external_id or unique email
Example:
{
"external_id": "CUST-11111",
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Smith"
}If customer with same external_id or email exists, Yotpo returns existing customer or error.
Order Fulfillments
POST /core/v3/stores/{store_id}/order_fulfillmentsIdempotency: Use external_id (your fulfillment/shipment ID)
Example:
{
"external_id": "SHIP-99999",
"status": "fulfilled",
"fulfilled_at": "2024-01-16T14:30:00Z"
}Best practices
1. Always use external_id for POST
❌ Don't:
# Missing external_id - duplicate risk!
create_product({
"name": "Classic T-Shirt",
"price": 29.99
})✅ Do:
# Include external_id from your system
create_product({
"external_id": product.sku, # Unique
"name": "Classic T-Shirt",
"price": 29.99
})2. Use the same external_id for retries
❌ Don't:
# Generating new ID on retry - creates duplicate!
for attempt in range(3):
try:
create_product({
"external_id": str(uuid.uuid4()), # New ID each time!
"name": "Classic T-Shirt"
})
break
except Exception:
continue✅ Do:
# Same external_id on retry - idempotent
external_id = str(product.id) # Generate once
for attempt in range(3):
try:
create_product({
"external_id": external_id, # Same ID
"name": "Classic T-Shirt"
})
break
except Exception:
continue3. Store external_id for future updates
After creating a resource, save both Yotpo's yotpo_id and your external_id:
response = create_product({
"external_id": "SKU-12345",
"name": "Classic T-Shirt"
})
# Store both IDs for later use
product_mapping = {
"external_id": "SKU-12345",
"yotpo_id": response["product"]["yotpo_id"]
}
save_to_database(product_mapping)Later, you can update using either ID:
# Update by yotpo_id
PUT /core/v3/stores/{store_id}/products/{yotpo_id}
# Or retrieve by external_id first
GET /core/v3/stores/{store_id}/products?external_id=SKU-123454. Implement retry logic carefully
Combine idempotency with smart retry logic:
def create_resource_with_retry(resource_type, data, max_retries=3):
# Ensure external_id is present
if "external_id" not in data:
raise ValueError("external_id is required for idempotent creation")
for attempt in range(max_retries):
try:
response = api_client.post(f"/{resource_type}", data=data)
return response.json()
except ServerError:
# Server error - safe to retry (same external_id)
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
continue
raise
except DuplicateError:
# Resource exists - fetch and return it
existing = api_client.get(
f"/{resource_type}?external_id={data['external_id']}"
)
return existing[resource_type][0]
except ValidationError:
# Client error - don't retry
raise5. Handle network timeouts
Network timeout doesn't mean the request failed — it might have succeeded:
import requests
try:
response = requests.post(
url,
json=data,
timeout=30
)
except requests.exceptions.Timeout:
# Request might have succeeded!
# Check if resource was created before retrying
external_id = data["external_id"]
existing = check_if_exists(external_id)
if existing:
return existing # Was created
else:
# Retry safely (same external_id)
return retry_request(url, data)Common scenarios
Bulk product import
When importing thousands of products, use external_id to allow safe retries:
def import_products(products):
for product in products:
# Use SKU as external_id (unique in your system)
product_data = {
"external_id": product.sku,
"name": product.name,
"price": product.price
}
try:
create_product(product_data)
except Exception as e:
logger.error(f"Failed to import {product.sku}: {e}")
# Safe to retry later with same external_id
retry_queue.add(product_data)Order processing with retries
Ensure orders are created exactly once even with retries:
def sync_order(order):
order_data = {
"external_id": str(order.id), # Your order ID
"order_date": order.created_at,
"total_amount": order.total,
"currency": order.currency
}
try:
# Create order
response = api_client.post("/orders", data=order_data)
return response
except Timeout:
# Network timeout - order might exist
# Check before retrying
existing = api_client.get(f"/orders?external_id={order.id}")
if existing["orders"]:
return existing["orders"][0]
# Order wasn't created, safe to retry
return api_client.post("/orders", data=order_data)Troubleshooting
Getting "duplicate" errors
Problem: API returns error that resource already exists.
Solution:
- This is expected behavior with
external_id - Fetch the existing resource instead of creating again
- If you need to update it, use PUT or PATCH
Created duplicates accidentally
Problem: Found duplicate products/orders in Yotpo.
Prevention:
- Always use
external_idfor POST requests - Use the same
external_idfrom your system (SKU, order ID) - Implement check-before-create for critical resources
Cleanup:
Use the API to find and delete duplicates:
# Find duplicates by external_id
products = api_client.get("/products?external_id=SKU-12345")
if len(products["products"]) > 1:
# Keep first, delete rest
for product in products["products"][1:]:
api_client.delete(f"/products/{product['yotpo_id']}")Unsure if request succeeded
Problem: Got a network timeout, don't know if resource was created.
Solution:
try:
response = create_resource(data)
except Timeout:
# Check if resource exists
existing = api_client.get(
f"/resource?external_id={data['external_id']}"
)
if existing:
# It was created
return existing
else:
# Safe to retry
return create_resource(data)Summary
Key takeaways:
- GET, PUT, PATCH, DELETE are idempotent — safe to retry
- POST is NOT idempotent — use
external_idto prevent duplicates - Always include
external_idwith POST requests - Use the same
external_idon retries (don't generate new ones) - Check for existing resources after network timeouts before retrying
Related
- Error handling — When to retry requests
- Rate limiting — Handling 429 errors
- Working with entity identifiers — More on
external_id
Updated about 3 hours ago