> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cometly.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Account-level attribution for B2B (company external ID)

> Attribute ad-platform conversions to the account that clicked the ad, not just the person who paid, using company_external_id.

## The problem

In B2C, the person who clicks the ad is usually the person who buys — attribution just follows one contact. B2B breaks that assumption. Accounts payable pays the invoice; a developer on the same team clicked the ad and signed up for a free trial weeks earlier using a personal Gmail address. Grouping contacts by email domain can't connect them, because the payer's and the developer's email domains don't match, or neither address is a business address at all.

What you actually want is CRM-style **account-level attribution**: group every contact that belongs to the same customer account together, so a conversion by one teammate can be credited to the ad another teammate clicked. `company_external_id` is how you tell Cometly which contacts belong to the same account.

## Prerequisites

* A Cometly [API key](/introduction/authentication).
* A Space.
* The Space's **Company identity mode** set to **"Group by your account ID"** (External ID), in **Space Setup → Additional Setup → Company Tracking**. The other mode, **"Group by email domain"** (Auto), is the default and ignores `company_external_id` entirely.

Pick one mode and stick to it. Switching does not move existing contacts: companies that were grouped by email domain keep their current members and simply stop growing, and companies already grouped by external ID stay exactly as they are. Nothing is merged or re-grouped retroactively.

While a Space is in the default **Auto** mode, `company_external_id` is still accepted on events and stored — it's just ignored for grouping. If your events already carry the field while the Space is in Auto mode, the Company Tracking settings page shows a warning banner telling you so, so you don't spend time debugging a company grouping that was never turned on.

## The identifiers, contrasted

Three fields on [Create Event](/api-reference/endpoint/create-event) sound similar but do unrelated jobs. Mixing them up either merges contacts that shouldn't be merged, or fails to group the account at all.

| Field                 | Does                                                                                                                                                                                                        | Never                                                                                                           |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `tracking_id`         | Merges events into **one contact**. Use a stable per-user value.                                                                                                                                            | Never groups companies. Never send a shared account ID here — every teammate would merge into a single contact. |
| `company_external_id` | Groups contacts into a **company** by your own account ID.                                                                                                                                                  | Never merges contacts, and never takes part in contact matching.                                                |
| `idempotency_key`     | An exact-dedup key, unique **per event**. Required for high-frequency pipelines — without a distinct key per event, repeated events can collapse into each other under Cometly's same-contact dedup window. | —                                                                                                               |

`company_name` rides alongside `company_external_id` as a fourth field: it's a display-only name for the company, last write wins, and it's never used for matching — two companies with the same name stay separate. It's ignored unless `company_external_id` is also sent, since the name alone doesn't identify a company.

## Send it on every event

Send `company_external_id` on every event a member of the account triggers — sign-up, trial activation, purchase, whatever your funnel tracks — alongside a per-user `tracking_id`, a unique `idempotency_key`, and optionally `company_name`. Normalization rules for `company_external_id` and `company_name`: trimmed, case **preserved** (never lowercased), stored byte-exact, 1–190 characters (`company_external_id`) or 1–255 characters (`company_name`) after trimming, and integers are accepted. A blank string is treated as absent (`null`); floats, arrays, and booleans are rejected with a `422`.

A developer signs up with a personal address:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://app.cometly.com/public-api/v1/events/track \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Accept: application/json" \
    -H "Content-Type: application/json" \
    -d '{
      "event_name": "sign_up",
      "email": "dev@gmail.com",
      "tracking_id": "user_48213",
      "idempotency_key": "evt_signup_48213_20260909",
      "company_external_id": "acct_8f3k2",
      "company_name": "Example Co",
      "event_time": "2026-09-09T14:02:00Z"
    }'
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch('https://app.cometly.com/public-api/v1/events/track', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      event_name: 'sign_up',
      email: 'dev@gmail.com',
      tracking_id: 'user_48213',
      idempotency_key: 'evt_signup_48213_20260909',
      company_external_id: 'acct_8f3k2',
      company_name: 'Example Co',
      event_time: '2026-09-09T14:02:00Z'
    })
  });

  const data = await response.json();
  ```

  ```php PHP theme={null}
  <?php
  $url = 'https://app.cometly.com/public-api/v1/events/track';
  $headers = [
      'Authorization: Bearer YOUR_API_KEY',
      'Accept: application/json',
      'Content-Type: application/json'
  ];

  $data = [
      'event_name' => 'sign_up',
      'email' => 'dev@gmail.com',
      'tracking_id' => 'user_48213',
      'idempotency_key' => 'evt_signup_48213_20260909',
      'company_external_id' => 'acct_8f3k2',
      'company_name' => 'Example Co',
      'event_time' => '2026-09-09T14:02:00Z',
  ];

  $ch = curl_init();
  curl_setopt($ch, CURLOPT_URL, $url);
  curl_setopt($ch, CURLOPT_POST, true);
  curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

  $response = curl_exec($ch);
  curl_close($ch);
  ?>
  ```

  ```python Python theme={null}
  import requests

  url = 'https://app.cometly.com/public-api/v1/events/track'
  headers = {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Accept': 'application/json',
      'Content-Type': 'application/json'
  }

  data = {
      'event_name': 'sign_up',
      'email': 'dev@gmail.com',
      'tracking_id': 'user_48213',
      'idempotency_key': 'evt_signup_48213_20260909',
      'company_external_id': 'acct_8f3k2',
      'company_name': 'Example Co',
      'event_time': '2026-09-09T14:02:00Z',
  }

  response = requests.post(url, headers=headers, json=data)
  ```
</CodeGroup>

Weeks later, accounts payable pays the invoice from a different, business email address — same `company_external_id`, different contact:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://app.cometly.com/public-api/v1/events/track \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Accept: application/json" \
    -H "Content-Type: application/json" \
    -d '{
      "event_name": "purchase",
      "email": "ap@example.com",
      "tracking_id": "user_91004",
      "idempotency_key": "evt_purchase_ord-77213",
      "amount": 4999.00,
      "order_id": "ORD-77213",
      "company_external_id": "acct_8f3k2",
      "company_name": "Example Co",
      "event_time": "2026-09-25T09:15:00Z"
    }'
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch('https://app.cometly.com/public-api/v1/events/track', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      event_name: 'purchase',
      email: 'ap@example.com',
      tracking_id: 'user_91004',
      idempotency_key: 'evt_purchase_ord-77213',
      amount: 4999.00,
      order_id: 'ORD-77213',
      company_external_id: 'acct_8f3k2',
      company_name: 'Example Co',
      event_time: '2026-09-25T09:15:00Z'
    })
  });

  const data = await response.json();
  ```

  ```php PHP theme={null}
  <?php
  $url = 'https://app.cometly.com/public-api/v1/events/track';
  $headers = [
      'Authorization: Bearer YOUR_API_KEY',
      'Accept: application/json',
      'Content-Type: application/json'
  ];

  $data = [
      'event_name' => 'purchase',
      'email' => 'ap@example.com',
      'tracking_id' => 'user_91004',
      'idempotency_key' => 'evt_purchase_ord-77213',
      'amount' => 4999.00,
      'order_id' => 'ORD-77213',
      'company_external_id' => 'acct_8f3k2',
      'company_name' => 'Example Co',
      'event_time' => '2026-09-25T09:15:00Z',
  ];

  $ch = curl_init();
  curl_setopt($ch, CURLOPT_URL, $url);
  curl_setopt($ch, CURLOPT_POST, true);
  curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

  $response = curl_exec($ch);
  curl_close($ch);
  ?>
  ```

  ```python Python theme={null}
  import requests

  url = 'https://app.cometly.com/public-api/v1/events/track'
  headers = {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Accept': 'application/json',
      'Content-Type': 'application/json'
  }

  data = {
      'event_name': 'purchase',
      'email': 'ap@example.com',
      'tracking_id': 'user_91004',
      'idempotency_key': 'evt_purchase_ord-77213',
      'amount': 4999.00,
      'order_id': 'ORD-77213',
      'company_external_id': 'acct_8f3k2',
      'company_name': 'Example Co',
      'event_time': '2026-09-25T09:15:00Z',
  }

  response = requests.post(url, headers=headers, json=data)
  ```
</CodeGroup>

Both events carry the same `company_external_id`, so once External ID mode is on, both contacts land in the same company — even though they never share a `tracking_id`, an email domain, or a name.

## Tag logged-in visitors from your website

The API and webhooks cover what your backend knows. The browser knows something too: the developer who clicked the ad and is now browsing your app while logged in. If your website can tell who the visitor is, declare their account once on the page and the Cometly tracking script includes it in **every event it sends** — page views, form events, and anything you fire through `comet()` — with no per-event code.

```html theme={null}
<script>
  window.cometlySettings = {
    company_external_id: 'acct_8f3k2',
    company_name: 'Example Co'
  };
</script>
<!-- your Cometly tracking script tag -->
```

How it behaves:

* **Render it on every page where the visitor is logged in**, and leave it out when they are not. The script does not remember it between pages, so a shared computer never carries the previous user's account onto the next one.
* **Order does not matter.** Set the object before or after the script tag. The script reads it at the moment each event is sent, so a single-page app that finishes authenticating after the page loaded can assign `window.cometlySettings` at that point and the next event carries it.
* **Only these two keys are read.** Anything else in the object is ignored. The same normalization applies as on the API: trimmed, case preserved, integers accepted, blank treated as absent.
* **Direct values win.** If you pass `company_external_id` or `company_name` explicitly in `comet(event, data)`, that value is used for that event instead of the settings object.
* **External ID mode only.** Like every other way of sending the field, the object groups contacts only when the Space's company identity mode is **"Group by your account ID"**. In Auto mode it is accepted and ignored.

The first page view of a session usually fires before your app has rendered the object (the visitor is not logged in yet, or the SPA has not finished auth). That is fine: as soon as any later event from the same visitor carries the account, Cometly re-stamps that contact's earlier events, including the anonymous ad-click page view, onto the company.

<Note>
  Anything set in the browser can be changed by the visitor, from the developer console if nowhere else, exactly like an email typed into a form. Treat browser-supplied account IDs with the same trust as any other pixel data. For events that must be right, such as purchases, send the account ID from your server through the API or a webhook.
</Note>

## Send it via webhooks or Stripe metadata

If you're sending events through a [webhook](/concepts/sending-data-to-cometly) instead of the API directly, `Company External ID` and `Company Name` are available as mapping destinations in the webhook mapping step, alongside your other field mappings.

For Stripe specifically, your account ID typically travels in the object's metadata rather than as a top-level field. Map the source field `data.object.metadata.<your key>` (for example `data.object.metadata.account_id`) to the **Company External ID** destination, and optionally another metadata key to **Company Name**.

<Note>
  The source-field dropdown in the mapping step is only populated from the sample payloads Cometly received while you were setting up the webhook (**Check for Data**). If your metadata key wasn't present on any of those sample events, it won't show up as an option. Send a test event carrying the metadata key first, then re-run **Check for Data**, before mapping it.
</Note>

## Timing: send sign-up events in real time

Ad-platform conversions (CAPI) go out roughly 14 minutes after the conversion itself. For a teammate's ad click to be picked up when accounts payable's purchase is sent to Meta or Google, the developer's account membership (their `company_external_id`) needs to already be recorded by then. Send sign-up and membership events in real time as they happen, not batched into a nightly job — a sign-up event that arrives after the conversion it should inform has already been sent is too late to change how that conversion was attributed. Tagging logged-in visitors with `window.cometlySettings` (above) is the simplest way to get there: membership is recorded on the first page view after login, with no pipeline involved.

## Turn on ad-platform conversion identity = company

Company grouping alone doesn't change who a conversion is attributed to when it's sent to Meta, Google, and the other ad platforms — it only groups contacts for reporting. To also redirect the ad-platform conversion's identity to the right teammate, turn on the second Company Tracking setting: **Ad-platform conversion identity → "Send as the teammate who clicked the ad"** (`company` mode, on the same Company Tracking page).

With this on:

* A conversion is sent under the identity of the teammate in the same company whose ad click is the most recent before the conversion — their email, name, phone, and click IDs. It's always one person's identity, **never a blend** of two people.
* If no teammate in the company clicked an ad more recently, the conversion falls back to being sent as the person who actually converted.
* It never changes conversion facts: `amount`, `order_id`, and the conversion time are always the real ones.
* It requires company grouping — a contact with no company (in either identity mode) is always sent as themselves.

## Fix a grouping, or link a contact that never sends events

Some contacts never send an event carrying `company_external_id` at all — an inbound lead created via CRM sync, for instance. Two endpoints let you fix that without waiting for another event:

* [Update Company](/api-reference/endpoint/update-company) — set or change a company's `external_id` directly.
* [Attach Contact Company](/api-reference/endpoint/attach-contact-company) / [Detach Contact Company](/api-reference/endpoint/detach-contact-company) — move a contact onto a company, or off it entirely.

What happens after a manual attach or detach depends on the Space's company identity mode: in **Auto** mode, the tracker only assigns a company to a contact that currently has none, so a manual attach sticks (it isn't undone by a later event) and a manual detach is undone by the contact's next business-email event. In **External ID** mode, the next event carrying a `company_external_id` re-points the contact again either way.

## Read it back

* [Get Company](/api-reference/endpoint/get-company) and [List Companies](/api-reference/endpoint/list-companies) both return `external_id` alongside `domain` and `name`.
* The [Companies dataset](/data-warehouse/datasets/companies) in the data warehouse carries `external_id` as a column — join on an exact match against your own account IDs.
* If you're **enabling External ID mode on a Space that already has history**, run a one-time export over all history on the Companies dataset. Incremental exports are windowed on `created_at`, so a company that gains an `external_id` after its original creation window won't be re-exported by a recurring run.

## Reality note

Cometly groups companies by business-email domain (Auto mode) or by your own `company_external_id` (External ID mode) — it does not read association data from HubSpot, Salesforce, or any other CRM's company/account records. If you need Cometly's company grouping to match your CRM's account structure, send that CRM's account identifier as `company_external_id` on every event, rather than expecting CRM associations to be picked up automatically.
