Webhooks
Webhooks push SpaceKru events to a URL of yours within seconds, signed with HMAC-SHA256. Subscribe a URL to one of 16 events with one API request; SpaceKru retries failed deliveries up to 6 times, and tells you who caused every change.
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.
Subscribe a URL
One subscription = one event + one URL. Subscribe the same URL several times to receive several events. You need a key with Read & write access.
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"
}Skip your own changes
Send "exclude_own_changes": true and changes made with the SAME key that creates the subscription are not delivered to it. Use it when your platform both writes through the API and listens to the same events, so nothing comes back as an echo (Two-way sync).
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)Rules for target_url
https://only, on the default port (443). No user or password inside the URL.- A public host:
localhost,*.local,*.internaland private or reserved IP addresses are rejected — when you subscribe and again before every delivery. - Redirects (
3xx) are never followed: they count as a failed delivery. Use the final URL. - Your endpoint must answer within 8 seconds.
- At most 50 active subscriptions per account.
Only new events
Subscribing does not send past events: only changes that happen after the subscription exists are delivered. To load history, list the resource once (Pagination & sync).
List and delete subscriptions
GET /subscriptions lists every subscription of your account (active and deactivated, from any key). You can also see and remove them in Integrations → API & Webhooks. Webhooks are created only through the 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)Deleting is permanent, and retries still pending for that subscription are cancelled.
Event catalog
| Event | Fires when | changes |
|---|---|---|
| `lead.created` | A lead is created from any source: the app, a CSV import, a form, the Lead Finder, an inbound message or the API. | {} (always empty). |
| `lead.updated` | One of these fields of a lead changes: 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. Changes to any other field (for example internal follow-up counters) never fire it. | { "field": [old, new] } for each field that changed. Values are the raw stored values (for example tags exactly as stored), and only those 17 fields appear. |
| `deal.created` | A deal is created: in the app, by an AI Employee, from a quote or through the API. | {} (always empty). |
| `deal.stage_changed` | A deal's pipeline stage changes, or its status changes (open, won, lost). Use it for "deal won" and "deal lost". | stage, stage_id and/or status, each as [old, new]. |
| `deal.updated` | One of these stored fields of a deal changes WITHOUT a stage or status change: deal_title, deal_value, agent_id, close_date, lost_reason, next_step_action, next_step_date, external_id. (deal_title and deal_value are title and value in the Deal object.) | { "field": [old, new] } with the stored field names listed above. |
| `quote.created` | A quote is created. | {} (always empty). |
| `quote.status_changed` | A quote's status changes (for example from sent to accepted). | status as [old, new]. |
| `order.created` | An order is created. | {} (always empty). |
| `order.status_changed` | An order's status changes, or its payment status changes (for example to paid). | status and/or payment_status, each as [old, new]. |
| `campaign_lead.replied` | A lead in one of your campaigns replies (its campaign status becomes replied). | status as [old, "replied"]. |
| `campaign_lead.form_submitted` | A lead in one of your campaigns submits the campaign's form. | {} (always empty). data.form_submitted_at has the time. |
| `product.created` | A product is created: in the app, by a CSV import, by an AI Employee or through the API. | {} (always empty). |
| `product.updated` | One of these fields of a product changes: name, sku, reference_code, category, description, brand, price, sale_price, currency, stock_quantity, track_stock, status, images, url, min_order_quantity, external_id. Variant changes do not fire it. | { "field": [old, new] } for each field that changed. |
| `booking.created` | A booking is created: in the app, by an AI Employee or through the API. | {} (always empty). |
| `booking.updated` | One of these fields of a booking changes: resource_id, lead_id, start_time, end_time, status, party_size, notes, meeting_url, external_id. | { "field": [old, new] } for each field that changed. |
| `conversation.message_created` | The AI Employee, or a person replying from the Inbox, writes in a conversation that was opened through the API. | {} (always empty). |
Internal records SpaceKru keeps for its own team never produce events.
Payload and headers
Each delivery is a POST with a JSON body:
{
"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— the event id. The same for every subscription and every retry of this event.event— the event type.occurred_at— when the change happened (UTC).changes—{}on*.created,campaign_lead.form_submittedandconversation.message_created; otherwise{ "field": [old, new] }.triggered_by—{ "type": "api", "api_key_id": "…" }when a request with an API key of your account caused the change;{ "type": "app", "api_key_id": null }for anything else (your team, an AI Employee, a form, an import).data— the record as it is when the event is delivered, in the same shape as the API returns it. If the record was deleted before delivery, the event is dropped.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SpaceKru-Webhooks/1.0 |
X-SpaceKru-Event | The event type, e.g. lead.created. |
X-SpaceKru-Delivery | Delivery id — the same on every retry. Deduplicate on it. |
X-SpaceKru-Timestamp | Unix seconds when this attempt was sent — new on every attempt. |
X-SpaceKru-Signature | sha256= + hex HMAC-SHA256 (see below). |
X-Request-Id | Id of this HTTP attempt. |
Verify signatures
Verify every delivery before trusting it:
- Take the raw request body bytes, exactly as received (never re-serialized JSON).
- Compute
HMAC-SHA256(secret, X-SpaceKru-Timestamp + "." + raw_body)as hex. The key is the full secret, including the `whsec_` prefix. - Compare it to the part after
sha256=inX-SpaceKru-Signature, with a constant-time comparison. - Reject timestamps older than a few minutes (5 is a good tolerance). Each retry carries a fresh timestamp, so this never rejects a legitimate retry.
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 secondsRespond fast
Answer with any 2xx within 8 seconds, then do the work in the background (a queue, a job). Slow processing inside the request turns into timeouts, retries and, eventually, a deactivated subscription.
Retries
A delivery fails when your endpoint answers anything other than 2xx (including 3xx), or doesn't answer within 8 seconds. Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h and 6 h: up to 6 attempts in total. Every retry has the same X-SpaceKru-Delivery and body, and a new timestamp and signature.
Unsubscribe with 410
Answer 410 Gone and SpaceKru stops sending to that subscription: it is deactivated (is_active: false, disabled_reason: "target_gone_410"), not deleted — it stays visible in GET /subscriptions and in the app until you delete it. This is how Zapier and similar tools turn off a hook.
Automatic deactivation
- After 20 consecutive failed delivery attempts, the subscription is deactivated (
disabled_reason: "disabled_after_20_consecutive_failures"). Every retry counts as an attempt. Any successful delivery resets the count to 0 (failure_count). - Revoking the API key that created a subscription deactivates it too (
disabled_reason: "key_revoked"). - A deactivated subscription is never reactivated. Fix the endpoint, delete the subscription and create a new one (you get a new secret).
Delivery guarantees
- At least once. Network failures can cause the same delivery to arrive twice: deduplicate on
X-SpaceKru-Delivery(or on the eventidper subscription). - No ordering guarantee. Events are delivered in parallel (up to 5 at a time) and retries arrive later. Use
occurred_atanddata.updated_atto order them, and treatdataas the latest state. - Within seconds. New events are picked up about every 15 seconds.
- Only on paid plans. Events that happen while the account is not on a paid plan are not delivered, now or later.
Testing your endpoint
- Point a subscription to a Zapier Catch Hook or a Make custom webhook to see real payloads without writing code.
- For your own endpoint during development, use a tunnel that gives you a public
https://URL.localhostand private addresses are rejected. - Trigger events from SpaceKru itself: create a lead, move a deal, change a quote's status.
- Watch the subscription's state in Integrations → API & Webhooks or with
GET /subscriptions:failure_countandlast_success_attell you whether deliveries are landing.
Each event's schema and example payload is in the API reference.