What is Idempotency?
Idempotency means you can safely retry the same request multiple times without creating duplicates. This is critical for:
Network failures and timeouts
Uncertain transaction states
Retry logic in distributed systems
Preventing accidental duplicates
Two Idempotency Mechanisms
1. External ID Pattern
Use external_id field to ensure resources are created only once:
{
"external_id" : "your-system-id-123" ,
"legal_name" : "Example Ltd" ,
...
}
Behavior :
First request : Creates resource, returns 201 Created
Retry with same external_id : Returns existing resource, returns 200 OK
The external_id should be unique within your system and stable (don’t change it).
For operations that don’t support external_id, use the Idempotency-Key header:
POST /v1/platform/businesses/{id}/invoices/payments/
Idempotency-Key: payment-20240115-001
Behavior :
First request : Processes payment, returns 201 Created
Retry with same key : Returns cached response, returns 200 OK
Resources Supporting external_id
Businesses
business_data = {
"external_id" : f "customer- { customer.id } " , # Your customer ID
"legal_name" : "Example Ltd" ,
"entity_type" : "limited" ,
"email" : "[email protected] " ,
"country" : "GB" ,
"currency" : "GBP"
}
# Safe to retry - won't create duplicates
response = requests.post(
"https://dev-backend.thredfi.com/v1/platform/businesses/" ,
headers = { "Authorization" : f "Bearer { unscoped_token } " },
json = business_data
)
if response.status_code == 200 :
print ( "Business already exists (idempotent)" )
elif response.status_code == 201 :
print ( "Business created" )
Invoices
invoice_data = {
"external_id" : f "invoice- { your_invoice.id } " ,
"customer_id" : customer_id,
"invoice_number" : "INV-001" ,
"sent_at" : "2024-01-15" ,
"due_at" : "2024-02-15" ,
"currency" : "GBP" ,
"line_items" : [ ... ]
}
# Safe to retry
response = requests.post(
f " { base_url } /v1/platform/businesses/ { business_id } /invoices/" ,
headers = { "Authorization" : f "Bearer { business_token } " },
json = invoice_data
)
Bills
bill_data = {
"external_id" : f "bill- { your_bill.id } " ,
"vendor_id" : vendor_id,
"bill_number" : "BILL-001" ,
"received_at" : "2024-01-15" ,
"due_at" : "2024-02-15" ,
"currency" : "GBP" ,
"line_items" : [ ... ]
}
# Idempotent
response = requests.post(
f " { base_url } /v1/platform/businesses/ { business_id } /bills/" ,
headers = { "Authorization" : f "Bearer { business_token } " },
json = bill_data
)
Customers & Vendors
customer_data = {
"external_id" : f "customer- { crm_customer.id } " ,
"company_name" : "Example Ltd" ,
"email" : "[email protected] "
}
# Idempotent
response = requests.post(
f " { base_url } /v1/platform/businesses/ { business_id } /customers/" ,
headers = { "Authorization" : f "Bearer { business_token } " },
json = customer_data
)
Invoice Payments
payment_data = {
"amount_cents" : 100000 ,
"payment_date" : "2024-01-20" ,
"payment_method" : "bank_transfer" ,
"allocations" : [
{
"invoice_id" : invoice_id,
"amount_cents" : 100000
}
]
}
# Use Idempotency-Key header
response = requests.post(
f " { base_url } /v1/platform/businesses/ { business_id } /invoices/payments/" ,
headers = {
"Authorization" : f "Bearer { business_token } " ,
"Content-Type" : "application/json" ,
"Idempotency-Key" : f "inv-payment- { invoice.id } - { datetime.now().date() } "
},
json = payment_data
)
Bill Payments
response = requests.post(
f " { base_url } /v1/platform/businesses/ { business_id } /bills/payments/" ,
headers = {
"Idempotency-Key" : f "bill-payment- { bill.id } - { payment.id } "
},
json = payment_data
)
Use a format that combines relevant identifiers:
Entity type + ID: invoice-123
Entity + date: payment-20240115-001
UUID: 550e8400-e29b-41d4-a716-446655440000
Good Examples :
invoice-payment-inv123-20240115
customer-onboard-cust456
bill-payment-bill789-pay001
Bad Examples :
random-string # Not meaningful
timestamp-only # Can collide across resources
Idempotency Window
Idempotency keys are stored for 24 hours . After that, the same key can be reused for a new transaction.
Response Status Codes
Status Meaning Description 201 Created Resource created successfully (first request) 200 OK Resource already exists (idempotent retry)
The response body is identical in both cases.
Best Practices
Generate Stable IDs
# Good - stable, reproducible
external_id = f " { entity_type } - { your_db_id } "
# Bad - changes on retry
external_id = f " { entity_type } - { random.uuid4() } "
Check Response Status
response = create_business(business_data)
if response.status_code == 201 :
print ( "Business created" )
send_welcome_email()
elif response.status_code == 200 :
print ( "Business already exists (idempotent)" )
# Don't send duplicate emails
else :
handle_error(response)
Use for All Creates
Always provide external_id or Idempotency-Key when creating resources:
Recommended
Not Recommended
# Always include external_id
invoice_data = {
"external_id" : f "invoice- { id } " , # ← Always include
"invoice_number" : "INV-001" ,
...
}
Handling Conflicts
If you retry with different data but same external_id:
# First request
{
"external_id" : "customer-123" ,
"company_name" : "Example Ltd"
}
# → 201 Created
# Retry with different data
{
"external_id" : "customer-123" ,
"company_name" : "Different Company" # Changed!
}
# → 200 OK (returns ORIGINAL customer, ignores new data)
Idempotent retries with the same external_id always return the original resource , even if request data differs. To update a resource, use the update endpoint instead.
Example: Safe Invoice Creation
def create_invoice_safe ( invoice_data , retry_count = 3 ):
"""Create invoice with retry safety via external_id."""
for attempt in range (retry_count):
try :
response = requests.post(
f " { base_url } /v1/platform/businesses/ { business_id } /invoices/" ,
headers = {
"Authorization" : f "Bearer { business_token } " ,
"Content-Type" : "application/json"
},
json = {
"external_id" : f "invoice- { invoice_data[ 'id' ] } " , # Idempotent
** invoice_data
},
timeout = 10
)
if response.status_code in [ 200 , 201 ]:
return response.json()
elif response.status_code >= 500 :
# Server error - retry
if attempt < retry_count - 1 :
time.sleep( 2 ** attempt)
continue
else :
# Client error - don't retry
raise APIError(response.json())
except requests.exceptions.Timeout:
# Timeout - safe to retry because of external_id
if attempt < retry_count - 1 :
time.sleep( 2 ** attempt)
continue
raise
raise MaxRetriesExceeded()
Next Steps