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.

Leer en español

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:

  1. Do not receive your own changes: subscribe with exclude_own_changes: true.
  2. 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 (null clears it). A value already used by another record answers 409 with existing_id.

2. Write with the upsert

PUT /leads/external/{external_id} is the one call your platform needs for leads. It decides by itself:

SituationResultX-SpaceKru-Upsert
A lead with that external_id existsIt is updated (200)updated
A lead with the same email or phone exists and has no external_idIt is updated and linked to your id (200) — the first sync of a base that exists on both sidesadopted
Nothing matchesIt is created (201); it needs a contact channelcreated
Same email or phone, but ANOTHER external_id409 with existing_id: two of your records point to one person — fix it on your side—
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)
Response 200
{
  "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
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
}'
Node.js
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);
Python
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

  1. For each customer of your platform, call PUT /leads/external/{your id}: existing SpaceKru leads are adopted, the rest are created.
  2. Then list every SpaceKru lead (pagination); the ones with external_id: null do not exist in your platform yet: create them there and write your new id back with PATCH /leads/{id}.

From then on

  1. Your platform → SpaceKru: on every change, PUT /leads/external/{your id} with the changed fields.
  2. SpaceKru → your platform: subscribe lead.created and lead.updated with exclude_own_changes: true and apply them:
Webhook handler
// 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

ResourceWriteListen
DealsPOST /deals, PATCH /deals/{id}deal.created, deal.stage_changed, deal.updated
ProductsPOST /products, PATCH /products/{id}product.created, product.updated
QuotesPOST /quotes, PATCH /quotes/{id}quote.created, quote.status_changed
OrdersPOST /orders, PATCH /orders/{id}, POST /orders/{id}/paymentsorder.created, order.status_changed
BookingsPOST /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.