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

# Post a conversion another system saw

> Records a fact the site cannot see itself — a reservation paid on a booking site, a lead that became a customer — as an event with source: external, and delivers it to the ad platforms the Conversion reports to (Google Ads, Google Analytics 4, Meta, TikTok server APIs).

```ts AdonisJS wrap theme={"dark"}
import app from '@adonisjs/core/services/app'
import { SiteClient } from '@fetchski/attract-cms-sdk'

const site = await app.container.make(SiteClient)
const createExternalConversionEvent = await site.conversionEvents().createExternalConversionEvent({ eventId: 'tenant-42:order:1234:purchase', conversion: 'booking', producer: { system: 'reservation' } })
```

```json 200 wrap theme={"dark"}
{
  "created": true,
  "event": {
    "eventId": "tenant-42:orders:1234:purchase",
    "conversion": {
      "id": "ckq1w2e3r4t5y6u7i8o9p0",
      "key": "booking",
      "title": "Booking"
    },
    "trigger": {
      "type": "external",
      "ref": "booking-1234"
    },
    "source": "external",
    "occurredAt": "2026-09-21T15:04:05.000Z",
    "recordedAt": "2026-09-21T15:04:05.412Z",
    "updatedAt": "2026-09-21T15:04:07.020Z",
    "page": {
      "url": "https://example.com/contact?gclid=Cj0KCQjw",
      "path": "/contact",
      "referrer": "https://www.google.com"
    },
    "visitorId": "0b8e6a4c-8d2f-4b6e-9a1c-3f5d7e9b1a2c",
    "attribution": {
      "firstTouch": {
        "at": "2026-09-20T09:12:00.000Z",
        "path": "/",
        "landingUrl": "https://example.com/?gclid=Cj0KCQjw",
        "referrerOrigin": "https://www.google.com",
        "adPlatforms": [
          "google"
        ],
        "campaign": {
          "utm_source": "google",
          "utm_campaign": "spring-sale"
        },
        "clickIds": {
          "gclid": "Cj0KCQjw"
        }
      },
      "adPlatforms": [
        "google"
      ],
      "campaign": {
        "utm_source": "google",
        "utm_campaign": "spring-sale"
      },
      "clickIds": {
        "gclid": "Cj0KCQjw"
      },
      "browserIds": {
        "_gcl_au": "1.1.1234567890.1790000000"
      },
      "provenance": {
        "gclid": "ourRecord",
        "_gcl_au": "platformCookie"
      }
    },
    "consent": {
      "categories": {
        "analytics": true,
        "marketing": true
      },
      "mode": "lenient",
      "country": "US",
      "gpc": false,
      "policyVersion": 7
    },
    "value": 3200,
    "currency": "USD",
    "userData": {
      "emailSha256": "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514",
      "phoneSha256": "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514",
      "phoneDigitsSha256": "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514"
    },
    "submissionId": "ck9submission1",
    "delivery": {
      "google-ads": {
        "browser": "sent",
        "server": {
          "status": "sent",
          "attempts": 1,
          "sentAt": "2026-09-21T15:04:07.001Z",
          "lastAttemptAt": "2026-09-21T15:04:07.001Z"
        }
      },
      "ga4": {
        "browser": "sent",
        "server": {
          "status": "n/a",
          "attempts": 0
        }
      }
    },
    "status": "active",
    "adjustments": [],
    "producer": {
      "system": "reservation",
      "ref": "booking-1234"
    },
    "join": {
      "by": "token"
    }
  }
}
```

## In your app

A controller answers a route on your app: it calls `createExternalConversionEvent()` and returns the answer.

**1. Add the controller**

```ts app/controllers/create_external_conversion_event_controller.ts theme={"dark"}
import { inject } from '@adonisjs/core'
import { SiteClient } from '@fetchski/attract-cms-sdk'

@inject()
export default class CreateExternalConversionEventController {
  constructor(protected site: SiteClient) {}

  async handle() {
    return this.site.conversionEvents().createExternalConversionEvent({ eventId: 'tenant-42:order:1234:purchase', conversion: 'booking', producer: { system: 'reservation' } })
  }
}
```

The other values are the example’s: take yours from the request, a form or your database.

**2. Add the route**

```ts start/routes.ts theme={"dark"}
import router from '@adonisjs/core/services/router'

const CreateExternalConversionEventController = () => import('#controllers/create_external_conversion_event_controller')

router.post('/conversion-event/external', [CreateExternalConversionEventController])
```

Put it behind your auth middleware (`.use(middleware.auth())`): it acts with your key’s access.

**3. Call it**

Your own page’s form or script sends `POST /conversion-event/external` and gets back its answer as JSON. Test it, below, calls it.

Google Analytics only accepts it within 72 hours of when it happened.

**Joining.** The CMS joins the fact to a visitor’s journey with `match`, strongest first (visitor ID, the `fk_at` token, `_gcl_au`, a click ID, hashed email / phone), and copies that visitor’s attribution onto the event. `join.by` in the answer says which matched. The producer’s own click IDs win over the visitor record’s; its browser IDs only fill gaps.

**Consent.** The producer’s `consent` flags govern what is sent about the fact (omitted flags are granted). A denial trims what is sent — no contact details or browser data, Google receives `CONSENT_DENIED` — but the conversion is always stored and delivered.

**Browser context.** Send the customer’s `client` (IP, user agent) and `page.url` as captured when they booked; with them the fact reaches Meta as a `website` event (de-duplicated against the pixel), without them as `system_generated`.

**Idempotent on `eventId`.** Posting the same fact again returns the stored event with `created: false`; the same ID with different content is a 409. Retry freely.

**Signed:** every API key must sign this request (`X-Fetchski-Timestamp` + `X-Fetchski-Signature`, see the headers below), whatever its own signing mode. Signed-in users in the CMS need no signature. [Signing requests](https://docs.fetchski-attract.com/signing.md) shows how, from the API and both SDKs.

> [accordion] Required parameters
>
> One object, e.g. `createExternalConversionEvent({ eventId })`; leave out the optional fields.
>
> **eventId** `string` *required*
>
> **Required.** Opaque, unique per site, stable. The CMS mints a UUID for events it originates; another system mints its own (e.g. `tenant-42:order:1234:purchase`). Forwarded unchanged to every ad platform as its de-duplication key. Matches `^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`. At most 128 characters.
>
> **conversion** `string` *required*
>
> **Required.** A Conversion’s id or key (`GET /conversion/definitions`); it must have a “Reported by another system” trigger. At most 128 characters.
>
> **producer** `ExternalConversionFactProducer` *required*
>
> **Required.** Your system: who is posting the fact.
>
> **ExternalConversionFactProducer**
>
> **system** `string` *required*
>
> **Required.** Your system’s name (lowercase, `a-z0-9._-`), the same on every post. Matches `^[a-z0-9][a-z0-9._-]*$`. At most 64 characters.
>
> **ref** `string`
>
> Your own reference for the fact (an order or booking number). At most 256 characters.

> [accordion] Optional parameters
>
> **occurredAt** `Date | string | null | undefined`
>
> **Optional.** When it happened (not in the future). Defaults to now. Format: an ISO 8601 date and time.
>
> **value** `number | null | undefined`
>
> **Optional.** Rounded to cents. Defaults to the Conversion’s value. At least 0.
>
> **currency** `string | null | undefined`
>
> **Optional.** Leave it out (or pass `null`) and it isn’t sent: the site’s default applies. ISO 4217 code of `value`, e.g. `USD`. Matches `^[A-Z]{3}$`.
>
> **match** `FactMatch | null | undefined`
>
> **Optional.** Leave it out (or pass `null`) and it isn’t sent: the site’s default applies. What the CMS may join the fact to a visitor with, tried strongest first: `visitorId`, `token` (fk\_at), `gclAu`, a click ID, then `emailSha256` / `phoneSha256`. Hashes also travel with the conversion to the platforms (see `UserDataHashes` for the normalization).
>
> **FactMatch**
>
> **visitorId** `string`
>
> The visitor id the site gave the customer’s browser (its `fetchski_vid` cookie), when you have it. At most 128 characters.
>
> **token** `string`
>
> The `fk_at` attribution token the visitor arrived with. At most 512 characters.
>
> **gclAu** `string`
>
> The `_gcl_au` cookie value.
>
> **gclid** `string`
>
> The Google Ads click id (`gclid`) the visit came with.
>
> **gbraid** `string`
>
> The Google Ads app click id (`gbraid`, iOS).
>
> **wbraid** `string`
>
> The Google Ads web-to-app click id (`wbraid`, iOS).
>
> **fbclid** `string`
>
> The Meta click id (`fbclid`) the visit came with.
>
> **ttclid** `string`
>
> The TikTok click id (`ttclid`) the visit came with.
>
> **fbp** `string`
>
> The `_fbp` cookie value.
>
> **fbc** `string`
>
> The `_fbc` cookie value.
>
> **ttp** `string`
>
> The `_ttp` cookie value.
>
> **emailSha256** `string`
>
> Lower-case hex SHA-256 digest. Matches `^[0-9a-f]{64}$`.
>
> **emailGoogleSha256** `string`
>
> Lower-case hex SHA-256 digest. Matches `^[0-9a-f]{64}$`.
>
> **phoneSha256** `string`
>
> Lower-case hex SHA-256 digest. Matches `^[0-9a-f]{64}$`.
>
> **phoneDigitsSha256** `string`
>
> Lower-case hex SHA-256 digest. Matches `^[0-9a-f]{64}$`.
>
> **consent** `FactConsent | null | undefined`
>
> **Optional.** The customer’s ad-data consent as the producer knows it. Each flag defaults to the US-friendly value when omitted. A denial trims what is sent (no contact details or browser data; Google receives CONSENT\_DENIED); the conversion itself is always stored and delivered.
>
> **FactConsent**
>
> **adUserData** `boolean`
>
> The customer allows their data to be sent to ad platforms.
>
> **adPersonalization** `boolean`
>
> The customer allows ads personalized for them.
>
> **limitedDataUse** `boolean`
>
> Meta LDU / TikTok limited data use (e.g. a US opt-out of sale).
>
> **client** `FactClient | null | undefined`
>
> **Optional.** Leave it out (or pass `null`) and it isn’t sent: the site’s default applies. The customer’s browser as captured WHEN THEY BOOKED (not the server confirming later). With a user agent and a page URL the fact reaches Meta as a `website` event; without, as `system_generated`. Kept only until delivery is finished, never returned.
>
> **FactClient**
>
> **ip** `string`
>
> IPv4 or IPv6.
>
> **userAgent** `string`
>
> The browser’s `User-Agent` header. At most 512 characters.
>
> **capturedAt** `Date`
>
> When the browser data was captured. Format: an ISO 8601 date and time.
>
> **page** `ExternalConversionFactPage | null | undefined`
>
> **Optional.** Leave it out (or pass `null`) and it isn’t sent: the site’s default applies. Where the customer was when it happened.
>
> **ExternalConversionFactPage**
>
> **url** `string`
>
> The page the purchase happened on (http/https). Format: an absolute URL. At most 2048 characters.

> [accordion] Returns
>
> A `CreateExternalConversionEventResponse`. The stored event. `created` is false when this event ID was already stored with the same content. Read its properties (`createExternalConversionEvent.created`): each says whether the site always sends it.
>
> **created** `boolean`
>
> **Always sent.**
>
> **event** `ConversionEvent`
>
> **Always sent.** A `ConversionEvent`. One conversion occurrence. Fields the visitor’s consent did not allow are ABSENT, not empty. Never returned: the visitor’s IP address and user agent, outbox lease and retry bookkeeping.
>
> **ConversionEvent**
>
> **eventId** `string`
>
> **Always sent.** Opaque, unique per site, stable. The CMS mints a UUID for events it originates; another system mints its own (e.g. `tenant-42:order:1234:purchase`). Forwarded unchanged to every ad platform as its de-duplication key. Matches `^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`. At most 128 characters.
>
> **conversion** `ConversionEventConversion`
>
> **Always sent.** A `ConversionEventConversion`. The Conversion it counts for.
>
> **ConversionEventConversion**
>
> **id** `string`
>
> **Always sent.** The Conversion’s id.
>
> **key** `string`
>
> **Always sent.** The Conversion’s key when the event was recorded.
>
> **title** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. The Conversion’s name in the CMS.
>
> **source** `ConversionEventSource`
>
> **Always sent.** How it was recorded: `browser` (the site’s tag), `external` (posted through the Conversion Data API), or `server` (the site’s own server; nothing records one yet). One of `browser`, `server`, `external`; a newer site may send others, so keep a fallback.
>
> **occurredAt** `Date`
>
> **Always sent.** When it happened. Format: an ISO 8601 date and time.
>
> **updatedAt** `Date`
>
> **Always sent.** Last change (delivery outcome, adjustment) — the sync order. Format: an ISO 8601 date and time.
>
> **delivery** `Readonly\<Record\<string, DeliveryState\>\>`
>
> **Always sent.** A map of names to `DeliveryState`. Per destination the Conversion reports to.
>
> **DeliveryState**
>
> **browser** `Browser`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Whether the page fired it at this platform’s tag; `n/a` when the Conversion sends to this platform from the server only. One of `sent`, `not-sent`, `n/a`; a newer site may send others, so keep a fallback.
>
> **server** `ServerDeliveryState`
>
> **Sometimes sent:** `undefined` when the site leaves it out. A `ServerDeliveryState`. Server-side delivery to this platform’s API (Google Data Manager, GA4 Measurement Protocol, Meta Conversions API, TikTok Events API).
>
> **ServerDeliveryState**
>
> **status** `ServerDeliveryStateStatus`
>
> **Always sent.** `queued`/`retry` — owed; `sent` — delivered (`test: true` in a platform test mode); `failed` — attempts used up or a permanent error; `not-configured` — credentials, pixel, Google tag or conversion action missing; `expired` — older than the platform accepts (Google Ads 90 days, Meta and TikTok 7, Google Analytics 3); `not-sent` — retracted first, or nothing the platform can match; `no-consent` — the visitor did not allow Marketing; `n/a` — the platform has no server API here. One of `queued`, `retry`, `sent`, `failed`, `not-configured`, `expired`, `not-sent`, `no-consent`, `n/a`; a newer site may send others, so keep a fallback.
>
> **attempts** `number`
>
> **Always sent.** Sends tried so far. At least 0.
>
> **sentAt** `Date`
>
> **Sometimes sent:** `undefined` when the site leaves it out. When the platform took it; absent until then. Format: an ISO 8601 date and time.
>
> **lastAttemptAt** `Date`
>
> **Sometimes sent:** `undefined` when the site leaves it out. The last try; absent before the first. Format: an ISO 8601 date and time.
>
> **nextAttemptAt** `Date | null`
>
> **Sometimes sent:** `undefined` when the site leaves it out, and it can be `null`. Set while `status` is `retry`; null otherwise. Format: an ISO 8601 date and time.
>
> **lastError** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Why the last try failed, as the platform said. At most 500 characters.
>
> **detail** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Why it wasn’t sent, for a status it ended in without trying (e.g. “The event was retracted.”). At most 500 characters.
>
> **test** `boolean`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Present (true) only when it went to the platform’s test mode. Always `true`.
>
> **adjustment** `AdjustmentDeliveryState`
>
> **Sometimes sent:** `undefined` when the site leaves it out. An `AdjustmentDeliveryState`. Telling this platform about the latest adjustment, once it had already received the event. Google Ads: the event re-sent with its new value (a retraction is sent as 0 — Google still counts the conversion). Google Analytics: a `refund`, for purchases only and only to lower the value. Meta, TikTok, Google Tag Manager and browser-only rows cannot be told (`unsupported`, with what to do instead in `detail`).
>
> **AdjustmentDeliveryState**
>
> **adjustmentId** `string`
>
> **Always sent.** Opaque, unique per site, stable. The CMS mints a UUID for events it originates; another system mints its own (e.g. `tenant-42:order:1234:purchase`). Forwarded unchanged to every ad platform as its de-duplication key. Matches `^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`. At most 128 characters.
>
> **action** `Action`
>
> **Always sent.** `retract` (withdraw the conversion) or `restate` (a new value). One of `retract`, `restate`; a newer site may send others, so keep a fallback.
>
> **status** `AdjustmentDeliveryStateStatus`
>
> **Always sent.** `queued`/`retry` — owed; `sent` — delivered (`test: true` in a platform test mode); `failed` — attempts used up or a permanent error; `not-configured` — credentials or the live Google tag missing; `unsupported` — the platform cannot be told (`detail` says why and what to do instead); `not-needed` — the platform already holds this value. One of `queued`, `retry`, `sent`, `failed`, `not-configured`, `unsupported`, `not-needed`; a newer site may send others, so keep a fallback.
>
> **attempts** `number`
>
> **Always sent.** Sends tried so far. At least 0.
>
> **value** `number`
>
> **Sometimes sent:** `undefined` when the site leaves it out. The value the platform holds once it is sent. At least 0.
>
> **refund** `number`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Google Analytics: the amount refunded. At least 0.
>
> **currency** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. ISO 4217 code, e.g. `USD`. Matches `^[A-Z]{3}$`.
>
> **sentAt** `Date`
>
> **Sometimes sent:** `undefined` when the site leaves it out. When the platform took it; absent until then. Format: an ISO 8601 date and time.
>
> **lastAttemptAt** `Date`
>
> **Sometimes sent:** `undefined` when the site leaves it out. The last try; absent before the first. Format: an ISO 8601 date and time.
>
> **nextAttemptAt** `Date | null`
>
> **Sometimes sent:** `undefined` when the site leaves it out, and it can be `null`. Set while `status` is `retry`; null otherwise. Format: an ISO 8601 date and time.
>
> **lastError** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Why the last try failed, as the platform said. At most 500 characters.
>
> **detail** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Why it wasn’t sent, for a status it ended in without trying (e.g. “The event was retracted.”). At most 500 characters.
>
> **test** `boolean`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Present (true) only when it went to the platform’s test mode. Always `true`.
>
> **status** `Status`
>
> **Always sent.** `active`; `retracted` (withdrawn by an adjustment) or `adjusted` (its value restated). One of `active`, `retracted`, `adjusted`; a newer site may send others, so keep a fallback.
>
> **adjustments** `readonly Adjustment[]`
>
> **Always sent.** A list of `Adjustment`. Every adjustment, oldest first (empty when none).
>
> **Adjustment**
>
> **adjustmentId** `string`
>
> **Always sent.** Opaque, unique per site, stable. The CMS mints a UUID for events it originates; another system mints its own (e.g. `tenant-42:order:1234:purchase`). Forwarded unchanged to every ad platform as its de-duplication key. Matches `^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`. At most 128 characters.
>
> **action** `Action`
>
> **Always sent.** `retract` (withdraw the conversion) or `restate` (a new value). One of `retract`, `restate`; a newer site may send others, so keep a fallback.
>
> **recordedAt** `Date`
>
> **Always sent.** When the site stored it. Format: an ISO 8601 date and time.
>
> **value** `number`
>
> **Sometimes sent:** `undefined` when the site leaves it out. The restated value (restate only). At least 0.
>
> **currency** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. ISO 4217 code of `value`. Matches `^[A-Z]{3}$`.
>
> **reason** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Why, as the writer gave it. At most 500 characters.
>
> **occurredAt** `Date`
>
> **Sometimes sent:** `undefined` when the site leaves it out. When the change happened (as the writer said). Format: an ISO 8601 date and time.
>
> **recordedBy** `RecordedBy`
>
> **Sometimes sent:** `undefined` when the site leaves it out. A `RecordedBy`. Who wrote it: an API key (its public key ID and label) or an administrator.
>
> **RecordedBy**
>
> **type** `RecordedByType`
>
> **Always sent.** An API key or a signed-in person. One of `apiKey`, `user`; a newer site may send others, so keep a fallback.
>
> **label** `string`
>
> **Always sent.** The key’s label, or the person’s name.
>
> **keyId** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. An API key: its public key id.
>
> **id** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. A person: their user `_id`.
>
> **trigger** `ConversionEventTrigger`
>
> **Sometimes sent:** `undefined` when the site leaves it out. A `ConversionEventTrigger`. What fired it.
>
> **ConversionEventTrigger**
>
> **type** `ConversionEventTriggerType`
>
> **Sometimes sent:** `undefined` when the site leaves it out. A form submitted, a page viewed, a link clicked, a chat event, or an `external` fact. One of `form`, `pageView`, `linkClick`, `chat`, `external`; a newer site may send others, so keep a fallback.
>
> **ref** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. What exactly: the form’s `aposDocId` (`form`), the page path (`pageView`), the link rule that matched or `outbound:<host>` / `download:<type>` (`linkClick`), the chat event (`chat`), or the producer’s reference (`external`). At most 256 characters.
>
> **recordedAt** `Date`
>
> **Sometimes sent:** `undefined` when the site leaves it out. When the site stored it. Format: an ISO 8601 date and time.
>
> **page** `ConversionEventPageInfo`
>
> **Sometimes sent:** `undefined` when the site leaves it out. A `ConversionEventPageInfo`. The page it happened on.
>
> **ConversionEventPageInfo**
>
> **url** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Analytics or Marketing only. Format: an absolute URL.
>
> **path** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Its path.
>
> **referrer** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Origin only.
>
> **visitorId** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Analytics or Marketing only.
>
> **attribution** `EventAttribution`
>
> **Sometimes sent:** `undefined` when the site leaves it out. An `EventAttribution`. The attribution snapshot the event copied when it happened — independent of the visitor record’s later changes or expiry.
>
> **EventAttribution**
>
> **adPlatforms** `readonly string[]`
>
> **Always sent.** The ad platforms whose click ids the visits carried (empty when none).
>
> **campaign** `Readonly\<Record\<string, string\>\>`
>
> **Always sent.** The campaign parameters in force (`utm_*`, …); empty when none.
>
> **firstTouch** `AttributionTouch | null`
>
> **Sometimes sent:** `undefined` when the site leaves it out, and it can be `null`. An `AttributionTouch`. The visitor’s first visit on record; null when unknown.
>
> **AttributionTouch**
>
> **at** `Date`
>
> **Sometimes sent:** `undefined` when the site leaves it out. When the page view happened. Format: an ISO 8601 date and time.
>
> **path** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. The page’s path.
>
> **landingUrl** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. The full landing URL. Without Marketing consent its identifier parameters are removed. Format: an absolute URL.
>
> **referrerOrigin** `string | null`
>
> **Sometimes sent:** `undefined` when the site leaves it out, and it can be `null`. Where the visitor came from; null for a direct visit.
>
> **adPlatforms** `readonly AdPlatforms[]`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Platforms whose ad click ID the URL carried — kept for every visitor, consent or not. Each one of `google`, `meta`, `tiktok`, `other`.
>
> **campaign** `Readonly\<Record\<string, string\>\>`
>
> **Sometimes sent:** `undefined` when the site leaves it out. UTM and campaign parameters (`utm_*`, `gad_source`, `gad_campaignid`, `srsltid`).
>
> **clickIds** `Readonly\<Record\<string, string\>\>`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Ad click IDs (`gclid`, `gbraid`, `wbraid`, `fbclid`, `ttclid`). Marketing only.
>
> **linker** `Readonly\<Record\<string, string\>\>`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Google’s cross-domain linker value (`_gl`). Marketing only.
>
> **lastTouch** `AttributionTouch | null`
>
> **Sometimes sent:** `undefined` when the site leaves it out, and it can be `null`. An `AttributionTouch`. Their last visit with an ad click or campaign parameters; null when none. Its fields are listed under the first `AttributionTouch` above.
>
> **clickIds** `Readonly\<Record\<string, string\>\>`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Marketing only.
>
> **browserIds** `Readonly\<Record\<string, string\>\>`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Ad-platform browser IDs (`_gcl_au` — the cross-site key — `_gcl_aw`, `_gcl_gb`, `_gcl_gs`, `_gcl_dc`, `_ga`, `_fbp`, `_fbc`, `_ttp`). Marketing only.
>
> **gaSessions** `Readonly\<Record\<string, string\>\>`
>
> **Sometimes sent:** `undefined` when the site leaves it out. The visitor’s Google Analytics session per data stream when it happened (from the `_ga_<container>` cookies), keyed by the measurement ID without its `G-`. The GA4 server sender joins the event to that session. Marketing only.
>
> **provenance** `Readonly\<Record\<string, Provenance\>\>`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Where each identifier came from: the visitor’s attribution record, the page URL, the platform’s own cookie, or the system that posted an external fact.
>
> **consent** `ConsentSnapshot`
>
> **Sometimes sent:** `undefined` when the site leaves it out. A `ConsentSnapshot`. The consent in force when the event happened. For an external fact (`origin: producer`) the posting system’s flags are recorded as `adUserData` / `adPersonalization` / `gpc` (limited data use).
>
> **ConsentSnapshot**
>
> **categories** `ConsentSnapshotCategories`
>
> **Always sent.** A `ConsentSnapshotCategories`. What the visitor allowed.
>
> **ConsentSnapshotCategories**
>
> **analytics** `boolean`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Analytics cookies and measurement.
>
> **marketing** `boolean`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Marketing: ad platforms and their identifiers.
>
> **mode** `ConsentSnapshotMode`
>
> **Always sent.** The rule that applied to the visitor: `lenient` (a notice-only region: non-essential ran unless they declined) or `strict` (an opt-in region: nothing non-essential until they agreed). One of `lenient`, `strict`; a newer site may send others, so keep a fallback.
>
> **gpc** `boolean`
>
> **Always sent.** Global Privacy Control, or the producer’s limited-data-use flag.
>
> **country** `string | null`
>
> **Sometimes sent:** `undefined` when the site leaves it out, and it can be `null`. The visitor’s two-letter country, when known; null otherwise. Matches `^[A-Z]{2}$`.
>
> **policyVersion** `number | null`
>
> **Sometimes sent:** `undefined` when the site leaves it out, and it can be `null`. The consent policy version the visitor answered; null when none.
>
> **adUserData** `boolean`
>
> **Sometimes sent:** `undefined` when the site leaves it out. An external fact: the producer allowed sending user data to ad platforms.
>
> **adPersonalization** `boolean`
>
> **Sometimes sent:** `undefined` when the site leaves it out. An external fact: the producer allowed ad personalization.
>
> **origin** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Present (`producer`) when the consent came with an external fact. Always `producer`.
>
> **value** `number`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Its value, when the Conversion has one. At least 0.
>
> **adjustedValue** `number`
>
> **Sometimes sent:** `undefined` when the site leaves it out. The latest restated value. At least 0.
>
> **currency** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. ISO 4217 code of `value`, e.g. `USD`. Matches `^[A-Z]{3}$`.
>
> **userData** `UserDataHashes`
>
> **Sometimes sent:** `undefined` when the site leaves it out. An `UserDataHashes`. Hashed contact details. Email: trimmed + lower-cased; `emailGoogleSha256` only for a Gmail address with dots (Google drops them). Phone: E.164 — `phoneSha256` with the "+", `phoneDigitsSha256` without (Meta’s form).
>
> **UserDataHashes**
>
> **emailSha256** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Lower-case hex SHA-256 digest. Matches `^[0-9a-f]{64}$`.
>
> **emailGoogleSha256** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Lower-case hex SHA-256 digest. Matches `^[0-9a-f]{64}$`.
>
> **phoneSha256** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Lower-case hex SHA-256 digest. Matches `^[0-9a-f]{64}$`.
>
> **phoneDigitsSha256** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Lower-case hex SHA-256 digest. Matches `^[0-9a-f]{64}$`.
>
> **submissionId** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. The form submission a form conversion came from.
>
> **producer** `Producer`
>
> **Sometimes sent:** `undefined` when the site leaves it out. A `Producer`. The system that posted an external fact.
>
> **Producer**
>
> **system** `string`
>
> **Always sent.** The system’s name, as it posted it (lowercase, `a-z0-9._-`, at most 64).
>
> **contentHash** `string`
>
> **Always sent.** Fingerprint of the posted fact (idempotency).
>
> **recordedBy** `RecordedBy`
>
> **Always sent.** A `RecordedBy`. Who wrote it: an API key (its public key ID and label) or an administrator. Its fields are listed under the first `RecordedBy` above.
>
> **ref** `string`
>
> **Sometimes sent:** `undefined` when the site leaves it out. Its own reference for the fact (at most 256 characters), when it gave one.
>
> **join** `ConversionEventJoin`
>
> **Sometimes sent:** `undefined` when the site leaves it out. A `ConversionEventJoin`. How an external fact was joined to a visitor (`by: null` — no match).
>
> **ConversionEventJoin**
>
> **by** `By | null`
>
> **Sometimes sent:** `undefined` when the site leaves it out, and it can be `null`. The identifier that matched, or null for none. One of `visitorId`, `token`, `gclAu`, `gclid`, `gbraid`, `wbraid`, `fbclid`, `ttclid`, `emailSha256`, `phoneSha256`; a newer site may send others, so keep a fallback.

> [accordion] Errors
>
> Nothing to catch: the SDK retries what is safe, and throws the rest as the classes below. Your exception handler turns them into your app’s own answers, once for every call (see [Map SDK errors to responses](https://docs.fetchski-attract.com/adonisjs/calling-the-api.md#map-sdk-errors-to-responses)); catch only what you want to react to here.
>
> **ValidationFailed** `400`
>
> The request is not valid. `data.errors` lists each problem with the `path` of the parameter or body field it concerns.
>
> **Retried first:** No. **Read:** `errors` (field → messages), `siteMessage`.
>
> **AuthenticationFailed** `401`
>
> The API key is missing, malformed, unknown, disabled, expired or archived (`invalid`), or a request that must be signed — by the route, or by the key’s own signing mode — has a missing, stale or wrong signature (an upload signs its SHA-256, declared in `X-Fetchski-Content-SHA256`, in the body’s place) (`unauthorized`).
>
> **Retried first:** No. **Read:** `siteMessage`, `errorName`, `data`.
>
> **Forbidden** `403`
>
> The API key lacks the scope this route needs (the message names it), or the key is used from an address outside its allowlist.
>
> **Retried first:** No. **Read:** `siteMessage`, `errorName`, `data`.
>
> **NotFound** `404`
>
> Nothing with this identifier — or the caller may not use the Data API at all (anonymous visitors and non-admin sessions get 404 so the API is not advertised). A write while conversion tracking is switched off also answers 404.
>
> **Retried first:** No. **Read:** `siteMessage`, `errorName`, `data`.
>
> **Conflict** `409`
>
> The identifier was already used with different content (an event ID re-posted with a changed fact, an adjustment ID re-used), or the event is retracted.
>
> **Retried first:** No. **Read:** `siteMessage`, `errorName`, `data`.
>
> **RateLimited** `429`
>
> The key used up its reads- or writes-per-minute limit (set per key; defaults 600 / 120). Still, after the client’s own waits and retries.
>
> **Retried first:** Yes: the client waits `Retry-After` (1 s when absent; over 60 s it gives up at once) and retries, up to `retries` times (2 by default). **Read:** `retryAfter` (seconds, or null), `siteMessage`.
>
> **SiteError** `5xx`
>
> The site failed (or something in front of it did). Its logs say why.
>
> **Retried first:** No: a write may have landed. **Read:** `siteMessage`, `errorName`, `data`.
>
> **SiteUnreachable** `no answer`
>
> No answer: DNS, a refused connection, TLS, or the timeout (15 s per attempt by default).
>
> **Retried first:** Only when nothing reached the site (`neverConnected()`); never after a timeout, when it may have landed. **Read:** `reason`, `neverConnected`.
>
> **RequestNotSent** `not sent`
>
> The client refused to send it (e.g. a path segment of `..`); the site never saw it.
>
> **Retried first:** No. **Read:** `reason` (what to change).
>
> **SiteRequestFailed** `any other`
>
> Any other status, or a success whose JSON doesn’t match this SDK version’s types (update the SDK).
>
> **Retried first:** No. **Read:** `reason`, `status`.
>
> **UnknownSite** `setup`
>
> `sites.forSite('acme')` named a slug `config/attract.ts` doesn’t list (with the keys in the database: no row in `attract_api_keys`).
>
> **Retried first:** No: nothing was sent. **Read:** `message`, `slug`.
>
> **SiteConfigurationError** `setup`
>
> The site’s entry is wrong: a malformed `baseUrl` or key, a key filed under another slug, or no default site. `node ace attract:check` names the fix.
>
> **Retried first:** No: nothing was sent. **Read:** `message` (what to fix).

## Behavior

* **Access:** an API key with `conversions:write` — or its group master `ads:all`, or `admin:all` — or a signed-in user holding the `conversion-events:write` permission. Anonymous callers get 404.
* **Rate limit:** the key’s own writes-per-minute limit, set when the key is created (default 120); over it, 429 with `Retry-After`. The client waits and retries for you; you see `RateLimited` only if that runs out.
* **Retries:** a write is sent again only after a 429 (refused before it acted) or when it never reached the site. After a timeout or a 5xx it may have landed: check before you send it again.
* **Timeout:** 15 s per attempt by default; raise `timeout` (milliseconds) in `config/attract.ts` for longer.

## Test it

It calls the controller above with the SDK faked: `fakeAttract()` puts the fake in the container for this one test, and it answers every call with a valid example, so nothing leaves your machine and no key is read. Save it as `tests/unit/conversion_events_create_external_conversion_event.spec.ts`, then run your tests:

```ts tests/unit/conversion_events_create_external_conversion_event.spec.ts theme={"dark"}
import { test } from '@japa/runner'
import app from '@adonisjs/core/services/app'
import { fakeAttract } from '@fetchski/attract-cms-adonis/testing'
import CreateExternalConversionEventController from '#controllers/create_external_conversion_event_controller'

test('conversionEvents.createExternalConversionEvent', async () => {
  const site = fakeAttract()

  const controller = await app.container.make(CreateExternalConversionEventController)
  await controller.handle()

  site.assertCalled('conversionEvents.createExternalConversionEvent')
})
```

```bash Terminal theme={"dark"}
node ace test
```

**Answer with your own values:** fake `conversionEvents.createExternalConversionEvent` with `factories.CreateExternalConversionEventResponse({...})`, the fields you give over a valid example (nested ones too).

```ts tests/unit/conversion_events_create_external_conversion_event_answers.spec.ts theme={"dark"}
import { test } from '@japa/runner'
import { factories, fakeAttract } from '@fetchski/attract-cms-adonis/testing'

test('conversionEvents.createExternalConversionEvent answers your values', async ({ assert }) => {
  const site = fakeAttract({
    'conversionEvents.createExternalConversionEvent': factories.CreateExternalConversionEventResponse({ event: { eventId: 'Your value' } }),
  })

  const createExternalConversionEvent = await site.conversionEvents().createExternalConversionEvent({ eventId: 'tenant-42:order:1234:purchase', conversion: 'booking', producer: { system: 'reservation' } })
  assert.containSubset(createExternalConversionEvent, { event: { eventId: 'Your value' } })
})
```

## Related

> [card] [List conversion events (incremental sync)](https://docs.fetchski-attract.com/adonisjs/reference/conversion-events/list-conversion-events.md)
>
> `site.conversionEvents().listConversionEvents()`

> [card] [Get one conversion event](https://docs.fetchski-attract.com/adonisjs/reference/conversion-events/get-conversion-event.md)
>
> `site.conversionEvents().getConversionEvent()`

> [card] [Retract or restate a conversion event](https://docs.fetchski-attract.com/adonisjs/reference/conversion-events/adjust-conversion-event.md)
>
> `site.conversionEvents().adjustConversionEvent()`

> [card] [POST /conversion-event/external](https://docs.fetchski-attract.com/api-reference/conversion-events/post-a-conversion-another-system-saw.md)
>
> The endpoint this calls, in the API reference: the HTTP request and answer, and every rule the site checks.


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