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 ID

Strategy 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_id support

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_id not 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}/products

Idempotency: 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}/orders

Idempotency: 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}/customers

Idempotency: 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_fulfillments

Idempotency: 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:
        continue

3. 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-12345

4. 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
            raise

5. 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:

  1. This is expected behavior with external_id
  2. Fetch the existing resource instead of creating again
  3. If you need to update it, use PUT or PATCH

Created duplicates accidentally

Problem: Found duplicate products/orders in Yotpo.

Prevention:

  1. Always use external_id for POST requests
  2. Use the same external_id from your system (SKU, order ID)
  3. 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_id to prevent duplicates
  • Always include external_id with POST requests
  • Use the same external_id on retries (don't generate new ones)
  • Check for existing resources after network timeouts before retrying

Related


Did this page help you?