Recipes
Five things people actually build, each one complete: every call, what comes back, and the decision you make with it. Nothing here is a fragment.
All of them assume a token bound to a location — see
Authenticating requests — and the base
URL https://klozzo.com.
1. A lead from a landing page, with its campaign
The form on your site posts to your server; your server posts here. Send the UTMs beside the person and the CRM keeps them attached to the arrival, not to the contact — so the second campaign that reaches them does not overwrite the first.
curl --request POST "https://klozzo.com/api/leads" \
--header "Authorization: Bearer $KLOZZO_TOKEN" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--header "Idempotency-Key: form-9f2c1a-1756000000" \
--data '{
"email": "ana.lopez@example.com",
"phone": "5555555555",
"phone_country": "MX",
"first_name": "Ana",
"source": "landing-verano",
"interest": "Paquete de firmas",
"landing_page": "https://example.com/producto-b",
"referrer": "https://example.com/blog/precios",
"utm": {
"utm_source": "adwords",
"utm_medium": "ppc",
"utm_campaign": "verano",
"gclid": "Cj0KCQjw"
}
}'
{
"data": {
"contact": { "ulid": "01M0RPTKZTY0KY011772XSRRWX" },
"contact_created": true,
"touch_id": "01M0RPTM0B9KJH7VK5B2MTWYT3",
"applied": { "email": "ana.lopez@example.com", "source": "landing-verano" },
"skipped": {},
"unmapped_keys": []
}
}
Three things this recipe depends on:
Idempotency-Keymade from the form submission, not from the person. A double-clicked submit button is one arrival; the same person filling the form next month is two.phone_countrybeside a national number. Send E.164 and you can leave it out. Send neither and you get a422— the CRM will not guess.sourceis written once and locked. Where somebody came from does not change because they came back. Look atskippedto see it happen.
2. Keeping an external CRM in step
Your system already has ids. Put them in external_ref and you never store
ours.
# 1 · Create the company with your identifier on it.
curl --request POST "https://klozzo.com/api/v1/companies" \
--header "Authorization: Bearer $KLOZZO_TOKEN" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: company-acme-create" \
--data '{ "name": "Acme", "external_ref": "acct_88213" }'
# 2 · Find it later by your id, never by ours.
curl --request GET "https://klozzo.com/api/v1/deals?external_ref=sub_88213" \
--header "Authorization: Bearer $KLOZZO_TOKEN"
# 3 · Adopt a record that predates the integration.
curl --request POST "https://klozzo.com/api/v1/deals/01JZ8Z0ZP00000000000000000/external-ref" \
--header "Authorization: Bearer $KLOZZO_TOKEN" \
--header "Content-Type: application/json" \
--data '{ "external_ref": "sub_88213" }'
# 4 · The money landed on your side. Close the deal on ours.
curl --request POST "https://klozzo.com/api/v1/deals/reconcile" \
--header "Authorization: Bearer $KLOZZO_TOKEN" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: rec-2026-08-02-88213" \
--data '{
"external_ref": "sub_88213",
"status": "won",
"amount": "25013.94",
"paid_at": "2026-08-02",
"reference": "SPEI-8891"
}'
external_ref is unique inside a location, so step 1 run twice is a conflict,
not a duplicate — which is why every mutating call here carries an
Idempotency-Key. The full contract is in
Two-way sales integration.
3. Send a quote and find out they opened it
The API creates the quote; the webhook tells you what the customer did with it. You do not poll for this.
curl --request POST "https://klozzo.com/api/v1/quotes" \
--header "Authorization: Bearer $KLOZZO_TOKEN" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: quote-88213-v1" \
--data '{
"deal_id": 41,
"title": "Paquete 1,100 firmas",
"valid_until": "2026-09-15",
"items": [
{ "name": "Firmas digitales", "quantity": 1100, "unit_price": "22.74" }
]
}'
Then subscribe an endpoint to the quote events and wait:
| Event | What just happened |
|---|---|
quote.shared |
The link went out to the customer. |
quote.viewed |
They opened it. This is the one worth a notification. |
quote.accepted / quote.rejected |
They decided. |
quote.expired |
valid_until passed with no answer. |
{
"event": "quote.viewed",
"data": { "schema_version": 1, "ulid": "01JZ8Z0ZP00000000000000000", "external_ref": "quote_88213" },
"delivery_id": 5512
}
Verify the signature before you trust any of it — Outbound webhooks has the code in four languages.
The quote also carries pdf_url, a link valid for about thirty minutes that
needs no token, for showing the document inside your own interface. It is
not the link the customer gets: theirs has the branded viewer and the read
tracking behind it, which is what makes quote.viewed possible at all.
4. React to an inbound WhatsApp
Somebody replies on WhatsApp. You get a webhook, decide what to do, and answer through the same conversation.
{
"event": "message.received",
"data": {
"schema_version": 1,
"conversation_id": 17,
"contact": { "ulid": "01M0RPTKZTY0KY011772XSRRWX" },
"channel": "whatsapp",
"body": "¿Sigue disponible el paquete?"
},
"delivery_id": 5513
}
# Read the thread if you need the context.
curl --request GET "https://klozzo.com/api/conversations/17/messages?per_page=20" \
--header "Authorization: Bearer $KLOZZO_TOKEN"
# Answer on the same channel the person used.
curl --request POST "https://klozzo.com/api/conversations/17/messages" \
--header "Authorization: Bearer $KLOZZO_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"channel": "whatsapp",
"body": "Sí, sigue disponible. ¿Le comparto la cotización?"
}'
{
"data": {
"id": 9001,
"conversation_id": 17,
"channel": "whatsapp",
"direction": "outbound",
"status": "queued"
}
}
queued is not sent. The message enters the channel's queue and its status
moves on its own — subscribe to message.sent if you need to know it left, and
do not block a request waiting for it.
If you do not have the conversation id, POST /api/contacts/{id}/conversations
opens one for a contact, or hands you the one that already exists.
5. Export the contacts that match a filter
Exports are asynchronous on purpose: an account with two hundred thousand contacts cannot answer that in one request.
# 1 · Ask for it.
curl --request POST "https://klozzo.com/api/exports" \
--header "Authorization: Bearer $KLOZZO_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"format": "xlsx",
"columns": ["first_name", "last_name", "email", "phone_e164", "source"],
"filters": { "source": "landing-verano" }
}'
{ "data": { "id": 88, "status": "pending", "download_url": null } }
# 2 · Poll until it stops being pending. Every few seconds is plenty.
curl --request GET "https://klozzo.com/api/exports/88" \
--header "Authorization: Bearer $KLOZZO_TOKEN"
{
"data": {
"id": 88,
"status": "completed",
"total_rows": 1420,
"download_url": "https://klozzo.com/api/exports/88/download?signature=…",
"expires_at": "2026-08-25T01:40:42+00:00"
}
}
The download_url is signed and needs no token, which is what lets you hand
it to a browser. It expires — expires_at says when — and so does the file
behind it. Download it, do not store the link.
Better than polling: subscribe to bulk_operation.completed and let the CRM
tell you. Polling is the fallback for when you cannot receive a webhook.