Skip to main content

The problem

An AI agent that writes directly to a CRM can fire duplicate updates if it retries, or silently overwrite fields the moment it decides to — no confirmation, no audit record.
// ❌ Direct HubSpot call — no dedup, no record, fires immediately
await fetch(`https://api.hubapi.com/crm/v3/objects/contacts/${contactId}`, {
  method: "PATCH",
  headers: { Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}` },
  body: JSON.stringify({ properties: { email: "new@example.com", phone: "+15550001234" } }),
});
If the agent retries (network hiccup, timeout), HubSpot gets two identical writes. And if the agent is wrong about the contact ID, the damage is immediate and silent.

The SafeFetch solution

Wrap the CRM call in safeFetch() with a dedupe key so duplicate requests are collapsed into one, and use sync: true to wait for confirmation before telling the user the update succeeded.

Full example

import { safeFetch } from "safefetch";

// Derive a stable dedupe key from the operation — same contact + same fields
// within 24 h will return the original action instead of firing again.
const dedupeKey = `crm-update:${contactId}:email+phone`;

const action = await safeFetch({
  url: `https://api.hubapi.com/crm/v3/objects/contacts/${contactId}`,
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: {
    properties: {
      email: "new@example.com",
      phone: "+15550001234",
    },
  },
  dedupe: dedupeKey,
  // Wait for HubSpot to respond before returning
  sync: true,
  syncTimeout: 15_000, // 15 s
});

if (action.status === "completed" && action.response_code === 200) {
  console.log("Contact updated:", action.id);
} else if (action.deduplicated) {
  console.log("Duplicate request — original action:", action.id);
} else {
  console.log("Update failed:", action.last_error);
}
safeFetch() returns once the PATCH to HubSpot has completed (or failed). action.deduplicated === true means the same write was already in flight — no second request was made.

How deduplication works

1

First call goes through

SafeFetch stores the action and dispatches the PATCH to HubSpot. The dedupe key is recorded alongside it.
2

Retry or duplicate call arrives

If your agent retries within 24 hours using the same dedupe key, SafeFetch returns the original action object immediately. HubSpot is not contacted a second time.
3

Check `deduplicated`

Inspect action.deduplicated to know whether you got a fresh result or the cached one. Either way, action.status and action.response_code reflect the true outcome.

Using sync mode

sync: true polls the action until it reaches a terminal status (completed, failed, or cancelled) and returns the final object. This is useful when the downstream workflow needs confirmation before proceeding.
const action = await safeFetch({
  url: `https://api.salesforce.com/services/data/v59.0/sobjects/Contact/${contactId}`,
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.SF_ACCESS_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: {
    Email: "new@example.com",
    Phone: "+15550001234",
  },
  sync: true,
});

// Guaranteed to be in a terminal status here
console.log(action.status);        // "completed" or "failed"
console.log(action.response_code); // e.g. 204 (Salesforce returns 204 on success)

Checking status later

If you don’t use sync: true, inspect the action at any point using its ID:
const action = await safeFetch.get("act_Xk9mP2nQ4rT1vW8sY");

if (action.status === "completed") {
  console.log(`Update confirmed in ${action.duration_ms}ms`);
} else if (action.status === "failed") {
  console.log("Failed after", action.attempts, "attempts:", action.last_error);
}

Adding a human approval gate

For high-stakes field changes (ownership transfers, account merges, subscription downgrades), add approve: true to require sign-off before the write reaches your CRM:
const action = await safeFetch({
  url: `https://api.hubapi.com/crm/v3/objects/contacts/${contactId}`,
  method: "PATCH",
  headers: { Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}` },
  body: { properties: { hs_lead_status: "UNQUALIFIED" } },
  approve: true,
  notify: { email: "sales-ops@yourcompany.com" },
  dedupe: `crm-disqualify:${contactId}`,
});

console.log(action.status); // "awaiting_approval"
See Human Approval for the full approval workflow.
sync: true and approve: true cannot be combined. Use sync: true for automated writes where you need an immediate result, and approve: true when a human must review the change first.