---
title: Custom events
slug: custom-events
docTags: 
createdAt: 2026-06-24T22:05:23.664Z
---

A **custom event** is any event Chord receives that is not part of the Chord
tracking plan — either an event whose name is not one of the canonical events
(`Order Completed`, `Product Viewed`, `Checkout Started`, `Product Added`, …)
or a canonical event carrying non-standard properties.

This document explains what happens to those events: how they are processed,
what transformation (if any) is applied, and which destinations forward them.

# The short answer

**Chord does not validate events against the tracking plan, and it does not**
**reject, quarantine, or strip events for being non-compliant.** Every event that
is ingested flows through the same pipeline. Custom events are processed
**as-is**, with only the minimal, optional transformations described below, and
are forwarded to destinations.

What a custom event ultimately looks like at a destination depends entirely on
that destination, because each destination decides how to map an event to its
own schema. Some forward custom events verbatim; some only act on a fixed set of
canonical events and silently ignore everything else.

:::BlockQuote
**The tracking plan is a convention, not a schema validator.** It defines a
set of canonical event names and snake\_case field names (`email`, `phone`,
`first_name`, `address1`, `zipcode`, …). Destination code uses these canonical
names as *fallback lookups* when extracting properties. There is no component
anywhere in ingest, Rotor, or the destination functions that checks an
incoming event against the plan and blocks it for failing to match.
:::

# How an event is processed

Every event — canonical or custom — travels the same path:

```javascript
Chord SDK / S2S  →  Ingest  →  Kafka  →  Rotor (function chain)  →  Destination
```

Inside Rotor, the function chain applies a series of transformations. **None of**
**these steps inspect whether an event complies with the tracking plan.** They
treat a custom event exactly as they treat a canonical one:

| Step                     | What it does                                                          | Applies to custom events?                                                        |
| ------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| **Geo enrichment**       | Resolves `context.ip` to `context.geo` (browser events only)          | Yes, identically                                                                 |
| **User recognition**     | Merges anonymous events with known identity (if configured)           | Yes, identically                                                                 |
| **Connection filters**   | Drop/allow rules matching on event type, name, or property values     | Yes — *can* drop a custom event if a rule names it, but not because it is custom |
| **Property mappings**    | Customer-defined field remapping (`@jitsu/mapping-engine`)            | Yes, identically                                                                 |
| **Consent filtering**    | Drops events lacking required consent categories                      | Yes, identically                                                                 |
| **Deduplication**        | Skips repeat messages within a Redis-backed window (if enabled)       | Yes, identically                                                                 |
| **UDFs**                 | Custom JavaScript transforms that can modify, drop, or fan-out events | Yes, identically                                                                 |
| **Destination function** | Maps the event to the destination's API/schema                        | **Destination-specific — see below**                                             |

The only places an event is ever dropped are: a connection filter rule, consent
enforcement, a UDF returning `null`/`"drop"`, deduplication, or a destination
deliberately not firing. A custom event is **never** dropped merely for being
absent from the tracking plan.

# How destinations handle custom events

This is where behavior diverges. There are two delivery modes, and within each,
destinations fall into a few distinct patterns.

## Cloud-mode destinations (server-side)

These run as functions in Rotor. The dominant pattern is **passthrough**: the
event name and properties are forwarded with little or no transformation, so
custom events arrive intact.

| Pattern                          | Behavior for custom events                                                                                                              | Examples                                                                                                                          |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **Passthrough**                  | Event forwarded as-is; the event name is used directly                                                                                  | Webhook, Segment, MongoDB, Data Warehouse / Bulker, June, Intercom, Heap, Mixpanel, PostHog                                       |
| **Canonical mapping + fallback** | Canonical events are translated to the destination's standard event; unmapped (custom) events still go through via a `default` fallback | GA4 (custom name is handelized/kebab-cased, standard props filtered), Facebook Conversions (custom name passed through unchanged) |
| **Configurable mapping**         | The destination acts only on events the customer has explicitly mapped                                                                  | Amplitude (supports a `$drop` mapping), Braze (purchase-event list, otherwise generic track)                                      |
| **Identity / fixed-purpose**     | Built around `identify`/`group` or a small set of events; track-style custom events get generic treatment or none                       | Klaviyo (special-cases `Order Completed`, all other tracks become generic metrics), HubSpot, Salesforce                           |

Data warehouse destinations are the most permissive: a custom track event simply
gets its own table named after the event (e.g. `my_custom_event`), or its raw
document inserted (MongoDB).

:::BlockQuote
Even canonical-mapping destinations like GA4 and Facebook do **not** drop
custom events — they fall through to a default branch that still sends the
event under its own (optionally normalized) name.
:::

## Device-mode destinations (client-side)

Device-mode plugins (`libs/jitsu-js/src/destination-plugins/`) fire directly
from the browser via each vendor's pixel/SDK. Per the forwarding policy above,
every plugin handles the unrecognized-event case explicitly — it either forwards
the event or logs an explicit skip.

| Pattern                            | Behavior for custom events                                                                                                                | Examples                                                                                                                                                                                                                                                       |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Passthrough**                    | An unrecognized event name is forwarded to the vendor SDK under its own name (or as a generic "custom event")                             | Facebook Pixel (`trackSingleCustom`), TikTok Pixel, Pinterest Tag, Insider Pixel, Bing Ads, StackAdapt Pixel, Dotdigital Tag (`event()`), Google Tag Manager (pushed to the Data Layer as `event: "<Event Name>"`; triggers and tags decide what happens next) |
| **Fixed-taxonomy (explicit skip)** | Only the platform's recognized events fire; unrecognized events are skipped with a `console.warn`                                         | Snap Pixel, Northbeam Pixel, Reddit Pixel, Retention.com, Postie, Spotify Ad Analytics Pixel, OpenAI Ads Pixel and Conversions API, Nosto Tag and Nosto                                                                                                        |
| **Config-driven**                  | Fires only for events the customer mapped in the plugin's configuration; everything else (including unmapped canonical events) is skipped | Twitter Ads (`eventMapping`), Google Ads (`clickConversions` / `pageLoadConversions`)                                                                                                                                                                          |

:::BlockQuote
**If you need to send high-cardinality or custom-named events to an ad
platform, choose a passthrough plugin.** Fixed-taxonomy plugins (Snap, Reddit, Spotify,
etc.) are scoped to the conversion events the vendor's backend recognizes; they
will skip anything outside that set and log a warning so the skip is visible.
:::

## Custom property mappings

Custom *property* mappings are a separate mechanism from custom event names.
When a customer maps a property in Hub (e.g. `properties.customer_type →`
`customer_type`), the dispatcher writes it as a top-level key on the event. A
plugin only carries it into the outgoing payload if the plugin spreads
`getCustomMappedProperties` / `getCustomDataProperties` — in which case the
custom property reaches the destination even on a fixed event set.

**Not every plugin does this.** Plugins that build a hardcoded payload from
formatters (`formatOrderData`, etc.) without that spread silently drop
custom-mapped properties. Today the plugins that forward them are
`facebook-pixel`, `snap-pixel`, `tiktok-pixel`, `pinterest-tag`, `twitter-ads`,
`bing-ads`, `google-ads`, `reddit-pixel`, and `dotdigital-tag` (on its custom-event
call only — see below); fixed-taxonomy plugins such as
`spotify-ad-analytics-pixel`, `postie`, `retention`, `northbeam-pixel`,
`sfmc-collect`, `thetradedesk-pixel`, and `yahoo-pixel` do not. (PII fields like
email and phone are excluded from this passthrough regardless.)

**Forwarding is not the same as being used.** `reddit-pixel` forwards custom
properties and Reddit accepts them without error, but Reddit publishes a fixed
metadata list (`conversion_id`, `currency`, `item_count`, `value`, `products`)
with no general-purpose container, and does not document what becomes of anything
else. So the property reaches the vendor and may still be inert. Where a vendor
neither documents a passthrough field nor rejects unknown keys, say so in the
destination doc rather than implying the mapping works — an operator who builds
reporting on a silently-discarded property is worse off than one who was told
nothing.

**A closed vendor schema can make forwarding impossible, not merely absent.**
`openai-ads-pixel` is the first plugin in this category. OpenAI validates the
event against a fixed schema — the conversion data accepts only `type`,
`amount`, `currency`, `contents` and `plan_id`, and there is no `custom_data`
slot like Meta's or TikTok's. An unknown key is not ignored: the SDK logs
`validation failed; event dropped` and the conversion never leaves the browser.

So for that destination the convention inverts. It exists to stop custom
properties being silently lost; here, forwarding them silently loses the entire
event, which is strictly worse. The plugin therefore sends none, and its
`custom-property-mapping.test.ts` asserts the *exclusion* so the exception reads
as a decision rather than an oversight.

**A closed vendor API can make forwarding impossible, not merely absent.**
`nosto-tag` is the second plugin in this category and the clearer case: Nosto's
browser API has no property bag *at all*. Each method takes a fixed signature, and
its cart, customer and order objects are closed shapes — there is no `custom_data`
like Meta's, no `properties` like TikTok's, and no key Nosto would read. A custom
property has nowhere to go on either Nosto surface, so both skip it and both public
docs say so rather than leaving an operator to discover it.

**A vendor can have an extensibility field on some calls and none on others.**
`dotdigital-tag` is the case that does not fit either bucket cleanly. Dotdigital's `event()`
takes an arbitrary attribute bag, so custom properties are forwarded there — but every other
method it exposes (`identify`, `productBrowse`, `productList`, `cartUpdate`, `checkout`,
`purchaseComplete`) takes a fixed signature with no property bag, so they get none. The plugin
therefore spreads on exactly one call, and its `custom-property-mapping.test.ts` asserts both
halves: that mapped properties arrive on the custom-event path, and that they cannot appear on
a commerce payload where Dotdigital defines the field names itself. The public doc states the
split, because an operator configuring a mapping needs to know which events it reaches.

Before adding the usual spread to a new plugin, check whether the vendor has an
extensibility field at all — and check it per call, not per vendor. If it does not,
omit the spread and pin that with a test.

# Custom event forwarding policy

Across destinations, the behaviors above are not all intentional — some are
genuine vendor constraints and some are implementation gaps. To keep current and
future destinations consistent, Chord follows one rule:

:::BlockQuote
**Every destination must handle the unrecognized-event case explicitly. It
either forwards the event or logs an explicit skip — it must never fall
through silently.**
:::

A silent fall-through (a `switch` with no `default`) is the thing to avoid: it
drops events with no signal in the logs, so a custom event that never arrives at
a destination looks identical to one that was delivered. That is the worst
outcome for debugging.

## Two buckets

Each destination belongs to one of two buckets, chosen by **what the receiving**
**platform actually does with an unrecognized event** — not by whether the event
*can* be transmitted:

| Bucket                                                                                          | Default for unrecognized events                                          | Why                                                                                                                    |
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| **Passthrough-capable** — general-purpose collectors and platforms with a real custom-event API | **Forward** the event under its own name (with custom-mapped properties) | The platform stores or acts on arbitrary events, so forwarding is useful                                               |
| **Fixed-taxonomy** — attribution pixels whose backend only recognizes a closed set of events    | **Skip with an explicit&#x20;**`console.warn`                            | The platform discards or rejects unrecognized events, so forwarding would be a no-op that falsely reads as "delivered" |

Examples of **passthrough-capable**: Webhook, Data Warehouse / Bulker, MongoDB,
Mixpanel, PostHog, GA4, Facebook (pixel via `trackSingleCustom` / Conversions API via a
custom `event_name`), TikTok, Pinterest, Insider, Bing, Dotdigital (the Tag's `event()` and
the v3 events import both create an Insight data collection for any non-reserved name).

Examples of **fixed-taxonomy**: Snap (closed enum + five `CUSTOM_EVENT_n`
slots), Spotify Ad Analytics (`view`/`purchase`/`lead` only), Postie (Snowplow
schemas it has registered), Retention.com (fixed case-sensitive "Reclaim"
names), Northbeam (custom goals must be pre-registered vendor-side), Nosto (a
closed set of page types plus cart, customer and order updates, with no custom
event at all), Reddit (eight standard events; a `Custom` event is discarded
unless its name is registered in the advertiser's Reddit account), OpenAI Ads
(closed enum plus a `custom` type whose name must be registered in Ads Manager).

Some fixed-taxonomy platforms offer a **registered custom event** as an escape
hatch, which is fixed-taxonomy and config-driven at once. OpenAI Ads is the
clearest example: it accepts a `custom` event type, but only for names the
advertiser has already created in Ads Manager, and it silently discards any
other name. So the destination skips an unmapped event by default, and forwards
it only when the operator has supplied a **Custom Event Mapping** entry — an
explicit statement that the name exists vendor-side. Forwarding on a guess would
look like delivery and be a no-op.

Reddit has the same `Custom` escape hatch vendor-side, but Chord's Reddit
destinations expose no event-mapping credential, so they always skip an
unrecognized event rather than guess at a registered name.

## Decide by platform, not by transport

A destination's bucket is a property of the **platform**, and the **pixel**
**(device-mode) and Conversions-API (server-side) implementations for the same**
**platform must make the same choice.**

Server-side / CAPI delivery is permissive by construction — `event_name` is just
a string field in an HTTP body, with no client SDK enum to satisfy — so it is
tempting to forward everything server-side. But transmitting an event the
platform ignores is still a no-op; it just happens over HTTP instead of a JS
call. Facebook accepts custom events on **both** its pixel and its Conversions
API, so both forward. A fixed-taxonomy platform like Snap would skip on both.
There is no case where the CAPI side should forward what the pixel side drops for
the same platform.

OpenAI Ads shows why this is not merely tidy. Its two surfaces deduplicate on
(pixel ID, event name, event ID), so they must agree on the *name* as well as
the decision to send: if the pixel skipped an event that the Conversions API
forwarded under a name of its own, the conversion would be recorded once under a
name that matches no conversion goal — worse than dropping it, because it reads
as delivered. Both surfaces therefore resolve event names through one shared
table and honor the same **Custom Event Mapping**.

:::BlockQuote
**Why custom-mapped properties are always forwarded regardless of bucket:** the
bucket decision is about the *event name*. Custom *properties* mapped by the
customer are merged into every outgoing call (see below), because a fixed-set
platform still accepts extra fields on the events it does recognize.
:::

# Practical guidance

- **Sending a custom event to a warehouse or a passthrough destination?** It will
  arrive with its original name and properties. No tracking-plan compliance is
  required.
- **Sending a custom event to an ad platform via a device-mode pixel?** Confirm
  the plugin is passthrough or that you have mapped the event in the plugin's
  configuration. Fixed-taxonomy plugins will skip it and log a `console.warn`.
- **Want a custom event to look like a canonical one at a destination?** Use a
  property mapping or a UDF in the connection to reshape it before it reaches the
  destination function.
- **Want to stop a custom event from reaching a destination?** Add a connection
  filter rule, or drop it in a UDF — there is no automatic rejection to rely on.

# Summary

Chord is a flexible, schema-less pipeline. It accepts any well-formed event,
transforms it only as explicitly configured, and forwards it. The tracking plan
provides canonical names that destinations \*prefer\* when present, but compliance
is never enforced. Custom events are first-class citizens of the pipeline; the
only meaningful variation is how individual destinations choose to map — or
ignore — events they were not built to recognize.

