Webhooks
Los webhooks envían eventos de SpaceKru a una URL tuya en segundos, firmados con HMAC-SHA256. Suscribe una URL a uno de 16 eventos con una solicitud a la API; SpaceKru reintenta las entregas fallidas hasta 6 veces y te dice quién causó cada cambio.
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.
Suscribe una URL
Una suscripción = un evento + una URL. Suscribe la misma URL varias veces para recibir varios eventos. Necesitas una llave de Lectura y escritura.
curl -X POST "https://app.spacekru.com/api/public/v1/subscriptions" \
-H "Authorization: Bearer $SPACEKRU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "lead.created",
"target_url": "https://hooks.zapier.com/hooks/catch/1234567/abcdefg/"
}'const res = await fetch('https://app.spacekru.com/api/public/v1/subscriptions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPACEKRU_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"event_type": "lead.created",
"target_url": "https://hooks.zapier.com/hooks/catch/1234567/abcdefg/"
}),
});
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.post(
"https://app.spacekru.com/api/public/v1/subscriptions",
headers={
"Authorization": f"Bearer {os.environ['SPACEKRU_API_KEY']}",
},
json={
"event_type": "lead.created",
"target_url": "https://hooks.zapier.com/hooks/catch/1234567/abcdefg/",
},
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){
"object": "subscription",
"id": "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
"event_type": "lead.created",
"target_url": "https://hooks.zapier.com/hooks/catch/1234567/abcdefg/",
"is_active": true,
"failure_count": 0,
"disabled_reason": null,
"last_success_at": null,
"created_at": "2026-10-19T14:40:00.000Z",
"exclude_own_changes": false,
"secret": "whsec_EXAMPLE0nlyN0tARealSecretDoNotUseIt00000"
}Omite tus propios cambios
Envía "exclude_own_changes": true y los cambios hechos con la MISMA llave que crea la suscripción no se le entregan. Úsalo cuando tu plataforma escribe por la API y escucha los mismos eventos, para que nada vuelva como eco (Sincronización en ambos sentidos).
curl -X POST "https://app.spacekru.com/api/public/v1/subscriptions" \
-H "Authorization: Bearer $SPACEKRU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "lead.updated",
"target_url": "https://hooks.zapier.com/hooks/catch/1234567/abcdefg/",
"exclude_own_changes": true
}'const res = await fetch('https://app.spacekru.com/api/public/v1/subscriptions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPACEKRU_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"event_type": "lead.updated",
"target_url": "https://hooks.zapier.com/hooks/catch/1234567/abcdefg/",
"exclude_own_changes": true
}),
});
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.post(
"https://app.spacekru.com/api/public/v1/subscriptions",
headers={
"Authorization": f"Bearer {os.environ['SPACEKRU_API_KEY']}",
},
json={
"event_type": "lead.updated",
"target_url": "https://hooks.zapier.com/hooks/catch/1234567/abcdefg/",
"exclude_own_changes": True,
},
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)Reglas de target_url
- Solo
https://, en el puerto por defecto (443). Sin usuario ni contraseña dentro de la URL. - Un host público: se rechazan
localhost,*.local,*.internaly las IPs privadas o reservadas, al suscribirte y otra vez antes de cada entrega. - Las redirecciones (
3xx) nunca se siguen: cuentan como entrega fallida. Usa la URL final. - Tu endpoint debe responder en menos de 8 segundos.
- Como máximo 50 suscripciones activas por cuenta.
Solo eventos nuevos
Suscribirte no envía eventos pasados: solo se entregan los cambios posteriores a la suscripción. Para cargar el histórico, lista el recurso una vez (Paginación y sincronización).
Lista y borra suscripciones
GET /subscriptions lista todas las suscripciones de tu cuenta (activas y desactivadas, de cualquier llave). También puedes verlas y quitarlas en Integraciones → API y Webhooks. Los webhooks solo se crean por la API.
curl "https://app.spacekru.com/api/public/v1/subscriptions" \
-H "Authorization: Bearer $SPACEKRU_API_KEY"const res = await fetch('https://app.spacekru.com/api/public/v1/subscriptions', {
headers: {
Authorization: `Bearer ${process.env.SPACEKRU_API_KEY}`,
},
});
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.get(
"https://app.spacekru.com/api/public/v1/subscriptions",
headers={
"Authorization": f"Bearer {os.environ['SPACEKRU_API_KEY']}",
},
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)curl -X DELETE "https://app.spacekru.com/api/public/v1/subscriptions/0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d" \
-H "Authorization: Bearer $SPACEKRU_API_KEY"const res = await fetch('https://app.spacekru.com/api/public/v1/subscriptions/0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.SPACEKRU_API_KEY}`,
},
});
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.delete(
"https://app.spacekru.com/api/public/v1/subscriptions/0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
headers={
"Authorization": f"Bearer {os.environ['SPACEKRU_API_KEY']}",
},
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)Borrar es permanente, y se cancelan los reintentos pendientes de esa suscripción.
Catálogo de eventos
| Evento | Se dispara cuando | changes |
|---|---|---|
| `lead.created` | Se crea un lead desde cualquier origen: la app, una importación CSV, un formulario, el Lead Finder, un mensaje entrante o la API. | {} (siempre vacío). |
| `lead.updated` | Cambia uno de estos campos de un lead: type, full_name, business_name, job_title, work_email, personal_email, work_phone, personal_phone, company_name, status, tags, city, country, instagram_profile, linkedin_profile_url, website, external_id. Los cambios en cualquier otro campo (por ejemplo, contadores internos de seguimiento) nunca lo disparan. | { "campo": [antes, después] } por cada campo que cambió. Los valores son los guardados tal cual (por ejemplo tags como se almacena), y solo aparecen esos 17 campos. |
| `deal.created` | Se crea un deal: en la app, por un Empleado de IA, desde una cotización o por la API. | {} (siempre vacío). |
| `deal.stage_changed` | Cambia la etapa del pipeline de un deal, o su estado (open, won, lost). Úsalo para "deal ganado" y "deal perdido". | stage, stage_id y/o status, cada uno como [antes, después]. |
| `deal.updated` | Cambia uno de estos campos guardados de un deal SIN cambio de etapa ni de estado: deal_title, deal_value, agent_id, close_date, lost_reason, next_step_action, next_step_date, external_id. (deal_title y deal_value son title y value en el objeto Deal.) | { "campo": [antes, después] } con los nombres de campo guardados de la lista anterior. |
| `quote.created` | Se crea una cotización. | {} (siempre vacío). |
| `quote.status_changed` | Cambia el estado de una cotización (por ejemplo de sent a accepted). | status como [antes, después]. |
| `order.created` | Se crea una orden. | {} (siempre vacío). |
| `order.status_changed` | Cambia el estado de una orden, o su estado de pago (por ejemplo a paid). | status y/o payment_status, cada uno como [antes, después]. |
| `campaign_lead.replied` | Un lead de una de tus campañas responde (su estado en la campaña pasa a replied). | status como [antes, "replied"]. |
| `campaign_lead.form_submitted` | Un lead de una de tus campañas envía el formulario de la campaña. | {} (siempre vacío). data.form_submitted_at trae la hora. |
| `product.created` | Se crea un producto: en la app, por importación CSV, por un Empleado de IA o por la API. | {} (siempre vacío). |
| `product.updated` | Cambia uno de estos campos de un producto: name, sku, reference_code, category, description, brand, price, sale_price, currency, stock_quantity, track_stock, status, images, url, min_order_quantity, external_id. Los cambios de variantes no lo disparan. | { "campo": [antes, después] } por cada campo que cambió. |
| `booking.created` | Se crea una reserva: en la app, por un Empleado de IA o por la API. | {} (siempre vacío). |
| `booking.updated` | Cambia uno de estos campos de una reserva: resource_id, lead_id, start_time, end_time, status, party_size, notes, meeting_url, external_id. | { "campo": [antes, después] } por cada campo que cambió. |
| `conversation.message_created` | El Empleado de IA, o una persona que responde desde el Inbox, escribe en una conversación abierta por la API. | {} (siempre vacío). |
Los registros internos que SpaceKru guarda para su propio equipo nunca producen eventos.
Contenido y headers
Cada entrega es un POST con un body JSON:
{
"id": "e4d3c2b1-a098-4765-8432-10fedcba9876",
"event": "lead.updated",
"occurred_at": "2026-10-19T14:32:11.000Z",
"organization_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"changes": {
"status": [
"new",
"contacted"
],
"job_title": [
"Operations Manager",
"Head of Operations"
]
},
"triggered_by": {
"type": "app",
"api_key_id": null
},
"data": {
"object": "lead",
"id": "3f2b8c1e-9a4d-4e6f-b7a8-1c2d3e4f5a6b",
"type": "person",
"full_name": "Maria Lopez",
"business_name": null,
"job_title": "Head of Operations",
"work_email": "maria.lopez@example.com",
"personal_email": null,
"work_phone": "+13055550142",
"personal_phone": null,
"company_name": "Acme Corp",
"city": "Miami",
"country": "United States",
"status": "contacted",
"source": "api",
"tags": [
"typeform",
"spring-promo"
],
"instagram_profile": null,
"linkedin_profile_url": null,
"website": null,
"external_id": "crm-88412",
"created_at": "2026-10-19T14:32:10.000Z",
"updated_at": "2026-10-19T14:32:10.000Z"
}
}id: el id del evento. El mismo para cada suscripción y cada reintento de este evento.event: el tipo de evento.occurred_at: cuándo ocurrió el cambio (UTC).changes:{}en*.created,campaign_lead.form_submittedyconversation.message_created; si no,{ "campo": [antes, después] }.triggered_by:{ "type": "api", "api_key_id": "…" }cuando el cambio lo causó una solicitud con una llave de API de tu cuenta;{ "type": "app", "api_key_id": null }en cualquier otro caso (tu equipo, un Empleado de IA, un formulario, una importación).data: el registro tal como está cuando se entrega el evento, con la misma forma que devuelve la API. Si el registro se borró antes de la entrega, el evento se descarta.
| Header | Valor |
|---|---|
Content-Type | application/json |
User-Agent | SpaceKru-Webhooks/1.0 |
X-SpaceKru-Event | El tipo de evento, por ejemplo lead.created. |
X-SpaceKru-Delivery | Id de la entrega: el mismo en cada reintento. Deduplica con él. |
X-SpaceKru-Timestamp | Segundos Unix en que se envió este intento: nuevo en cada intento. |
X-SpaceKru-Signature | sha256= + HMAC-SHA256 en hexadecimal (ver abajo). |
X-Request-Id | Id de este intento HTTP. |
Verifica las firmas
Verifica cada entrega antes de confiar en ella:
- Toma los bytes crudos del body, exactamente como llegaron (nunca un JSON re-serializado).
- Calcula
HMAC-SHA256(secret, X-SpaceKru-Timestamp + "." + raw_body)en hexadecimal. La clave es el secreto completo, incluido el prefijo `whsec_`. - Compáralo con la parte después de
sha256=enX-SpaceKru-Signature, con una comparación de tiempo constante. - Rechaza marcas de tiempo de más de unos minutos (5 es una buena tolerancia). Cada reintento trae una marca nueva, así que esto nunca rechaza un reintento legítimo.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.SPACEKRU_WEBHOOK_SECRET; // the FULL value, including "whsec_"
const TOLERANCE_SECONDS = 300;
const seen = new Set(); // use your database or Redis in production
// Use the RAW body: re-serialized JSON will not match the signature.
app.post('/webhooks/spacekru', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-SpaceKru-Timestamp') ?? '';
const header = req.get('X-SpaceKru-Signature') ?? '';
const deliveryId = req.get('X-SpaceKru-Delivery') ?? '';
if (!/^\d+$/.test(timestamp) || !header.startsWith('sha256=')) {
return res.status(400).send('Missing signature');
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return res.status(400).send('Stale timestamp');
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(req.body) // Buffer with the raw bytes
.digest();
const received = Buffer.from(header.slice('sha256='.length), 'hex');
if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
return res.status(400).send('Invalid signature');
}
// Answer within 8 seconds; do the work afterwards.
res.sendStatus(200);
if (seen.has(deliveryId)) return; // retries reuse the same delivery id
seen.add(deliveryId);
const event = JSON.parse(req.body.toString('utf8'));
console.log(event.event, event.data.id, event.changes);
});
app.listen(3000);import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["SPACEKRU_WEBHOOK_SECRET"].encode() # the FULL value, including "whsec_"
TOLERANCE_SECONDS = 300
seen = set() # use your database or Redis in production
@app.post("/webhooks/spacekru")
def spacekru_webhook():
timestamp = request.headers.get("X-SpaceKru-Timestamp", "")
header = request.headers.get("X-SpaceKru-Signature", "")
delivery_id = request.headers.get("X-SpaceKru-Delivery", "")
raw = request.get_data() # raw bytes, read BEFORE parsing JSON
if not timestamp.isdigit() or not header.startswith("sha256="):
abort(400, "Missing signature")
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
abort(400, "Stale timestamp")
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, header[len("sha256="):]):
abort(400, "Invalid signature")
if delivery_id not in seen: # retries reuse the same delivery id
seen.add(delivery_id)
event = request.get_json()
print(event["event"], event["data"]["id"], event["changes"]) # queue it for processing
return "", 200 # answer within 8 secondsResponde rápido
Responde con cualquier 2xx en menos de 8 segundos y haz el trabajo en segundo plano (una cola, una tarea). Procesar lento dentro de la solicitud termina en timeouts, reintentos y, al final, una suscripción desactivada.
Reintentos
Una entrega falla cuando tu endpoint responde algo distinto de 2xx (incluido 3xx) o no responde en 8 segundos. Las entregas fallidas se reintentan después de 1 min, 5 min, 30 min, 2 h y 6 h: hasta 6 intentos en total. Cada reintento tiene el mismo X-SpaceKru-Delivery y body, y una marca de tiempo y firma nuevas.
Desuscríbete con 410
Responde 410 Gone y SpaceKru deja de enviar a esa suscripción: queda desactivada (is_active: false, disabled_reason: "target_gone_410"), no borrada; sigue visible en GET /subscriptions y en la app hasta que la borres. Así es como Zapier y herramientas similares apagan un hook.
Desactivación automática
- Después de 20 intentos de entrega fallidos seguidos, la suscripción se desactiva (
disabled_reason: "disabled_after_20_consecutive_failures"). Cada reintento cuenta como intento. Cualquier entrega exitosa reinicia el conteo a 0 (failure_count). - Revocar la llave de API que creó una suscripción también la desactiva (
disabled_reason: "key_revoked"). - Una suscripción desactivada nunca se reactiva. Arregla el endpoint, bórrala y crea una nueva (recibes un secreto nuevo).
Garantías de entrega
- Al menos una vez. Una falla de red puede hacer que la misma entrega llegue dos veces: deduplica por
X-SpaceKru-Delivery(o por eliddel evento en cada suscripción). - Sin garantía de orden. Los eventos se entregan en paralelo (hasta 5 a la vez) y los reintentos llegan después. Usa
occurred_atydata.updated_atpara ordenarlos, y tratadatacomo el estado más reciente. - En segundos. Los eventos nuevos se recogen aproximadamente cada 15 segundos.
- Solo en planes de pago. Los eventos que ocurren mientras la cuenta no está en un plan de pago no se entregan, ni en ese momento ni después.
Prueba tu endpoint
- Apunta una suscripción a un Catch Hook de Zapier o a un custom webhook de Make para ver contenidos reales sin programar.
- Para tu propio endpoint en desarrollo, usa un túnel que te dé una URL pública
https://. Se rechazanlocalhosty las direcciones privadas. - Dispara eventos desde el propio SpaceKru: crea un lead, mueve un deal, cambia el estado de una cotización.
- Mira el estado de la suscripción en Integraciones → API y Webhooks o con
GET /subscriptions:failure_countylast_success_atte dicen si las entregas llegan.
El esquema y un ejemplo de cada evento están en la referencia de la API.