> ## Documentation Index
> Fetch the complete documentation index at: https://80x.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook lead intake with safe upsert

> A webhook front door parses inbound JSON, dedupes on email or domain, and upserts a lead without creating a duplicate.

**A webhook front door** parses inbound JSON, dedupes on email or domain, and upserts a lead without creating a duplicate. A webhook is just a URL that other systems can POST data to. This is the intake that every revenue operations (RevOps) team eventually wants. Form fills, product sign-ups, and partner feeds all POST to one endpoint. The [Webhook received](/reference/triggers/webhook-received) trigger then sends each one down a clean, repeatable path.

That path has six steps. First you [parse the JSON](/reference/utilities/parse-json) field by field. Then you [filter](/reference/conditions/filter) out anything missing a usable identity. Next you look the contact up with [Find records](/reference/records/find-records) on a dedup key. A dedup key is the one stable field, like email, that you match on. It is what stops you from creating two copies of the same person. Finally you write with [Create or update record](/reference/records/create-or-update-record), tag the source with [Add record to list](/reference/lists/add-record-to-list), and confirm the intake in a [Slack](/reference/integrations) channel.

| | |
| - | - |
| **Difficulty** | Advanced |
| **Time to build** | About 20 minutes |
| **Trigger** | [Webhook received](/reference/triggers/webhook-received) |
| **Blocks used** | [Parse JSON](/reference/utilities/parse-json), [Filter](/reference/conditions/filter), [Find records](/reference/records/find-records), [Create or update record](/reference/records/create-or-update-record), [Add record to list](/reference/lists/add-record-to-list), [Slack](/reference/integrations) |
| **Credit cost per run** | 3 credits. See the worked example. |
| **Plan required** | Plus and above |

## Prerequisites

* An Attio plan on the **Plus** tier or higher. The new Workflows engine and the [Webhook received](/reference/triggers/webhook-received) trigger are available there.
* A connected Slack integration with a channel the team watches, such as `#new-leads`.
* A **Person** object with an `email_addresses` attribute, and a **Company** object with a `domains` attribute. These two attributes are your dedup keys.
* A list to tag intake against. This recipe uses one called **Inbound leads** with a single-select `source` attribute.
* Permission to read and write records in the workspace. The Attio side needs no API key or external scope. The source system needs only the webhook URL.

## How it works

The webhook accepts one JSON payload, you check it before any write, and the upsert matches on a stable key. An upsert means update the record if it already exists, or create it if it does not. Because you match on a stable key, a repeat submission updates the existing record instead of cloning it.

The free logic blocks do all the qualifying. That way credits are spent only on the writes at the end. The run flows through these blocks in order:

1. [Webhook received](/reference/triggers/webhook-received) accepts the inbound POST.
2. [Parse JSON](/reference/utilities/parse-json) extracts `email`, `name`, `company_domain`, and `source` field by field.
3. [Filter](/reference/conditions/filter) requires an email or a domain. It stops the run otherwise.
4. [Find records](/reference/records/find-records) looks up an existing Person by the dedup key.
5. [Create or update record](/reference/records/create-or-update-record) performs the safe upsert on `email_addresses`.
6. [Add record to list](/reference/lists/add-record-to-list) tags the lead with its source.
7. [Slack](/reference/integrations) confirms the intake to the team.

## Build steps

<Steps>
  <Step title="Add the webhook trigger">
    Create a new workflow and add the **Webhook received** trigger. Copy the generated endpoint URL into your source system. Set the source to send `Content-Type: application/json`, because the trigger parses the body only when the content type is JSON.

    | Field | Value |
    | - | - |
    | Trigger | Webhook received |
    | Content-Type | `application/json` |
    | Method | `POST` |
    | Endpoint | Copy the generated URL into your form or product event |
  </Step>

  <Step title="Parse the payload field by field">
    Add the **Parse JSON** block and point it at the webhook body. Define each field you depend on. Mark only the fields you truly need as required, so a thin payload fails loudly at this step instead of writing garbage downstream.

    | Field | Value |
    | - | - |
    | Source | Webhook received → Body |
    | `email` | `String`, required |
    | `name` | `String`, optional |
    | `company_domain` | `String`, optional |
    | `source` | `String`, optional |
  </Step>

  <Step title="Filter out unusable payloads">
    Add the **Filter** block. Require a usable identity, meaning at minimum an email or a domain. That way spam and empty pings stop at the Filter block before any credit is spent. A condition that stops a run is free.

    | Field | Value |
    | - | - |
    | Condition | `email` is not empty |
    | And/Or | OR `company_domain` is not empty |
    | On no match | Stop run |
  </Step>

  <Step title="Find the existing record">
    Add the **Find records** block to look up a Person on the dedup key. This lookup is what makes the upsert safe, because you match on the same stable attribute every time.

    | Field | Value |
    | - | - |
    | Object | Person |
    | Match attribute | `email_addresses` |
    | Match value | Parse JSON → `email` (`jane@acme.com`) |
    | Limit | 1 |
  </Step>

  <Step title="Upsert the lead">
    Add the **Create or update record** block. Set the matching attribute to the same key you searched on, so a repeat submission updates the existing Person instead of cloning it.

    | Field | Value |
    | - | - |
    | Object | Person |
    | Match attribute | `email_addresses` |
    | `email_addresses` | Parse JSON → `email` (`jane@acme.com`) |
    | `name` | Parse JSON → `name` (Priya Sharma) |
    | `company` | Parse JSON → `company_domain` (`acme.com`) |

    <Warning>
      The match attribute on **Create or update record** must be the same `email_addresses` key you searched on in **Find records**. A different match attribute, or none at all, makes every resubmission write a fresh Person and silently fills the workspace with duplicates.
    </Warning>
  </Step>

  <Step title="Tag the source list">
    Add the **Add record to list** block so each lead carries where it came from. Map the parsed source into the list's `source` attribute.

    | Field | Value |
    | - | - |
    | List | Inbound leads |
    | Record | Create or update record → Person |
    | `source` | Parse JSON → `source` (`pricing_page`) |
  </Step>

  <Step title="Confirm in Slack">
    Add the **Slack** block to post a one-line confirmation to the channel the team watches.

    | Field | Value |
    | - | - |
    | Channel | `#new-leads` |
    | Message | `New lead: {{name}} ({{email}}) via {{source}}` |
  </Step>
</Steps>

## Variable mapping

| Variable | Source block | Field | Example value |
| - | - | - | - |
| `email` | Parse JSON | `email` | `jane@acme.com` |
| `name` | Parse JSON | `name` | Priya Sharma |
| `company_domain` | Parse JSON | `company_domain` | `acme.com` |
| `source` | Parse JSON | `source` | `pricing_page` |
| `matched_person` | Find records | First result | Person record or empty |
| `person` | Create or update record | Record | Person `jane@acme.com` |

## Credit cost worked example

The trigger, **Parse JSON**, **Filter**, and **Find records** are free logic and lookup blocks. The two writes cost 1 credit each: **Create or update record** is 1 credit, and **Add record to list** is 1 credit. The **Slack** confirmation is an integration block at 1 credit. So a fully successful run that reaches Slack costs 3 credits. A run that upserts and tags but skips Slack costs 2 credits.

At a realistic 800 valid intakes per month, that comes to about 800 × 3 = 2,400 credits. Any payloads the **Filter** rejects add 0 credits. Compare that figure against your plan's monthly allowance on [Credits and pricing](/reference/credits-and-pricing) before you turn the workflow on. This recipe contains no AI blocks, so it carries no token-based cost to estimate.

## Test plan

1. POST a clean payload to the endpoint with `Content-Type: application/json`:

   ```json theme={null}
   {
     "email": "jane@acme.com",
     "name": "Priya Sharma",
     "company_domain": "acme.com",
     "source": "pricing_page"
   }
   ```

2. Open the run in run history. **Parse JSON** outputs the four fields, **Filter** passes, **Find records** returns empty on the first submission, and **Create or update record** reports a created Person.

3. POST the exact same payload again. This is the duplicate test. **Find records** now returns the existing Person, and **Create or update record** reports an update rather than a second record. Confirm in the Person list that there is still only one `jane@acme.com`.

4. Check `#new-leads` for two confirmation messages, one per run.

5. POST a deliberately failing payload with no identity:

   ```json theme={null}
   { "name": "Anon", "source": "pricing_page" }
   ```

   **Filter** stops the run, and run history shows zero writes and zero credits spent.

## Failure modes

| Symptom | Cause | Fix |
| - | - | - |
| Webhook fires but Parse JSON outputs nothing | Payload sent as form-encoded, not JSON | Set the source to send `Content-Type: application/json` |
| Source system reports a timeout or retries the POST | The outbound caller expects a response within about 5 seconds and retries on a missed or late acknowledgment | Keep the path lean. Attio acknowledges fast, but a slow source treats a missed acknowledgment as a retry. Make the upsert idempotent so retries stay safe. Idempotent means running the same request twice has the same result as running it once. |
| Duplicate records appear on resubmission | The match attribute on the upsert differs from the Find key, or no match attribute is set | Set the **Create or update record** match attribute to the same `email_addresses` key you searched on |
| Run stops at Filter on valid leads | A required field is marked too strictly in Parse JSON | Require only `email` or `company_domain`, and mark the rest optional |
| Lead created but no source on the list entry | The `source` value is unmapped, or the source string is not a valid single-select option | Map Parse JSON → `source` into the list attribute, and add the option if it is new |

## Variations

### Tagging each source to its own list

Route each source to its own list instead of one shared **Inbound leads** list. Add a [Switch](/reference/conditions/switch) on the `source` field after the upsert, with one branch per source (`pricing_page`, `partner_feed`, `webinar`). Give each branch its own **Add record to list** block. The **Switch** block is free, so this variation adds no credit cost beyond the writes you would already make.

### Rejecting spam early

Tighten the [Filter](/reference/conditions/filter) to drop obvious junk before any write. Add conditions that stop the run when `email` ends in a disposable domain, or when `company_domain` is on a blocklist. The **Filter** block is free and stops the run, so rejected spam costs zero credits and never reaches the upsert.

## See also

* [Score leads into explainable bands](/guides/capture-and-qualify/signal-scoring)
* [Records vs list entries](/explanation/records-vs-list-entries)
* [Create or update record](/reference/records/create-or-update-record)
* [Find records](/reference/records/find-records)
* [Webhooks in Attio](https://attio.com/help/apps/other-apps/webhooks)

***

*Source: [Webhooks in Attio](https://attio.com/help/apps/other-apps/webhooks) (Attio Help Center). Last reviewed against the Attio Help Center, June 2026.*


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.