Idempotency & duplicates
Networks fail and automation tools retry. Three mechanisms keep your data clean: the Idempotency-Key header makes a retried create return the original result, duplicate detection refuses a second lead with the same email or phone, and external_id gives every record your own unique id.
The SpaceKru API (v1.1) is a REST/JSON API available on every paid SpaceKru plan. It syncs leads both ways (upsert by external_id), adds leads to campaigns, creates and moves deals and asks AI Employees to follow up, manages products, quotes, orders, payments and bookings, lets your platform chat with your Customer Service AI Employee, and sends signed webhooks for 16 events that say who caused each change. Base URL: https://app.spacekru.com/api/public/v1. Authentication: Bearer API key (sk_live_…). Documentation: https://www.spacekru.com/developers (Spanish: https://www.spacekru.com/es/developers). OpenAPI 3.1: https://app.spacekru.com/api/public/v1/openapi.json.
The Idempotency-Key header
Send Idempotency-Key: <any unique string> — for example the id of the form response, order or payment in your system. Up to 255 characters.
| Operation | Idempotency-Key |
|---|---|
POST /orders, POST /quotes/{id}/convert, POST /orders/{id}/payments | Required (they move money and stock). Without it: 400 INVALID_REQUEST with details[0].field = "Idempotency-Key". |
POST /leads, PUT /leads/external/{id}, POST /deals, /products, /quotes, /bookings, /conversations | Optional, strongly recommended. |
| Every other operation | Ignored. PATCH is naturally safe to repeat. |
| What happens | Result |
|---|---|
| Same key, same body, within 24 h | You get the original response again, with the header Idempotent-Replayed: true. Nothing is created twice. |
| Same key, different body | 409 IDEMPOTENCY_CONFLICT. Use a new key for a different request. |
| Same key while the first request is still running | The second request waits up to 5 seconds for the first one and returns its result; if it is still running, 409 IDEMPOTENCY_CONFLICT — retry shortly. |
| The first request failed (any error) | Nothing is stored: fix the cause and retry with the same key. |
| After 24 h | The key is forgotten and can be used again. |
- Keys are scoped to your account, the operation and its record: the same key on two different quotes is two different requests.
- The body is compared by content, so the order of the JSON fields does not matter.
- A quote converts into an order only once: a second conversion with another key answers
409with the order asexisting_id.
Duplicate detection
Independently of the header, POST /leads never creates a second lead for the same person:
- If any email or phone you send matches an existing lead of your account, the API answers
409 DUPLICATE_RESOURCEwith theexisting_idof that lead and creates nothing. - If you send no email and no phone, the match is made on the social profile (Instagram, LinkedIn) instead.
PATCH /leads/{id}does not check for duplicates by email or phone: it updates exactly the lead you name.
{
"error": {
"code": "DUPLICATE_RESOURCE",
"message": "A matching record already exists.",
"existing_id": "3f2b8c1e-9a4d-4e6f-b7a8-1c2d3e4f5a6b",
"request_id": "5f2c9a1b7e3d"
}
}Your own id: external_id
Leads, deals, products, quotes, orders and bookings accept external_id, unique per account: a second record with the same value answers 409 DUPLICATE_RESOURCE with existing_id. Every list filters by it (?external_id=…). Full pattern in Two-way sync.
Create or update (upsert)
With your id: one call
If you have an id for the person in your system, PUT /leads/external/{external_id} creates, updates or adopts the lead in one call and tells you which in X-SpaceKru-Upsert.
curl -X PUT "https://app.spacekru.com/api/public/v1/leads/external/crm-88412" \
-H "Authorization: Bearer $SPACEKRU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: your-unique-request-id" \
-d '{
"full_name": "Maria Lopez",
"work_phone": "+13055550199",
"job_title": "Head of Operations"
}'const res = await fetch('https://app.spacekru.com/api/public/v1/leads/external/crm-88412', {
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.SPACEKRU_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'your-unique-request-id',
},
body: JSON.stringify({
"full_name": "Maria Lopez",
"work_phone": "+13055550199",
"job_title": "Head of Operations"
}),
});
const body = await res.json();
if (!res.ok) {
// Branch on body.error.code; quote body.error.request_id to support.
throw new Error(`${body.error.code}: ${body.error.message} (request ${body.error.request_id})`);
}
console.log(body);import os
import requests
r = requests.put(
"https://app.spacekru.com/api/public/v1/leads/external/crm-88412",
headers={
"Authorization": f"Bearer {os.environ['SPACEKRU_API_KEY']}",
"Idempotency-Key": "your-unique-request-id",
},
json={
"full_name": "Maria Lopez",
"work_phone": "+13055550199",
"job_title": "Head of Operations",
},
timeout=30,
)
body = r.json()
if not r.ok:
# Branch on body["error"]["code"]; quote body["error"]["request_id"] to support.
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} (request {body['error']['request_id']})")
print(body)Without your id: POST, then PATCH
POST; if the answer is 409 DUPLICATE_RESOURCE, PATCH the existing_id.
const BASE = 'https://app.spacekru.com/api/public/v1';
const headers = {
Authorization: `Bearer ${process.env.SPACEKRU_API_KEY}`,
'Content-Type': 'application/json',
};
// Creates the lead, or updates it when it already exists. Returns the lead.
async function upsertLead(lead, requestId) {
const res = await fetch(`${BASE}/leads`, {
method: 'POST',
headers: { ...headers, 'Idempotency-Key': requestId },
body: JSON.stringify(lead),
});
const body = await res.json();
if (res.ok) return body;
if (res.status === 409 && body.error.code === 'DUPLICATE_RESOURCE') {
// PATCH does not accept initial_note, and its `tags` REPLACE the current tags.
const { initial_note, ...changes } = lead;
const upd = await fetch(`${BASE}/leads/${body.error.existing_id}`, {
method: 'PATCH',
headers,
body: JSON.stringify(changes),
});
const updated = await upd.json();
if (!upd.ok) throw new Error(`${updated.error.code}: ${updated.error.message}`);
return updated;
}
throw new Error(`${body.error.code}: ${body.error.message} (request ${body.error.request_id})`);
}
const lead = await upsertLead(
{ full_name: 'Maria Lopez', work_email: 'maria.lopez@example.com', city: 'Miami' },
'crm-sync-maria-lopez-2026-10-19',
);
console.log(lead.id);import os
import requests
BASE = "https://app.spacekru.com/api/public/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SPACEKRU_API_KEY']}"}
def upsert_lead(lead, request_id):
"""Creates the lead, or updates it when it already exists. Returns the lead."""
r = requests.post(f"{BASE}/leads", headers={**HEADERS, "Idempotency-Key": request_id},
json=lead, timeout=30)
body = r.json()
if r.ok:
return body
if r.status_code == 409 and body["error"]["code"] == "DUPLICATE_RESOURCE":
# PATCH does not accept initial_note, and its "tags" REPLACE the current tags.
changes = {k: v for k, v in lead.items() if k != "initial_note"}
u = requests.patch(f"{BASE}/leads/{body['error']['existing_id']}", headers=HEADERS,
json=changes, timeout=30)
updated = u.json()
if not u.ok:
raise RuntimeError(f"{updated['error']['code']}: {updated['error']['message']}")
return updated
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} (request {body['error']['request_id']})")
lead = upsert_lead({"full_name": "Maria Lopez", "work_email": "maria.lopez@example.com", "city": "Miami"},
"crm-sync-maria-lopez-2026-10-19")
print(lead["id"])