Skip to content
Klozzo API
OpenAPI Postman

Recording a sale

Somebody paid on your side. Tell us, and stop there — you do not have to work out whether that person already exists, whether there is a deal open for them, or whether this is their first purchase or their fourth.

That is the whole contract: your system inserts, the CRM decides. Deciding it in two places is exactly how two systems come to disagree about who a customer is.

POST /api/v1/sales
Authorization: Bearer <token>
Idempotency-Key: ch_9912
Content-Type: application/json

{
  "external_ref": "ch_9912",
  "amount": "1499.00",
  "currency": "MXN",
  "paid_at": "2026-08-23T10:00:00Z",
  "reference": "SPEI-8891",
  "customer": {
    "email": "ada@example.com",
    "phone": "+52 55 1234 5678",
    "first_name": "Ada",
    "last_name": "Lovelace"
  },
  "plan": {
    "name": "Plan Pro",
    "external_ref": "sub_88213",
    "product_sku": "FIRMA-PAQ",
    "term_months": 12,
    "auto_renew": true
  }
}

Leave plan out entirely for a one-off purchase.

Call it when the money lands

Not when the checkout starts — when the charge is settled. From your payment provider's webhook, or right after your own transaction commits.

Send it from a queue with retries, never inside the request your customer is waiting on. If this CRM is down, that is our problem to fix, not a failed purchase on your side.

The two identifiers

This is the only decision you have to get right.

Field What it is Rule
external_ref The id of the charge Different on every payment. It is what makes recording it twice impossible
plan.external_ref The id of the customer's subscription The same on the first sale and on every renewal

The classic mistake is sending the same value for both. Repeat external_ref on a renewal and you will get back "already recorded" — and the renewal is lost.

What we do with it

meta.matched_by tells you which rule fired, so a sale that lands somewhere surprising explains itself instead of becoming a support ticket.

We found matched_by What happens
This external_ref already recorded payment_external_ref Nothing. You get the first answer back, created: false
The deal you named in deal_external_ref deal_external_ref It is closed and paid. An unknown reference answers 404
A deal carrying this sale's reference deal_by_sale_ref Same
The customer's one open deal contact_single_open_deal · company_single_open_deal Closed as won, and it keeps its owner — paying online is a payment method, not a change of who sold it
Nothing created A deal is opened already won and with no owner, because nobody here attended this customer

Two open deals for the same customer never resolve to a guess: there is no honest way to say which one the money was for, so a new deal is opened and a person decides.

Renewals are answered separately, in meta.renewal. Send the same plan.external_ref and the term is extended rather than duplicated — one contract, one customer — but the sale still gets a deal of its own. That is deliberate: hanging a renewal on the deal it renews would make that deal worth every payment ever made against it, and a report of a month that closed in March would change in September.

The customer

We look for them by email, then by phone in E.164 — including their secondary emails and phones. Send both if you have them; it makes the match better.

If they are not here, we create them. If they are, we reuse them and mark them as a customer.

We never overwrite what is already there. Name, phone and company are fill-if-empty: if the contact already has a name, the one you send is ignored. So sending the customer block on every sale is safe — it fills gaps, it does not rewrite history.

What to do with each answer

Code Meaning Your move
201 Recorded Mark it as synced
200 Already recorded Mark it as synced. This is not an error
400 No Idempotency-Key Add one and retry
403 The token lacks the quotes ability in this location Reissue it
404 You named a deal that is not here Do not retry blindly
422 Validation failed — no customer, or a currency that clashes with the deal's Do not retry unchanged
409 Same key with a different body, or two calls racing A bug on your side
5xx / timeout We failed Retry with the same Idempotency-Key

In code

class RecordSaleInCrm implements ShouldQueue
{
    public $tries = 5;
    public $backoff = [60, 300, 900, 3600];

    public function __construct(private Payment $payment) {}

    public function handle(): void
    {
        $response = Http::withToken(config('services.crm.token'))
            ->withHeaders(['Idempotency-Key' => $this->payment->id])
            ->acceptJson()
            ->post(config('services.crm.url').'/api/v1/sales', [
                'external_ref' => $this->payment->id,
                'amount'       => $this->payment->amount,
                'currency'     => $this->payment->currency,
                'paid_at'      => $this->payment->paid_at->toIso8601String(),
                'reference'    => $this->payment->reference,
                'customer' => [
                    'email'      => $this->payment->user->email,
                    'phone'      => $this->payment->user->phone,
                    'first_name' => $this->payment->user->first_name,
                    'last_name'  => $this->payment->user->last_name,
                ],
                'plan' => [
                    'name'         => $this->payment->plan->name,
                    'external_ref' => $this->payment->subscription_id,
                    'term_months'  => $this->payment->plan->months,
                    'auto_renew'   => $this->payment->subscription->auto_renews,
                ],
            ]);

        // A 4xx is our fault: retrying will not fix it and fills the queue.
        if ($response->clientError()) {
            $this->fail(new RuntimeException($response->json('message') ?? 'rejected'));

            return;
        }

        $response->throw(); // 5xx → retried with the same key
        $this->payment->update(['crm_synced_at' => now()]);
    }
}

What you do not have to build

  • Checking whether the customer exists first.
  • Creating the contact yourself through another endpoint.
  • Deciding whether to open a deal, close one, or leave it alone.
  • Tracking whether you already told us: that is what Idempotency-Key and a unique external_ref are for.