Lead API
Two independent directions. You can implement either one without the other, and most partners start with delivery alone.
| Direction | Who calls | Purpose |
|---|---|---|
| Outbound | Kamazeole → you | We push each new lead to your endpoint |
| Inbound | You → Kamazeole | You report status changes and notes |
Base URL for every inbound call: https://api.kamazeole.co.il
Receiving leads#
We POST to a URL you provide. The body encoding is configured per partner:
application/x-www-form-urlencoded by default,
application/json or application/xml on request.
Authentication is optional and can be HTTP Basic, a bearer token, a custom
header, or credentials placed inside the body.
Fields
These are the only fields we send. Field names are remappable per partner:
if your system expects full_name rather than name,
tell us and we map it. Empty fields are omitted rather than sent blank.
| Field | Notes |
|---|---|
ref | UUID. Your key for every inbound call. Store it. |
name | Full name, unmasked |
phone | Full number, unmasked |
email | May be absent |
company | Carrier name in Hebrew |
category | Slug: internet, cell, fiber, tv, triple, electric |
category_label | Hebrew label of the category |
status | Lead status at hand-off |
notes | Free text captured on our side, including the product the lead asked about and their answers to our qualifying questions |
landing_page | Path of the page the lead came from |
created_at | ISO 8601 with timezone |
Duplicates
Every request carries an Idempotency-Key header that is stable
for a given lead and endpoint. Retries reuse it.
Treat ref as a unique key
Upsert on ref. If you receive a ref you already
hold, update it — do not create a second record. We retry on failure, and
a retry can arrive after your server actually succeeded but its response
was lost. That is ordinary network behaviour, not a fault on either side.
How we judge success
A delivery counts as successful on any 2xx. If your API signals
failure inside a 200 body — common in Israeli lead systems —
give us the keyword that marks success. We then require both the
2xx and that keyword. Without it, we would record silent
failures as deliveries.
Retries
| Attempt | Delay after the previous failure |
|---|---|
| 1 | immediate |
| 2 | ~1 minute |
| 3 | ~5 minutes |
| 4 | ~30 minutes |
Delays carry ±20% jitter so a batch of failed deliveries does not hit your server simultaneously when it recovers. After the fourth attempt we stop and raise an internal alert.
We retry timeouts, connection errors, 408, 429 and
5xx. We do not retry any other 4xx: the request is
wrong and repeating it would not help. Respond within 5 seconds; acknowledge
first and process asynchronously.
Authentication#
Inbound calls need two credentials, issued once and shown only at creation. We store a hash of the token and the signing secret encrypted, so neither can be retrieved later — a lost credential is rotated, not recovered.
| Credential | Purpose | Format |
|---|---|---|
| API token | Identifies you | kz_live_… |
| Signing secret | Proves the request is intact | 64-character random string |
Both are per partner. There is no shared global key. Either can be rotated on its own, and during a rotation the old and new values are both accepted for 24 hours so your production never breaks mid-switch.
Your integration carries scopes. leads:status and
leads:notes are separate: a token allowed to report statuses
cannot write notes unless it also holds that scope.
Required headers
| Header | Value |
|---|---|
Authorization | Bearer kz_live_… |
Content-Type | application/json |
X-Kamazeole-Timestamp | Unix time in seconds, integer, UTC. Omitted when signing is disabled |
X-Kamazeole-Event-Id | Your unique id for this event, ≤191 chars, [A-Za-z0-9._:-]+ |
X-Kamazeole-Signature | v1= followed by the lowercase hex HMAC. Omitted when signing is disabled |
Signing requests#
Build a canonical string of six lines joined by a single LF
(\n, 0x0A). No CR, no trailing newline, no added whitespace.
v1
<HTTP METHOD, UPPERCASE>
<path, leading slash, no query string>
<timestamp, same value as the header>
<event id, same value as the header>
<sha256 of the raw request body, lowercase hex>
signature = lowercaseHex( HMAC-SHA256( signing_secret, canonical_string ) )
header = X-Kamazeole-Signature: v1=<signature>
Hash the bytes you transmit
Line six is the digest of the raw body you are about to send — never a re-serialisation of your object. Any difference in key order, spacing or Unicode escaping changes the hash. For a request without a body, use the SHA-256 of the empty string.
Line one is inside the signed string on purpose: nobody can downgrade the protocol by editing a header alone.
Test vector
Reproduce this before writing anything else. If your code produces this signature from these inputs, your implementation is correct. If it does not, the fault is here and not in your credentials.
secret = test_signing_secret_do_not_use_in_production
method = PATCH
path = /api/v1/kamazeole/leads/6f1c9a2e-0000-4000-8000-000000000000/status
timestamp = 1786230000
event_id = 550e8400-e29b-41d4-a716-446655440000
raw_body = {"status":"follow_up"}
body_sha256 = a15c0b1a4def01462bf1800e4839a45060b64277d91835ae67eaa19df9a59f6b
v1
PATCH
/api/v1/kamazeole/leads/6f1c9a2e-0000-4000-8000-000000000000/status
1786230000
550e8400-e29b-41d4-a716-446655440000
a15c0b1a4def01462bf1800e4839a45060b64277d91835ae67eaa19df9a59f6b
hmac_sha256_hex = c0f9cb4aff3b13bc0c5fd6307ab2b0e9a312c523c21e27dfcaeff84b98186bc0
header_signature = v1=c0f9cb4aff3b13bc0c5fd6307ab2b0e9a312c523c21e27dfcaeff84b98186bc0
If your signature does not match, compare the exact bytes being signed:
76310a50415443480a2f6170692f76312f6b616d617a656f6c652f6c6561647
32f36663163396132652d303030302d343030302d383030302d3030303030303
030303030302f7374617475730a313738363233303030300a3535306538343030
2d653239622d343164342d613731362d3434363635353434303030300a6131356
3306231613464656630313436326266313830306534383339613435303630623
634323737643931383335616536376561613139646639613539663662
Timestamp window
X-Kamazeole-Timestamp must fall within ±5 minutes of our
clock. Keep your server on NTP. This header dates the signature and changes
on every retry.
Never use it as a business date
It records when the request was signed, not when anything happened. The
business date is occurred_at, in the body.
Event id and idempotency
X-Kamazeole-Event-Id must be unique per event on your side. A
UUID is ideal. It is what makes retries safe.
| Situation | Result |
|---|---|
| First time we see this id | 200. Applied |
| Same id, same request | 200. The first result is replayed; nothing is applied twice |
| Same id, different request | 409. An id is bound to one payload |
| An earlier event is still processing | 409. Retry shortly |
Reuse the same event id when retrying a call that timed out. Generate a new one only for a genuinely new event.
If your system cannot sign#
Computing an HMAC requires code. Several established CRMs can post to a URL
and set an Authorization header through configuration alone,
but cannot build a canonical string and hash it. If that is your case, tell
us: signing can be switched off for your integration.
| Signature required (default) | Signature disabled | |
|---|---|---|
| Authorization | required | required |
| X-Kamazeole-Event-Id | required | still required |
| X-Kamazeole-Timestamp | required | not used |
| X-Kamazeole-Signature | required | not used |
| IP allowlist | not used | recommended |
The event id does not go away
Idempotency is not cryptography. Without an event id we cannot tell a network retry from a genuine second event, and you would create duplicates on your own retries. It stays mandatory in both modes.
Everything else is unchanged: per-partner token, separate scopes, ownership checked on every call, rate limits, TLS. What is lost is the protection against a token that leaks on its own — in a log, a screenshot, a support ticket. A signature makes a stolen token useless without the secret; a bearer alone does not.
That is why we ask for an IP allowlist in this mode. Send us
the addresses or CIDR ranges your system calls from, and requests from
anywhere else are refused with 403. If your addresses are
dynamic, say so — we will accept it, but the trade-off is yours to make
knowingly.
curl -sS -X PATCH "https://api.kamazeole.co.il/api/v1/kamazeole/leads/$REF/status" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-Kamazeole-Event-Id: $(uuidgen)" \
--data '{"status":"converted","source_version":7}'
Signing remains the default
New integrations require a signature. Disabling it is a per-partner decision, recorded on our side. If you can sign, sign — a signed request is always accepted, whichever mode your integration is in.
Reporting a status#
Requires the leads:status scope.
{
"status": "lost",
"lost_reason": "already_customer",
"occurred_at": "2026-08-09T10:15:00+03:00",
"source_version": 7
}
| Field | Required | Rules |
|---|---|---|
status | yes | One of the values below |
lost_reason | no | Only valid with status: "lost" |
occurred_at | no | ISO 8601, timezone mandatory |
source_version | no | Integer ≥ 0, monotonic per lead |
Unknown fields are rejected
Any field outside this list returns 422. We do not silently
ignore unrecognised keys: that would let you believe you had changed
something you had not.
Accepted statuses
new · contacted · follow_up · qualified · converted · lost
If your system uses different values
Some systems cannot emit these exact strings. Feedback codes are numeric, broadcast templates carry whatever labels the account already holds, and when everything is driven by configuration there is nobody on your side able to translate.
Tell us your values and we map them on our end. You keep sending
3, Closed Won or a Hebrew label; we translate on
receipt. Nothing changes in your system.
3 → converted
7 → lost
Closed Won → converted
12 → already_customer (loss reason)
14 → not_interested (loss reason)
The list stays closed on our side
Translation happens before validation, so what we store is always a canonical value. A code that is not in your mapping is refused exactly as before, with the accepted values in the message. This widens the input, not the output — which is what keeps conversion statistics meaningful.
Matching is exact first, then case-insensitive: Closed Won
matches a closed won entry. Send your list of values to your
account manager once, and it applies from then on.
Accepted loss reasons
| Value | Meaning |
|---|---|
already_customer | Already a customer of this carrier |
not_interested | Not interested |
wrong_number | Wrong number |
no_answer | Never reached |
price | Price objection |
out_of_area | Outside coverage |
duplicate | Duplicate of another lead |
other | Anything else |
lost_reason is optional — we would rather receive
“lost, no reason” than nothing. But it is a closed list: free
text is refused, because these values are what make refusal statistics
computable.
Response
{
"data": {
"result": "ok",
"ref": "6f1c9a2e-0000-4000-8000-000000000000",
"status": "lost",
"lost_reason": "already_customer",
"changed": true,
"duplicate": false,
"applied": true,
"stale": false,
"updated_at": "2026-08-09T10:15:04+03:00"
},
"meta": { "request_id": "…" }
}
| Field | Meaning |
|---|---|
result | Always "ok" on success — a stable keyword for systems that judge the body rather than the status code |
changed | false means the lead already held that status; no business effect |
duplicate | true means this event id had already been processed; nothing was re-applied |
applied | false means the event was not applied — see stale |
stale | true means we already hold a newer state. Not an error; do not retry |
We deliberately return no name, phone, email or note here: you already hold the lead’s data.
Notes#
Requires the leads:notes scope.
{
"note": "לקוח ביקש לחזור אליו אחרי 18:00",
"author": "Dana R.",
"external_note_id": "crm-note-88431",
"occurred_at": "2026-08-09T10:20:00+03:00"
}
| Field | Required | Rules |
|---|---|---|
note | yes | Non-empty text |
author | no | Who wrote it on your side |
external_note_id | no | Your note id, ≤191 chars |
occurred_at | no | ISO 8601 with timezone |
external_note_id gives you a second layer of idempotency,
independent of the event id: the same note id from the same integration is
never inserted twice, even if you regenerate the event id.
There is no PUT and no DELETE. A commercial history
note cannot be silently rewritten or erased by a partner.
Reading the shared thread
Requires the leads:notes scope, but no signature.
A read changes nothing, so replaying it has no effect, and requiring an event
id would create a registry entry per page view. The bearer token, the scope
and ownership still apply.
You receive your own notes plus the Kamazeole notes explicitly marked visible
to partners. Internal notes are never returned. Results are paginated:
?per_page= defaults to 25 and is capped at 100.
Ordering#
Three timestamps exist in this integration. Confusing them produces wrong data.
| Value | Where | Means | Use for ordering? |
|---|---|---|---|
X-Kamazeole-Timestamp | header | When the request was signed | never |
occurred_at | body | When the change happened in your system | yes |
updated_at | our database | When we recorded it | internal only |
Networks reorder. If two of your events arrive out of order, the older one must not overwrite the newer one. Send ordering metadata so we can tell the difference:
| Metadata | Strength |
|---|---|
source_version | Best. A counter you increment on every change to that lead. An integer comparison cannot be wrong. |
occurred_at | Good. Requires a sane clock; timezone mandatory. |
| neither | Fallback: last arrival wins. Acceptable only if you never send concurrent updates for the same lead. |
| Case | Result |
|---|---|
| Newer than what we hold | Applied. 200 |
| Older than what we hold | Ignored, reported as stale. Still 200 — not an error, we simply already know something more recent |
| Same ordering value, different content | 409. We cannot tell which came first; add source_version |
Important
Once you start sending source_version for a lead, keep sending
it. Falling back to occurred_at mid-stream makes the two
incomparable.
Errors#
| Code | Meaning | Retry? |
|---|---|---|
200 | Applied, replayed, or ignored as stale | no |
400 | Malformed request | no — fix it |
401 | Token missing, unknown, revoked, or signature invalid | no — fix it |
403 | Valid token, missing scope — or calling IP outside your allowlist | no — contact us |
404 | Reference unknown, or not yours | no |
409 | Event id reused with a different payload, ordering ambiguity, or concurrent write | yes, after a short delay |
422 | Validation failed | no — fix it |
429 | Rate limited | yes, with backoff |
5xx | Our fault | yes, with backoff |
Rate limits are 240 requests per minute per IP before authentication, and 600 per minute per integration after. Both sit far above any real flow.
Why 404 and not 403
A reference belonging to another partner returns 404. We do not
distinguish “does not exist” from “not yours” — that
difference would let someone enumerate our leads.
Common problems
| Symptom | Most likely cause |
|---|---|
401 on every call, token is correct | Signature mismatch. Reproduce the test vector first |
| Signature was correct, now fails intermittently | Server clock drift beyond 5 minutes. Check NTP |
401 only on requests with a body | You hashed a re-serialised object instead of the transmitted bytes |
409 on retry | You generated a new event id for the same event, with a different payload |
422, unexpected field | You sent a key outside the documented list |
404 on a reference you received | The lead was reassigned, or the reference belongs to another integration |
Response says stale | We already hold a newer state. Not an error |
| Duplicate leads on your side | You are keying on something other than ref |
Examples#
cURL
printf is used rather than echo throughout:
echo appends a newline, which would change both the body hash
and the canonical string.
SECRET="your_signing_secret"
TOKEN="kz_live_xxxxxxxx"
REF="6f1c9a2e-0000-4000-8000-000000000000"
PATH_="/api/v1/kamazeole/leads/$REF/status"
BODY='{"status":"converted","occurred_at":"2026-08-09T10:15:00+03:00","source_version":7}'
TS=$(date +%s)
EVENT_ID=$(uuidgen)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
CANONICAL=$(printf 'v1\nPATCH\n%s\n%s\n%s\n%s' "$PATH_" "$TS" "$EVENT_ID" "$BODY_HASH")
SIG=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -sS -X PATCH "https://api.kamazeole.co.il$PATH_" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-Kamazeole-Timestamp: $TS" \
-H "X-Kamazeole-Event-Id: $EVENT_ID" \
-H "X-Kamazeole-Signature: v1=$SIG" \
--data "$BODY"
PHP
<?php
function kamazeoleCall(
string $method,
string $path,
?array $body,
string $token,
string $secret,
string $eventId,
): array {
// Serialise once. The bytes you hash must be the bytes you send.
$raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$timestamp = time();
$canonical = implode("\n", [
'v1',
strtoupper($method),
$path,
(string) $timestamp,
$eventId,
hash('sha256', $raw),
]);
$signature = hash_hmac('sha256', $canonical, $secret);
$ch = curl_init('https://api.kamazeole.co.il' . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_POSTFIELDS => $raw,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'X-Kamazeole-Timestamp: ' . $timestamp,
'X-Kamazeole-Event-Id: ' . $eventId,
'X-Kamazeole-Signature: v1=' . $signature,
],
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'body' => json_decode((string) $response, true)];
}
// Keep this event id if you retry after a timeout.
$eventId = bin2hex(random_bytes(16));
$result = kamazeoleCall(
'PATCH',
'/api/v1/kamazeole/leads/6f1c9a2e-0000-4000-8000-000000000000/status',
['status' => 'lost', 'lost_reason' => 'already_customer', 'source_version' => 8],
getenv('KAMAZEOLE_TOKEN'),
getenv('KAMAZEOLE_SIGNING_SECRET'),
$eventId,
);
Node.js
const crypto = require('node:crypto');
const BASE = 'https://api.kamazeole.co.il';
async function kamazeoleCall({ method, path, body, token, secret, eventId }) {
// Serialise once — the bytes hashed must be the bytes sent.
const raw = body === undefined ? '' : JSON.stringify(body);
const timestamp = Math.floor(Date.now() / 1000);
const bodyHash = crypto.createHash('sha256').update(raw, 'utf8').digest('hex');
const canonical = ['v1', method.toUpperCase(), path, String(timestamp), eventId, bodyHash].join('\n');
const signature = crypto.createHmac('sha256', secret).update(canonical, 'utf8').digest('hex');
const response = await fetch(BASE + path, {
method: method.toUpperCase(),
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json',
'X-Kamazeole-Timestamp': String(timestamp),
'X-Kamazeole-Event-Id': eventId,
'X-Kamazeole-Signature': 'v1=' + signature,
},
body: raw === '' ? undefined : raw,
});
return { status: response.status, body: await response.json() };
}
// Reuse eventId when retrying a timed-out call.
const eventId = crypto.randomUUID();
await kamazeoleCall({
method: 'PATCH',
path: '/api/v1/kamazeole/leads/6f1c9a2e-0000-4000-8000-000000000000/status',
body: { status: 'converted', source_version: 9 },
token: process.env.KAMAZEOLE_TOKEN,
secret: process.env.KAMAZEOLE_SIGNING_SECRET,
eventId,
});
Go-live checklist#
- Reproduce the test vector byte for byte.
- Call
GET /api/v1/kamazeole/mewith your bearer token. It needs no signature and confirms your identity and scopes. - Send one status change on a test reference we provide.
- Send the same call again with the same event id — confirm nothing is applied twice.
- Send it again with a new event id and an older
source_version— confirm you receivestale. - Confirm your outbound endpoint upserts on
refand answers within 5 seconds. - Confirm your server clock is on NTP.
- Store both secrets outside your code repository.