# 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](/docs/guides/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. ```bash 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" } }' ``` ```json { "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-Key`** made 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_country` beside a national number.** Send E.164 and you can leave it out. Send neither and you get a `422` — the CRM will not guess. - **`source` is written once and locked.** Where somebody came from does not change because they came back. Look at `skipped` to 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. ```bash # 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](/docs/guides/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. ```bash 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. | ```json { "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](/docs/guides/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. ```json { "event": "message.received", "data": { "schema_version": 1, "conversation_id": 17, "contact": { "ulid": "01M0RPTKZTY0KY011772XSRRWX" }, "channel": "whatsapp", "body": "¿Sigue disponible el paquete?" }, "delivery_id": 5513 } ``` ```bash # 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?" }' ``` ```json { "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. ```bash # 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" } }' ``` ```json { "data": { "id": 88, "status": "pending", "download_url": null } } ``` ```bash # 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" ``` ```json { "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.