# 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. ```http POST /api/v1/sales Authorization: Bearer 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 ```php 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.