Custom events
View as MarkdownYour 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 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.
Three rules to know first
Section titled “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
unmatchedso you can see why, and it does nothing else. To add a new person, send a lead. - 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). - Every event has its own
id. Sending an id that was already recorded in the last 30 days answersduplicateand writes nothing, so retrying is always safe.
POST /api/v1/events
Section titled “POST /api/v1/events”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. |
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" }, wherestatusisaccepted(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) orduplicate(thisidwas already recorded; nothing was written). The answer never says which company or person an event matched.422when the event is refused, with the reason inerror.code(see Refusals).
POST /api/v1/events/batch
Section titled “POST /api/v1/events/batch”Send 1 to 100 events at once, in a body of at most 256 KB:
{ "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:
{ "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
Section titled “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.
{ "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 isacceptedfor 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
companyorcontactthat is not in your workspace, it isunmatched; the visitor is not used in its place. With acompanyand avisitor, 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
Section titled “Know your visitor id”In the browser, on a page that runs the tracker:
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
Section titled “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):
{ "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
jtistill 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_nameandcompanypassed toF1A.identifyare used for the new person’s name and company name.
GET /api/v1/events
Section titled “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. |
{ "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
Section titled “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.
{ "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}labelis the name people see in Funnel One. An admin can rename it.statusisactive,archived(still recorded, but hidden from the Signals builder) orblocked(events with this name are refused).signal_factis what a signal calls the event. Its properties areprop.<key>. See Custom signals.propertiesgives the type each property first arrived with:string,numberorbool.
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
seatsas 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
Section titled “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,iporip_address(pii_property); or - a property’s value looks like an email address or a card number
(
pii_value).
Limits
Section titled “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_limitedwithRetry-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_backloggedwithRetry-After: 60. - Every request also counts toward the usual per-key and per-workspace request limits. See Limits.
- Events are kept for 30 days.
Refusals
Section titled “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.