Two-way sync with your platform
Your platform and SpaceKru can edit the same customers, deals, products and bookings. These four pieces keep both sides identical without echo loops: your own id (external_id), an upsert, who caused each change (triggered_by) and subscriptions that skip your own changes (exclude_own_changes).
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 problem: echo loops
Your platform updates a phone in SpaceKru → SpaceKru sends lead.updated → your platform receives it and saves the "new" phone → if saving triggers another API call, it never ends. Two rules prevent it:
- Do not receive your own changes: subscribe with
exclude_own_changes: true. - Do not write back what did not change: compare before calling the API.
1. Store your id in SpaceKru: external_id
Leads, deals, products, quotes, orders and bookings have an external_id: the id of that record in YOUR system, unique per organization. With it you never need to store SpaceKru ids, and every webhook brings your id back in data.external_id.
- Send it when you create the record (
POST /leads,/deals,/products,/quotes,/orders,/bookings). - Find by it: every list accepts
?external_id=…. - Change or clear it with
PATCH(nullclears it). A value already used by another record answers409withexisting_id.
2. Write with the upsert
PUT /leads/external/{external_id} is the one call your platform needs for leads. It decides by itself:
| Situation | Result | X-SpaceKru-Upsert |
|---|---|---|
A lead with that external_id exists | It is updated (200) | updated |
A lead with the same email or phone exists and has no external_id | It is updated and linked to your id (200) — the first sync of a base that exists on both sides | adopted |
| Nothing matches | It is created (201); it needs a contact channel | created |
Same email or phone, but ANOTHER external_id | 409 with existing_id: two of your records point to one person — fix it on your side | — |
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){
"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": "+13055550199",
"personal_phone": null,
"company_name": "Acme Corp",
"city": "Miami",
"country": "United States",
"status": "new",
"source": "api",
"tags": [
"typeform",
"spring-promo",
"vip"
],
"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-19T15:02:44.000Z"
}3. Listen without echo: exclude_own_changes
Subscribe with the SAME key your platform writes with, and exclude_own_changes: true. Changes made with that key are not delivered to that subscription; changes made in the app, by AI Employees, by forms or by other keys are.
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)lead.updated only fires for these fields: 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. Internal counters never fire it.
4. Know who caused it: triggered_by
Every webhook has triggered_by: { "type": "api", "api_key_id": "…" } when a request with an API key of your organization caused the change, or { "type": "app", "api_key_id": null } for anything else (your team, an AI Employee, a form, an import). Use it for your own rules — for example, ignore changes made by other integrations.
Recipe: leads in both directions
First sync
- For each customer of your platform, call
PUT /leads/external/{your id}: existing SpaceKru leads are adopted, the rest are created. - Then list every SpaceKru lead (pagination); the ones with
external_id: nulldo not exist in your platform yet: create them there and write your new id back withPATCH /leads/{id}.
From then on
- Your platform → SpaceKru: on every change,
PUT /leads/external/{your id}with the changed fields. - SpaceKru → your platform: subscribe
lead.createdandlead.updatedwithexclude_own_changes: trueand apply them:
// Node.js (Express): one handler for lead.created and lead.updated
app.post('/hooks/spacekru', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify the signature first (see /developers/webhooks#verify-signatures).
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // answer fast, work afterwards
if (await alreadyProcessed(event.id)) return; // at-least-once delivery
if (event.triggered_by.type === 'api') return; // optional: ignore API writes (yours or other tools')
const lead = event.data;
const local = lead.external_id
? await db.customers.findById(lead.external_id)
: await db.customers.findBySpaceKruId(lead.id);
if (local) {
await db.customers.update(local.id, { phone: lead.work_phone, email: lead.work_email, spacekru_id: lead.id });
} else {
const created = await db.customers.create({ name: lead.full_name, phone: lead.work_phone, spacekru_id: lead.id });
// Link both sides: SpaceKru now knows your id.
await fetch(`${BASE}/leads/${lead.id}`, { method: 'PATCH', headers: HEADERS, body: JSON.stringify({ external_id: created.id }) });
}
});Deals, products, sales and bookings
| Resource | Write | Listen |
|---|---|---|
| Deals | POST /deals, PATCH /deals/{id} | deal.created, deal.stage_changed, deal.updated |
| Products | POST /products, PATCH /products/{id} | product.created, product.updated |
| Quotes | POST /quotes, PATCH /quotes/{id} | quote.created, quote.status_changed |
| Orders | POST /orders, PATCH /orders/{id}, POST /orders/{id}/payments | order.created, order.status_changed |
| Bookings | POST /bookings, PATCH /bookings/{id} | booking.created, booking.updated |
Same pattern: your id in external_id, the same key for writing and subscribing, and exclude_own_changes: true. These resources have no upsert: look the record up with ?external_id= and then create or update it.