You are signing off a CRM integration for an online school in Almaty. You create test contact C7 and two independent purchases, D101 and D102, for 25,000 ₸ each. Only 25,000 ₸ reaches the report. The developer deduplicated by customer ID and decided the second purchase had already been counted. The records and numbers are illustrative; the course price is the same as in the lead measurement plan.
The data itself is right everywhere: the CRM has two deals, the payment records have two payments, and after the import only one is left. The bug is in how the integration joins them. To find it, separate three things: the person, the lead and the payment, and describe how each one passes through the integration.
Contact C7: two independent purchases
Deal D101
Payment P501: 25,000 ₸
E801 arrived twice, but there is still one payment P501.
Deal D102
Payment P502: 25,000 ₸
Another purchase by the same contact C7
The report must show P501 and P502, totalling 50,000 ₸. Identify retries by the payment key, not the customer ID.
What links to what
A contact is a person. A deal is one sales opportunity with its own stage and outcome. A deal can have several payments, and one payment can produce several notifications. Mix up these levels and you get exactly the bug from the start of this lesson.
Draw a table like this for your test, then find the field in your CRM for each row:
| Object | Example records | What they identify |
|---|---|---|
| Contact | C7 | Who is buying |
| Deal | D101, D102 | Which of the two sales the manager is handling |
| Payment | P501, P502 | Which two transactions the payment records confirm |
| Notification | E801, E802 | Which messages the handler received |
P501 belongs to D101, P502 to D102, and both payments lead back to C7. If notification E801 arrives twice, there is still only one payment P501. If P502 arrives after it, that is a second payment, even though the customer is the same.
An ID means little without its source. Deal number 101 in two CRM accounts can be two different deals. So the integration's register stores the system, the account and the ID for every record, and when a manager merges contacts, which old ID became which new one.
Getting a website lead into the CRM
A lead register depends on a manager copying leads in by hand. Once the website server creates the contact and deal through the API, that copying goes away. When you accept this part, look for three results: the lead is saved, it has a CRM ID, and the campaign tags sit on that same lead.
First, the server needs access. An amoCRM integration authorises with OAuth 2.0: the access token lasts one day (OAuth in amoCRM), the refresh token lasts three months and can be exchanged for a new pair only once (amoCRM step-by-step authorisation). So save the new pair as soon as the exchange completes: lose it and the integration has to be connected again. Since February 2024, amoCRM also lets an external or private integration issue a long-lived token valid from one day to five years, with no refresh token. In Bitrix24, your own server can connect through an incoming webhook; its address and permissions come below.
In amoCRM, a deal and its contact are created in one POST /api/v4/leads/complex request. You can send up to 50 deals at a time, each with at most one contact and one company (amoCRM deal methods). The method looks for duplicates only among records already created, and only if duplicate control is enabled for the integration, so don't rely on it alone. In Bitrix24, deals are created with crm.item.add: development of the older crm.deal.add method has stopped. Stages and fields such as "Paid" and "Course programme" may be account settings in your project. The developer needs their real identifiers from this CRM; numbers from someone else's example won't work.
The developer keeps a pair: the local lead ID and the CRM deal ID. If a request has to be retried after a timeout, the integration finds the deal it already created and carries on with it. If it creates a new deal on every retry instead, the manager gets two cards for one enquiry and may call the person twice.
A form resubmitted a couple of minutes later and a graduate's new enquiry six months later are different cases. The first needs one current lead; the second may need a new deal under the existing contact. Agree the rule with the sales team and run it on a test form. A matching phone number alone is no reason to discard every future enquiry: a graduate who came back for a second course would simply vanish from the funnel.
Keep the lead's source with the deal
The school tags its ads using the shared UTM scheme, campaign course_start. The form sends whatever tags are in the URL along with the lead, and the integration puts them on the deal. A Bitrix24 deal has standard fields for them, utmSource, utmMedium, utmCampaign, utmContent and utmTerm (deal fields in crm.item.add); the older method calls them UTM_SOURCE, UTM_MEDIUM, UTM_CAMPAIGN, UTM_CONTENT and UTM_TERM. If the tags are not saved straight away, the first move to another page or a manager transferring the contact loses them, and the sale is left without its advertising context.
One person's history may say "first came from Instagram" while their new enquiry came from another campaign. So keep the first contact source and the lead source in separate fields, and apply the attribution model to them in the report. When the integration updates a contact field, that person's earlier deals must not change.
If the tags did not arrive, the source stays unknown, and that is what gets recorded. The integration does not label everyone who wrote on WhatsApp "Instagram", and it does not demand an advertising click ID from an organic lead. To send payments to Google Ads or another ad account, the identifiers and sending conditions get their own item in the specification. The ad event never gets the full customer card, only the fields the ad account requires.
Which status counts as a payment
Before setting up the exchange, the school owner and whoever handles payments agree where confirmation of money comes from. A manager may have moved a deal to "Successfully completed" (amoCRM system status 142) right after the contract was signed. That is not enough for a payment report: you need a confirmed payment with an amount, currency, time and transaction ID.
In the test, those are P501 and P502 for 25,000 ₸ each. The integration takes the payment facts from the payment records and links them to the deals. The deal value field may hold a price the manager only expects to receive, so it cannot automatically be treated as money received.
Reopening D101 does not create another P501. If the customer makes a second payment on the same deal, it has its own transaction ID. A refund is also recorded as its own transaction and changes the total under a rule you set in advance. So the specification needs examples of full payment, partial payment, a refund and an erroneous stage change, even if the first test has only two purchases.
What arrives through an API and a webhook
With an API, your service goes to the CRM itself: it reads or changes a record. A webhook works the other way round: the CRM tells you that something has changed. An integration needs both. The notification says a record has changed, and a request reads the data needed to process that change.
amoCRM publishes webhook management methods and subscription events. Subscribe to what the job needs: a deal stage change and a confirmed payment are different events. The amoCRM notification format has no event ID of its own for a status change, so the handler assigns a number such as E801 itself. The payment is identified by the stable key of the payment itself, P501 in the test.
Bitrix24 names its webhooks by direction relative to the CRM:
| Mechanism | Who initiates the request | Its job in our design |
|---|---|---|
| Incoming webhook | Our service calls Bitrix24 | Call the REST API with the access granted |
| Outgoing webhook | Bitrix24 calls our handler | Report that a record has changed |
The Bitrix24 local webhook documentation gives the incoming webhook address as https://<portal>/rest/<employee ID>/<secret code>/<method>.json. Requests to it run with the rights of the employee who created the webhook, limited to the permissions selected for it. The secret code in the address works like a password: whoever knows the address works with the CRM as that employee. Keep the address on the server, out of browser code, ordinary logs and screenshots for the client.
A notification may carry only the deal ID, so the handler reads the fields it needs through the API. In the two CRMs, a notification's authenticity is checked in different ways. A Bitrix24 outgoing webhook carries auth[application_token], which is compared with the token in the webhook settings. The amoCRM webhook documentation describes no signature, so the notification is not taken on trust: the deal is read again through the API before the result is saved.
Message delivery and payment accounting
A handler for CRM notifications accepts them like any other webhook receiver, with one adjustment: a retry is recognised by the payment key P501, not by the customer ID C7. Otherwise the second purchase disappears, as it did at the start of this lesson.
There is a second trap. If the handler writes a technical field back to the CRM, that write can itself trigger a webhook, and the exchange starts going round in circles. So the subscription and processing rules tell a manager's changes apart from the integration's own technical writes.
Finding a gap after an outage
Notifications let you react quickly; scheduled reconciliation is what finds the gaps. When reading a list of records, the developer goes through every page of the response and marks which range was processed successfully. Time boundaries, overlapping intervals and how much history is available come from the API documentation. The number of rows on the first page of a response tells you nothing about them.
The current status and the history answer different questions. A deal became paid at 10:00 and cancelled at 11:00, and a request at 12:00 may show only the cancellation. Recovering both operations takes a ledger of payments and refunds or a change history. If no ledger was kept, a current-state request cannot replace it.
The reconciliation record compares IDs and amounts; the total alone is not enough. Losing D102 for 25,000 ₸ and an extra copy of D101 for the same 25,000 ₸ give a correct total with two errors in it. So the reconciliation report needs lists of missing, extra and mismatched records, and every discrepancy has someone responsible for it.
An access error gets its own signal. If the employee who granted access leaves or has their rights revoked, the report will come back empty, and it must not look like a day without sales. In acceptance testing, reproduce an access denial on purpose, and for the live exchange assign someone who will get the alert and restore the connection.
Task: an integration acceptance record
Take a form and CRM from your own project. You need test access that lets you create and change records: in the client's live account, test deals would end up in the sales team's pipeline and reports until someone deletes them.
0 of 6 done
For C7, the acceptance record must show P501 and P502, 50,000 ₸ in total. Two notifications for P501 are two deliveries but one payment. C7's new purchase stays in the report because it has its own payment fact. If the amount matches but the transaction IDs don't, the integration is not accepted: a matching total can hide two errors, as with the extra D101 and the lost D102.
What you hand the client is a set of records that can be followed from the form to the money, plus instructions for an outage. A list of connected services without that check won't help when a sale disappears from Monday's report and you are asked where it went.
Video: an amoCRM integration example
An example of connecting to amoCRM with Python. Note where the author stores the access and refresh tokens and what the code does when the access token expires after a day.