Kamazeole Developers
v1 Stable Last updated 9 August 2026

Lead API

Two independent directions. You can implement either one without the other, and most partners start with delivery alone.

DirectionWho callsPurpose
OutboundKamazeole → youWe push each new lead to your endpoint
InboundYou → KamazeoleYou 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.

FieldNotes
refUUID. Your key for every inbound call. Store it.
nameFull name, unmasked
phoneFull number, unmasked
emailMay be absent
companyCarrier name in Hebrew
categorySlug: internet, cell, fiber, tv, triple, electric
category_labelHebrew label of the category
statusLead status at hand-off
notesFree text captured on our side, including the product the lead asked about and their answers to our qualifying questions
landing_pagePath of the page the lead came from
created_atISO 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

AttemptDelay after the previous failure
1immediate
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.

CredentialPurposeFormat
API tokenIdentifies youkz_live_…
Signing secretProves the request is intact64-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

HeaderValue
AuthorizationBearer kz_live_…
Content-Typeapplication/json
X-Kamazeole-TimestampUnix time in seconds, integer, UTC. Omitted when signing is disabled
X-Kamazeole-Event-IdYour unique id for this event, ≤191 chars, [A-Za-z0-9._:-]+
X-Kamazeole-Signaturev1= 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.

canonical string
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
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.

inputs
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
canonical string — 189 bytes, exactly 5 LF, no CR
v1
PATCH
/api/v1/kamazeole/leads/6f1c9a2e-0000-4000-8000-000000000000/status
1786230000
550e8400-e29b-41d4-a716-446655440000
a15c0b1a4def01462bf1800e4839a45060b64277d91835ae67eaa19df9a59f6b
expected result
hmac_sha256_hex  = c0f9cb4aff3b13bc0c5fd6307ab2b0e9a312c523c21e27dfcaeff84b98186bc0
header_signature = v1=c0f9cb4aff3b13bc0c5fd6307ab2b0e9a312c523c21e27dfcaeff84b98186bc0

If your signature does not match, compare the exact bytes being signed:

canonical string, hex
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.

SituationResult
First time we see this id200. Applied
Same id, same request200. The first result is replayed; nothing is applied twice
Same id, different request409. An id is bound to one payload
An earlier event is still processing409. 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
Authorizationrequiredrequired
X-Kamazeole-Event-Idrequiredstill required
X-Kamazeole-Timestamprequirednot used
X-Kamazeole-Signaturerequirednot used
IP allowlistnot usedrecommended

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.

bash — unsigned call
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#

PATCH /api/v1/kamazeole/leads/{ref}/status

Requires the leads:status scope.

request body
{
  "status": "lost",
  "lost_reason": "already_customer",
  "occurred_at": "2026-08-09T10:15:00+03:00",
  "source_version": 7
}
FieldRequiredRules
statusyesOne of the values below
lost_reasonnoOnly valid with status: "lost"
occurred_atnoISO 8601, timezone mandatory
source_versionnoInteger ≥ 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

status
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.

example mapping we configure for you
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

ValueMeaning
already_customerAlready a customer of this carrier
not_interestedNot interested
wrong_numberWrong number
no_answerNever reached
pricePrice objection
out_of_areaOutside coverage
duplicateDuplicate of another lead
otherAnything 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

200 OK
{
  "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": "…" }
}
FieldMeaning
resultAlways "ok" on success — a stable keyword for systems that judge the body rather than the status code
changedfalse means the lead already held that status; no business effect
duplicatetrue means this event id had already been processed; nothing was re-applied
appliedfalse means the event was not applied — see stale
staletrue 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#

POST /api/v1/kamazeole/leads/{ref}/notes

Requires the leads:notes scope.

request body
{
  "note": "לקוח ביקש לחזור אליו אחרי 18:00",
  "author": "Dana R.",
  "external_note_id": "crm-note-88431",
  "occurred_at": "2026-08-09T10:20:00+03:00"
}
FieldRequiredRules
noteyesNon-empty text
authornoWho wrote it on your side
external_note_idnoYour note id, ≤191 chars
occurred_atnoISO 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

GET /api/v1/kamazeole/leads/{ref}/notes

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.

ValueWhereMeansUse for ordering?
X-Kamazeole-TimestampheaderWhen the request was signednever
occurred_atbodyWhen the change happened in your systemyes
updated_atour databaseWhen we recorded itinternal 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:

MetadataStrength
source_versionBest. A counter you increment on every change to that lead. An integer comparison cannot be wrong.
occurred_atGood. Requires a sane clock; timezone mandatory.
neitherFallback: last arrival wins. Acceptable only if you never send concurrent updates for the same lead.
CaseResult
Newer than what we holdApplied. 200
Older than what we holdIgnored, reported as stale. Still 200 — not an error, we simply already know something more recent
Same ordering value, different content409. 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#

CodeMeaningRetry?
200Applied, replayed, or ignored as staleno
400Malformed requestno — fix it
401Token missing, unknown, revoked, or signature invalidno — fix it
403Valid token, missing scope — or calling IP outside your allowlistno — contact us
404Reference unknown, or not yoursno
409Event id reused with a different payload, ordering ambiguity, or concurrent writeyes, after a short delay
422Validation failedno — fix it
429Rate limitedyes, with backoff
5xxOur faultyes, 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

SymptomMost likely cause
401 on every call, token is correctSignature mismatch. Reproduce the test vector first
Signature was correct, now fails intermittentlyServer clock drift beyond 5 minutes. Check NTP
401 only on requests with a bodyYou hashed a re-serialised object instead of the transmitted bytes
409 on retryYou generated a new event id for the same event, with a different payload
422, unexpected fieldYou sent a key outside the documented list
404 on a reference you receivedThe lead was reassigned, or the reference belongs to another integration
Response says staleWe already hold a newer state. Not an error
Duplicate leads on your sideYou 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.

bash
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
<?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

javascript
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#

  1. Reproduce the test vector byte for byte.
  2. Call GET /api/v1/kamazeole/me with your bearer token. It needs no signature and confirms your identity and scopes.
  3. Send one status change on a test reference we provide.
  4. Send the same call again with the same event id — confirm nothing is applied twice.
  5. Send it again with a new event id and an older source_version — confirm you receive stale.
  6. Confirm your outbound endpoint upserts on ref and answers within 5 seconds.
  7. Confirm your server clock is on NTP.
  8. Store both secrets outside your code repository.