# Custom events

> Send events from your own systems to Funnel One with POST /api/v1/events, read them back, and see which companies they matched.

Your own systems can tell Funnel One when something happens: a trial starts,
a plan changes, an invoice is paid. Each event is recorded against a company or
contact that is already in your workspace, and you can then build
[custom signals](https://dev.funnelone.ai/rest/custom-signals/) on it.

| Endpoint | Scope | What it does |
|---|---|---|
| `POST /api/v1/events` | `events.send` | Send one event. |
| `POST /api/v1/events/batch` | `events.send` | Send up to 100 events in one request. |
| `GET /api/v1/events` | `events.send` | List the events received in the last 30 days. |
| `GET /api/v1/event-types` | `events.send` | List the event names your workspace has received. |

`events.send` ("Send custom events from the API") is held by the Admin role by
default. A key only has it while the person who owns the key has it too. See
[Scopes](https://dev.funnelone.ai/get-started/scopes/).

## Three rules to know first

- **An event never creates a company or a person.** It is matched to a company
  or contact that already exists in your workspace. When nothing matches, the
  event is kept for 30 days as `unmatched` so you can see why, and it does
  nothing else. To add a new person, [send a lead](https://dev.funnelone.ai/rest/).
- **An event may not carry personal details.** Put the person in `contact`,
  which is only used to look them up. An event whose properties hold personal
  data is refused (see [Personal data](#personal-data)).
- **Every event has its own `id`.** Sending an id that was already recorded in
  the last 30 days answers `duplicate` and writes nothing, so retrying is always
  safe.

## POST /api/v1/events

```sh
curl -X POST https://app.funnelone.ai/api/v1/events \
  -H "Authorization: Bearer $F1_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "evt_trial_4812",
    "name": "trial_started",
    "occurred_at": "2026-09-29T12:00:00Z",
    "company": { "domain": "acme.com" },
    "contact": { "email": "jane@acme.com" },
    "properties": { "plan": "pro", "seats": 12, "annual": true }
  }'
```

| Field | Required | Rules |
|---|---|---|
| `id` | Yes | Your own id for this event: 1 to 100 visible ASCII characters, unique in your workspace. |
| `name` | Yes | What happened. Starts with a lower-case letter, then `a-z`, `0-9` and `_`, at most 48 characters. For example `trial_started`. |
| `occurred_at` | No | When it happened, an ISO 8601 date and time. At most 30 days ago and at most 5 minutes ahead. Default: now. |
| `company` | One of `company`, `contact` or `visitor` | Exactly one of `{ "id": 123 }` or `{ "domain": "acme.com" }`. |
| `contact` | One of `company`, `contact` or `visitor` | Exactly one of `{ "id": 456 }` or `{ "email": "jane@acme.com" }`. A contact given without a company supplies the company. |
| `visitor` | One of `company`, `contact` or `visitor` | `{ "id": "<visitor id>" }`: the id Funnel One's website tracker gave this browser, 1 to 64 letters, digits and dashes. See [Events about a website visitor](#events-about-a-website-visitor). |
| `properties` | No | Up to 50 properties. Each name starts with a lower-case letter, then `a-z`, `0-9` and `_`, at most 40 characters. Each value is text (up to 512 characters), a number, or `true`/`false`. At most 4 KB in all. |

Any other field, including `organization_id`, is refused. The workspace is
always the one your key belongs to.

A domain is matched the same way Funnel One matches domains everywhere else,
including the other domains a company is known by. A company or contact id from
another workspace matches nothing, exactly like an id that does not exist.

An `Idempotency-Key` header is optional here: the event's `id` already makes a
retry safe.

**Answers:**

- `202 { "id", "status", "event_id" }`, where `status` is `accepted` (it
  matched), `unmatched` (recorded, but nothing in your workspace matched it),
  `pending_identity` (it named only a website visitor who has not been
  identified yet: recorded, and waiting for them) or `duplicate` (this `id` was
  already recorded; nothing was written). The answer never says which company
  or person an event matched.
- `422` when the event is refused, with the reason in `error.code` (see
  [Refusals](#refusals)).

## POST /api/v1/events/batch

Send 1 to 100 events at once, in a body of at most 256 KB:

```json
{ "events": [ { "id": "evt_1", "name": "plan_changed", "company": { "id": 1234 } } ] }
```

Each event is judged on its own. One refused event does not stop the others.
The answer is always `202`, with counts and one result per event, in the order
you sent them:

```json
{
  "accepted": 1, "duplicate": 0, "unmatched": 0, "pending_identity": 0, "refused": 1,
  "results": [
    { "id": "evt_1", "status": "accepted", "event_id": 90211 },
    { "id": "evt_2", "status": "refused", "error": { "code": "pii_value", "message": "…", "param": "properties.note" } }
  ]
}
```

Retrying a whole batch is safe: events already recorded come back as
`duplicate`.

## Events about a website visitor

If your site runs Funnel One's website tracker, an event can name the visitor
instead of a company or a person. This suits things your servers see before
you know who someone is, such as a trial started before sign-up.

```json
{ "id": "evt_trial_4813", "name": "trial_started", "visitor": { "id": "3f1c2b1a-0d4e-4f5a-9b8c-7d6e5f4a3b2c" } }
```

- **If the visitor has already told you who they are**, by filling in a form,
  booking a meeting, clicking a link in one of your emails, or through a
  signed `F1A.identify`, the event is `accepted` for that person and their
  company, straight away.
- **Otherwise the event waits** (`pending_identity`). It is recorded, and it
  does nothing yet. When the visitor identifies themselves in one of those
  ways, the event is matched to them and becomes a fact. Because it arrives
  late, it counts toward signals and scores but does not start alerts,
  workflows or motions.
- **A visitor we only guessed at does not count.** A company identified from
  the visitor's browsing, or an unsigned `F1A.identify`, is not enough: the
  event keeps waiting for the person.
- **Waiting lasts at most 30 days.** An event not matched by then is never
  matched, and it is deleted with the rest of your events after 30 days.
- **A visitor id the tracker has not seen yet** still waits, because your
  server can easily send before the browser does. It is matched once the
  tracker sees that id and the visitor identifies themselves.
- **An event names what it names.** If it also names a `company` or `contact`
  that is not in your workspace, it is `unmatched`; the visitor is not used in
  its place. With a `company` and a `visitor`, the visitor supplies the person
  only if they work at that company.
- **Nothing is created.** A visitor id only borrows who the visitor already is
  in your workspace. A visitor id from another workspace matches nothing.

### Know your visitor id

In the browser, on a page that runs the tracker:

```js
const visitorId = window.F1A && window.F1A.getVisitorId();
```

It returns the id, or `null` when the tracker is not tracking this visitor: the
browser sends Do Not Track or Global Privacy Control, your site waits for
cookie consent (`data-consent="required"`) and it has not been given, the
visitor refused with `F1A.consent(false)`, or the tracker has not started yet.
When it is `null`, send the event without `visitor`, or not at all. Pass the id
to your server with your own request.

On your server, you can instead read the `_f1vid` cookie from a request the
browser makes to your own site (the cookie belongs to your domain, so it
arrives with your own first-party requests). Only use it where the tracker
itself is allowed to track the visitor.

In `GET /api/v1/events`, a matched waiting event carries `resolved_at`, the
time it was matched. The visitor id itself is never returned.

### Signed identify can add the person

A signed `F1A.identify` for someone who is not in your workspace yet adds them:
the person, and their company when the email has a business domain. They are
added the way a lead sent to `POST /api/v1/leads` is, under your lead-handling
settings (enrichment runs only if you switched it on), and the visitor's
waiting events are then matched to them.

It adds someone only when the token your server signs carries a `jti`, a unique
id for that token (a random UUID is fine):

```json
{ "email": "dana@acme.com", "site": "sk_site_…", "iat": 1790000000, "exp": 1790003600, "jti": "4f1c2b1a-0d4e-4f5a-9b8c-7d6e5f4a3b2c" }
```

- **Use each token once.** A token is tied to the first browser that sends it.
  Sent again from that browser it changes nothing; sent from a different
  browser it counts only as an unverified claim and adds nobody.
- **A token without `jti`** still identifies someone already in your
  workspace, exactly as before. It never adds anyone.
- **An unsigned `F1A.identify`**, a token that does not verify, and a link click
  from one of your emails never add anyone.
- **At most 500 people a day** are added this way per workspace. Past that,
  identify still recognises people you already have.
- `first_name`, `last_name` and `company` passed to `F1A.identify` are used for
  the new person's name and company name.

## GET /api/v1/events

The events your workspace received in the last 30 days, newest first. Use it to
check what arrived, and why an event was `unmatched`.

| Query | Means |
|---|---|
| `limit` | Events per page, 1 to 100. Default 50. |
| `cursor` | The `nextCursor` of the previous page. |
| `name` | Only events with this name. |
| `status` | `accepted` or `unmatched`. A row itself can also be `pending_identity`. |

```json
{
  "rows": [
    {
      "id": "evt_trial_4812", "event_id": 90210, "name": "trial_started",
      "status": "accepted",
      "occurred_at": "2026-09-29T12:00:00.000Z", "received_at": "2026-09-29T12:00:01.000Z",
      "company_id": 1234, "contact_id": 55, "domain_hint": "acme.com",
      "resolved_at": null,
      "properties": { "plan": "pro", "seats": 12, "annual": true }
    }
  ],
  "nextCursor": "…",
  "total": 132
}
```

`id` is your own id; `event_id` is the id Funnel One recorded it under.
`domain_hint` is the domain the event named. An email address is never stored,
so it is never returned. Keep passing `nextCursor` as `cursor` until it is
`null`. A cursor this list did not issue answers `400 invalid_cursor`.

## GET /api/v1/event-types

Every event name your workspace has received (at most 200), in alphabetical
order, with `limit` (1 to 100, default 100) and `cursor` as above.

```json
{
  "rows": [
    {
      "id": 12, "name": "trial_started", "label": "Trial started",
      "status": "active", "signal_fact": "custom_trial_started",
      "properties": { "plan": "string", "seats": "number", "annual": "bool" },
      "first_seen_at": "2026-09-01T09:30:00.000Z", "last_seen_at": "2026-09-29T12:00:01.000Z"
    }
  ],
  "nextCursor": null,
  "total": 1
}
```

- `label` is the name people see in Funnel One. An admin can rename it.
- `status` is `active`, `archived` (still recorded, but hidden from the Signals
  builder) or `blocked` (events with this name are refused).
- `signal_fact` is what a signal calls the event. Its properties are
  `prop.<key>`. See [Custom signals](https://dev.funnelone.ai/rest/custom-signals/).
- `properties` gives the type each property first arrived with: `string`,
  `number` or `bool`.

## Names

The first event with a new name registers that name, with the type of each of
its properties. After that:

- a property keeps its first type. Sending `seats` as text after it arrived as
  a number is refused (`property_type_mismatch`);
- one name can have at most 50 different properties (`too_many_properties`);
- a workspace can have at most 200 names. A new name past that is refused
  (`event_type_limit`); archive or reuse a name first.

## Personal data

Personal details belong in `contact`, never in `properties`. An event is
refused when:

- a property's name is one of `email`, `e_mail`, `phone`, `mobile`, `name`,
  `first_name`, `last_name`, `full_name`, `address`, `street`, `zip`,
  `postcode`, `ssn`, `password`, `secret`, `token`, `api_key`, `card`, `iban`,
  `dob`, `birth_date`, `ip` or `ip_address` (`pii_property`); or
- a property's value looks like an email address or a card number
  (`pii_value`).

## Limits

- **1,000 events a minute and 100,000 a day** (the day is UTC) per workspace,
  counted by event, so a batch of 100 counts as 100. Past either:
  `429 events_rate_limited` with `Retry-After`. Nothing in that request was
  recorded.
- **Your workspace's earlier events are still being processed**: when 20,000 of
  them are waiting, new ones answer `429 ingest_backlogged` with
  `Retry-After: 60`.
- Every request also counts toward the usual per-key and per-workspace request
  limits. See [Limits](https://dev.funnelone.ai/rest/limits/).
- Events are kept for 30 days.

## Refusals

| Status | Code | Means |
|---|---|---|
| 400 | `invalid_body` | A field is missing, unknown, or the wrong type. `param` names it. |
| 413 | `batch_too_large` | More than 100 events, or more than 256 KB. |
| 422 | `invalid_request` | A rule the schema cannot check, such as a domain that is not a domain, or properties over 4 KB. |
| 422 | `event_too_old` | `occurred_at` is more than 30 days ago. |
| 422 | `event_in_future` | `occurred_at` is more than 5 minutes ahead. |
| 422 | `event_blocked` | Events with this name are blocked in your workspace. |
| 422 | `pii_property` | A property's name is on the personal-data list. |
| 422 | `pii_value` | A property's value looks like an email address or a card number. |
| 422 | `property_type_mismatch` | A property arrived with a different type than before. |
| 422 | `too_many_properties` | The name would have more than 50 different properties. |
| 422 | `event_type_limit` | The workspace already has 200 names. |
| 429 | `events_rate_limited` | 1,000 events this minute or 100,000 today. Wait `Retry-After`. |
| 429 | `ingest_backlogged` | Earlier events are still being processed. Wait `Retry-After`. |
| 503 | `events_unavailable` | Events cannot be accepted right now. Retry after `Retry-After`; every event keeps its id, so nothing is recorded twice. |

In a batch, the `422` codes appear on each refused event's `error` instead.

Source: https://dev.funnelone.ai/rest/events/
