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-Keyand a uniqueexternal_refare for.