Idempotencia y duplicados
Las redes fallan y las herramientas de automatización reintentan. Tres mecanismos mantienen tus datos limpios: el header Idempotency-Key hace que una creación reintentada devuelva el resultado original, la detección de duplicados rechaza un segundo lead con el mismo correo o teléfono, y external_id le da a cada registro tu propio id único.
La API de SpaceKru (v1.1) es una API REST/JSON disponible en todos los planes de pago de SpaceKru. Sincroniza leads en ambos sentidos (upsert por external_id), añade leads a campañas, crea y mueve deals y pide seguimientos a los Empleados de IA, gestiona productos, cotizaciones, órdenes, pagos y reservas, permite que tu plataforma converse con tu Empleado de IA de Customer Service y envía webhooks firmados para 16 eventos que dicen quién causó cada cambio. URL base: https://app.spacekru.com/api/public/v1. Autenticación: llave de API Bearer (sk_live_…). Documentación: https://www.spacekru.com/es/developers. OpenAPI 3.1: https://app.spacekru.com/api/public/v1/openapi.json.
El header Idempotency-Key
Envía Idempotency-Key: <cualquier texto único>, por ejemplo el id de la respuesta del formulario, de la orden o del pago en tu sistema. Hasta 255 caracteres.
| Operación | Idempotency-Key |
|---|---|
POST /orders, POST /quotes/{id}/convert, POST /orders/{id}/payments | Obligatoria (mueven dinero y stock). Sin ella: 400 INVALID_REQUEST con details[0].field = "Idempotency-Key". |
POST /leads, PUT /leads/external/{id}, POST /deals, /products, /quotes, /bookings, /conversations | Opcional, muy recomendada. |
| Las demás operaciones | Se ignora. PATCH se puede repetir sin riesgo. |
| Qué pasa | Resultado |
|---|---|
| Misma llave, mismo body, dentro de 24 h | Recibes otra vez la respuesta original, con el header Idempotent-Replayed: true. Nada se crea dos veces. |
| Misma llave, body distinto | 409 IDEMPOTENCY_CONFLICT. Usa una llave nueva para una solicitud distinta. |
| Misma llave mientras la primera solicitud sigue en curso | La segunda solicitud espera hasta 5 segundos a la primera y devuelve su resultado; si sigue en curso, 409 IDEMPOTENCY_CONFLICT: reintenta en un momento. |
| La primera solicitud falló (cualquier error) | No se guarda nada: corrige la causa y reintenta con la misma llave. |
| Después de 24 h | La llave se olvida y se puede usar de nuevo. |
- Las llaves se aíslan por cuenta, operación y registro: la misma llave en dos cotizaciones distintas son dos solicitudes distintas.
- El body se compara por contenido, así que el orden de los campos del JSON no importa.
- Una cotización se convierte en orden una sola vez: una segunda conversión con otra llave responde
409con la orden comoexisting_id.
Detección de duplicados
Con o sin el header, POST /leads nunca crea un segundo lead para la misma persona:
- Si algún correo o teléfono que envías coincide con un lead de tu cuenta, la API responde
409 DUPLICATE_RESOURCEcon elexisting_idde ese lead y no crea nada. - Si no envías ni correo ni teléfono, la coincidencia se busca por el perfil social (Instagram, LinkedIn).
PATCH /leads/{id}no busca duplicados por correo o teléfono: actualiza exactamente el lead que indicas.
{
"error": {
"code": "DUPLICATE_RESOURCE",
"message": "A matching record already exists.",
"existing_id": "3f2b8c1e-9a4d-4e6f-b7a8-1c2d3e4f5a6b",
"request_id": "5f2c9a1b7e3d"
}
}Tu propio id: external_id
Leads, deals, productos, cotizaciones, órdenes y reservas aceptan external_id, único por cuenta: un segundo registro con el mismo valor responde 409 DUPLICATE_RESOURCE con existing_id. Cada lista filtra por él (?external_id=…). El patrón completo está en Sincronización en ambos sentidos.
Crear o actualizar (upsert)
Con tu id: una llamada
Si tienes un id para la persona en tu sistema, PUT /leads/external/{external_id} crea, actualiza o adopta el lead en una sola llamada y te dice qué hizo en 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)Sin tu id: POST y luego PATCH
POST; si la respuesta es 409 DUPLICATE_RESOURCE, haz PATCH al 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"])