claude agent crm webhook duplicate imessage
By Beam team · Updated
Stop a Claude Agent From Texting a CRM Lead Twice
A CRM webhook can and will deliver the same lead-created or lead-updated event more than once, and a Claude agent that calls the iMessage send tool straight off that event will text the lead once per delivery, not once per lead. Fix it by deriving a stable idempotency key from the CRM event ID plus the agent's send decision, reserving that key in a store before the send call runs, and only marking it sent after the iMessage API confirms it went out.
Beam is an independent messaging platform, not an Anthropic or Apple product.
How Does a CRM Webhook End Up Firing Twice for the Same Lead?
A webhook retry is not a bug in your CRM. It is the contract. HubSpot, GoHighLevel and Salesforce all document at-least-once delivery, which means your endpoint has to assume the same event can show up more than once, not exactly once. When the thing sitting behind that webhook is a Claude agent deciding whether to send a text, the fix is not a try/catch around the HTTP call. It is a reservation step the agent has to pass before it is allowed to call send at all. We will walk through where the duplicates actually come from, what a safe idempotency key looks like for this specific case, and a numbered build-and-test procedure you can run before you point the agent at real contacts.
Webhook platforms retry on timeout, on a non-200 response, and sometimes on nothing more than a slow network hop between the CRM's servers and yours. Nylas, for example, documents at-least-once delivery for its own webhooks and notes that the same notification can arrive as many as three times (dev.to/mqasimca). A CRM webhook behaves the same way: if your endpoint takes too long to respond, or a worker between the receiver and your agent crashes mid-request, the CRM's retry logic treats the delivery as failed and sends it again, even though your system may have already started processing the first copy.
This is why our HubSpot iMessage integration guide at Beam treats the webhook receiver and the send logic as two separate jobs. The receiver's only job is to acknowledge quickly and queue the work. The send logic, running later and possibly on a different worker, is the part that has to decide whether this particular lead has already been texted for this particular event, not just whether the HTTP request looks new.
Three retry sources show up in almost every CRM-to-agent pipeline we have seen. The CRM itself resends a webhook whenever your endpoint does not return a 200 fast enough. A message queue sitting between the webhook receiver and the worker that calls the Claude agent can redeliver the same job too, since most queues, including SQS, Redis streams and Pub/Sub, are built at-least-once by design rather than exactly-once. And a crashed or restarted worker that was in the middle of handling an event when it died gets the same job rescheduled from the top by the orchestration layer, running through the identical event a second time.
None of these are rare edge cases on a low-traffic account. They are routine on any integration that runs for more than a few weeks, which is exactly why the idempotency work belongs in the build, not in a post-incident patch.
What Happens When a Claude Agent Re-Plans Instead of Repeating Itself?
A deterministic worker that gets the same event twice does the same thing twice, word for word, which is already bad enough. A Claude agent is worse in one specific way: it can produce a different output on the retry than it did on the first attempt, because the context available to the model is not guaranteed to be identical a second time. One widely read account of this failure mode put it plainly: "the first attempt sent 'Your order ships Tuesday', the retry sends 'Your order ships tomorrow'" (dev.to/pykero), because something as small as a changed timestamp in the prompt context was enough to shift the model's wording between the two runs.
That matters for a lead-texting agent specifically because the two messages do not read as a harmless repeat. A lead who gets "Thanks for your interest, when works for a call this week?" followed four seconds later by "Hey, following up, want to grab a quick call?" does not experience that as a glitch. It reads as two different people, or one disorganized one, texting them about the same thing. The fix has to sit in front of the model's decision, not inside it: the agent should never be invoked a second time for the same event once a send decision has already been reserved. Our guide to giving a Claude Agent SDK agent a send-text tool covers the other side of this, requiring a stable idempotency argument on the tool call itself so the send function refuses a repeat even if something upstream calls it twice.
What Should an Idempotency Key for an Outbound iMessage Actually Contain?
A key built only from the event ID is a start, but not quite enough, because two different real events can legitimately deserve two different texts. The key needs to capture the thing that should not repeat: one specific send decision, not the raw event, and that means pulling in four separate ingredients.
The first is the CRM event ID itself, taken from the webhook payload's own notification or event identifier rather than the record ID, which absorbs retries of the exact same delivery. The second is the lead or contact ID, the CRM record the text is actually about, which separates two different leads whose events happen to arrive close together. The third is the send intent, a short label the agent sets such as new_lead_greeting or follow_up_no_response, which lets a legitimate second message, a genuine follow-up, use a different key than the first instead of being blocked by it. The fourth is the line or number the message will send from, which matters once a workspace sends from more than one number, since the same lead could otherwise end up in two separate threads that never recognize each other as duplicates.
Concatenate those four values and hash the result. Skip a timestamp or the full event payload. A timestamp defeats the purpose, since every retry carries a different one, and the full payload makes the key brittle against field changes the CRM adds later.
Where Do You Store the Key Before You Ever Call the iMessage API?
The store matters more than the hashing scheme. A key you only check in application memory disappears the moment a worker restarts, which is precisely the failure mode most likely to cause a duplicate in the first place. One documented pattern for email agents facing the same problem uses a Redis SET with the NX flag, so the write only succeeds the first time a given key is seen, paired with a roughly one-day expiration so the store does not grow forever (dev.to/mqasimca). A relational database works just as well if Redis is not already part of your stack: a unique constraint on the idempotency key column does the same job, rejecting a second insert instead of returning true or false from a set command.
Either way, the sequence matters more than the storage engine. Reserve the key with a status of pending before the agent is allowed to call the send tool, call the iMessage API only after that reservation succeeds, and update the row to sent only once the API confirms the message queued. If the send call fails partway through, the row sits in pending, visible to a reconciliation job instead of silently vanishing or silently retrying forever. If your agent connects through MCP rather than a direct API call, the same reservation step still has to happen before the tool call fires; our guide to a custom agent's MCP rate limit covers the related question of what happens when retries start stacking up against that same connection, since an unbounded retry loop and a duplicate-send bug tend to show up together in practice.
How Do You Build This, Step By Step?
- Verify the CRM's webhook signature before you parse the payload, so a replayed or forged request never reaches the dedupe logic at all.
- Pull the event ID, the lead ID and the line the message will send from out of the payload, and decide the send intent label for this event (new lead, reminder, follow-up).
- Build the idempotency key from those four values and attempt to reserve it in your store, using
SET NXor an insert against a unique constraint. - If the reservation fails because the key already exists, stop here and return a success response to the CRM without calling the agent again. The event has already been handled or is being handled right now.
- If the reservation succeeds, hand the event to the Claude agent and let it decide what to send, passing the same idempotency key through as an argument on the send tool itself, not just at the webhook layer.
- Call the iMessage send endpoint or MCP tool with that key attached, and wait for a confirmed response before moving on.
- Update the reservation row from pending to sent once the send call confirms, or to failed if it errors out, so a reconciliation job can tell the difference between "never attempted" and "attempted and broke."
- Acknowledge the original webhook with a 200 as early in this flow as your platform allows, ideally right after step 1, so the CRM has no reason to retry a request that is already being handled correctly on your side.
Our CRM-to-iMessage integration guide covers the basic wiring between a CRM event and an outbound text; this procedure is the layer you add on top of that wiring once the connection already works and you are hardening it against retries.
How Do You Test That a Retried Webhook Never Reaches a Second Text?
A connection that works on the first try proves nothing about what happens on the second one. Run through this before any real lead sees a message from the agent:
- Capture one real webhook payload from your CRM and replay it twice in a row, a few seconds apart, against your local endpoint. Confirm exactly one row reaches sent and the second attempt short-circuits at the reservation step.
- Kill the worker process partway through handling an event, after the reservation succeeds but before the send call completes, then replay the same payload. Confirm the system either completes the original send or flags the row for reconciliation, never both a flagged row and a sent text.
- Send two genuinely different events for the same lead close together (a creation event and an update a minute later) and confirm both go through, since different send intents should not block each other.
- Check your idempotency store directly after each test and confirm the key composition matches what step two of the build procedure above describes, not just the raw event ID.
- Turn off your network connection to the iMessage API mid-test and confirm a failed send updates the row to failed rather than leaving it stuck in pending with no retry path and no alert.
- Re-run the full sequence a week later, after any change to the CRM's webhook payload shape, since an added or renamed field can quietly change what your key-building code extracts without raising an error anywhere.
- Compare what your reservation store shows against what the agent actually sent, pulled straight from the iMessage API's own message log, not from your application's record of what it believes it sent. A mismatch between those two sources is the clearest sign a gap still exists somewhere in the chain.
Diagram: a horizontal flow with three lanes. Lane one, labeled "CRM," shows one webhook event fired twice with a dotted arrow looping back to represent the retry. Lane two, labeled "Reservation store," shows a single key written once, with the second arrival hitting a red "blocked" box instead of a second key. Lane three, labeled "Claude agent and iMessage API," shows only one path continuing through to a single phone icon, with the blocked arrow from lane two never reaching lane three at all.
Treat this checklist the same way you would treat a payment integration's test suite. The lead only has to receive the second text once for the damage to be done, and by then there is no way to unsend it.
Before you connect
What do teams ask before they connect?
How long does it take to add idempotency protection to a Claude agent that already texts from a CRM webhook?
Most teams finish the core change in a day: one new table or Redis key, one guard clause before the send call, and a signature check on the webhook route. The slower part is testing. Give yourself a second day to replay recorded payloads and kill a worker mid-send before you trust it with real contacts.
Does deduplicating webhook events require a new paid service, or can we build it with what we already run?
You almost certainly already have what you need. A row in the Postgres database your CRM sync already writes to, or a Redis instance your queue already uses, is enough to hold an idempotency key and a status. This is a schema and logic change, not a new piece of infrastructure to buy and operate.
Does this same idempotency pattern work if our CRM is GoHighLevel or HubSpot instead of Salesforce?
Yes, because the fix lives below the CRM, not inside it. Every CRM webhook, whatever it is named there, carries some kind of event or notification identifier alongside the lead data. You build the idempotency key from that identifier plus your own agent's decision, so the pattern does not change when the CRM does.
Does deduplicating retries change how STOP requests and opt-out handling work on the texts we send?
No, and it should not. Idempotency only decides whether a given send attempt is allowed to run again. Opt-out status, quiet hours and consent checks still have to run inside the send path itself, every time, on the first attempt and on any attempt that gets past the duplicate check.
We already shipped a Claude agent that texts leads without an idempotency key. What is the smallest safe change to retrofit this?
Add the guard at the narrowest point first: wrap only the final send call in a reserve-then-send check keyed on the CRM event ID, before you touch anything else in the agent's logic. That stops the bleeding immediately. Broader fixes like signature verification and thread-level locks can follow once the immediate risk of texting someone twice is closed.
Keep exploring