Skip to content
Firmspace
Back to blog

The Event That Arrives Twice: Why Firmspace Counts a Payment, a Message and a Call Only Once

A payment provider sends a webhook. The CRM books the payment, answers, and the provider — not having received the answer quickly enough — sends the same notification again. A messenger retries a delivery after a timeout; a phone system posts the same call result twice. This is normal behaviour on the internet, not a broken integration. The danger is what a CRM does with the second copy.

Firmspace answers with a simple rule: every event must carry an identity from its source, and the CRM checks that identity before it changes anything. Money, messages and calls use different tables and different keys, but the principle is the same. The same event may arrive many times; its effect must happen once. That identity is also what makes it safe to let an assistant or an automation rule react to the event: the machine acts once, not twice. The second delivery is acknowledged and then ignored.

Take a card payment first. The webhook route checks the provider's signature before it reads the amount; an invalid signature is rejected. Intermediate statuses such as created or processing change nothing. Only a paid status goes further. The idempotency key is a truncated SHA-256 fingerprint of the raw notification body. Providers that retry usually resend the same bytes, so the same notification produces the same key.

That key is passed to registerPayment, the single function that books money in Firmspace. The manual paid button, the assistant, the acquiring webhook and bank reconciliation all end there. Inside a database transaction the function locks the invoice row, reads it, and looks for a payment event with the same organisation, source and external id. If the event exists, the answer is duplicate and the applied amount is zero. Nothing is written. If it does not exist, the function applies the amount, creates the event and updates the invoice.

The database enforces the same rule with a unique constraint on organisation, source and external id. Two identical notifications that arrive at the same moment cannot both create an event: one transaction wins, and the second either sees the existing event after the invoice lock or hits the unique constraint; both outcomes are treated as a duplicate, not a failure. The invoice row lock closes the remaining window, so two different payments cannot read the same already-paid amount and overwrite each other.

Partial payments are where this matters most. A customer pays a deposit; the provider retries the notification; without a key the second delivery would add the deposit again and the invoice would look more paid than it is. With the key, the second delivery returns the same invoice and an applied amount of zero. The balance does not move. When no amount is sent at all, the function applies only what is still open, not the full gross a second time.

The response code is part of the mechanism. A duplicate returns HTTP 200 with duplicate true; an invoice that is already closed also returns success, because the provider should stop retrying. A server error would do the opposite: it would invite more attempts. The duplicate answer is therefore not a technical detail. It is how the CRM tells the sender that the event is already stored.

Bank statements follow the same discipline. A monobank row receives an external id built from the statement's own row id; a CSV row carries the id from its file. Before inserting, the import loads the external ids already known for that account and filters the incoming rows. A repeated import of the same statement does not create a second movement. Matching a bank row to an invoice uses the bank transaction id as the payment key, so re-matching the same row does not add money again; changing the match first reverts the old payment and then applies the new one.

Messages have their own version of the rule. An incoming message from a provider carries a message id. The channel layer checks the pair of integration and message id before it downloads an attachment — a retry should not store the same file twice — and checks again just before inserting the message. A duplicate returns the existing conversation and no new message, so the notification, the lead filter and the contact activity entry are not repeated.

Calls use the same channel layer. The browser or the SIP provider posts a call id, a direction, a peer, a status and a duration. The layer turns that into the key call:CALL_ID:DIRECTION. A repeated post returns success with duplicate true and HTTP 200 instead of 201. The call record, the entry in the contact's activity, the missed-call event and the notification exist once. That matters because a missed call can start an automation rule or create a task; two records would mean two tasks.

The consequences are protected too. A notification can carry a key; if a notification with the same organisation and key already exists, the second one is not created. Team notifications append the recipient to the key, so each colleague still gets exactly one copy. When an activity entry carries a key, the log inside a deal, contact or company checks for that key before appending; the payment entry, for example, is keyed by invoice, source and external id. A retried payment therefore does not add a second payment-received line to the customer's history.

There are honest limits. The channel check is a read followed by an insert, not a database unique constraint on the message id, so a narrow race is possible even though the code checks twice. The bank transaction import also filters known external ids in application code rather than relying on a unique constraint. And no key can decide that two different provider ids describe the same real-world transfer. If a provider changes the id on every retry, or if a payment is first reported by a card processor and later by a bank, only a human reconciliation can decide whether it is the same money.

What is deliberately not merged is just as important. Two payments with different provider ids are two payments, even when the amount and the customer match. Two messages with different ids are two messages. A customer who writes hello twice has written twice; a provider that retries one message has not. The rule is identity, not similarity. Merging lookalike events would hide real repeat business and make the ledger lie in the other direction.

The duplicate invoice button belongs to a different category. It creates a new draft with a new number on purpose; it is a copy, not an idempotent retry. A credit note is also a new document with its own rules. The event rule applies to the same event arriving again, not to a person asking for another document.

This is why the subject matters beyond plumbing. The assistant's write actions use the same tools and, for money, the same payment function. Automation rules listen for events such as invoice paid, message received or call missed. If a duplicate is stored once, the rule runs once; if it were stored twice, an AI step could send two replies, create two tasks or book the same payment twice. Retries are safe only when the effect is singular.

A CRM cannot stop the network from repeating itself. It can make repetition harmless. The design choice in Firmspace is to give every event a name from its source, to check that name before acting, to let the database enforce uniqueness where the money is involved, and to answer a duplicate with success instead of an error. The owner sees one payment, one message, one call — and one set of consequences.

Let us automate your next process together.

Describe a recurring workflow to us. Together we will check whether and how it can be automated sensibly.