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.

Leer en español

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.

OperationIdempotency-Key
POST /orders, POST /quotes/{id}/convert, POST /orders/{id}/paymentsRequired (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, /conversationsOptional, strongly recommended.
Every other operationIgnored. PATCH is naturally safe to repeat.
What happensResult
Same key, same body, within 24 hYou get the original response again, with the header Idempotent-Replayed: true. Nothing is created twice.
Same key, different body409 IDEMPOTENCY_CONFLICT. Use a new key for a different request.
Same key while the first request is still runningThe 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 hThe 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 409 with the order as existing_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_RESOURCE with the existing_id of 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.
Response 409
{
  "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
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"
}'
Node.js
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);
Python
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.

Node.js
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);
Python
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"])