Skip to main content
Custom events let you send anything that happens in your product — a signup, a deposit, a plan upgrade, a support ticket — into Onchain Suite. Once ingested, an event can trigger an automation, feed a segment, and be queried in SQL next to onchain and messaging data.
Event names are entirely free-form. There is no schema to register and no reserved vocabulary — send first_deposit and it exists.

Sending your first event

Ingestion is authenticated with a secret key, so it happens server-side. The organization is derived from the key.
Returns 202 Accepted:
Ingestion is asynchronous — 202 means accepted for processing, not that automations have already run.

Request fields

event
string
required
Event name. Must match ^[a-zA-Z0-9_.:-]{1,64}$ and is lowercased on arrival, so First_Deposit and first_deposit are the same event.
contact
object
required
Who the event belongs to. Must contain at least one of email, walletAddress, or externalId.
contact.email
string
Valid email, max 320 characters.
contact.walletAddress
string
Max 128 characters. Lowercased on arrival, so casing never causes a mismatch.
contact.externalId
string
Your own user ID. Max 128 characters, case preserved.
attributes
object
Contact traits merged onto the contact record. Max 50 keys; values must be string, number, or boolean; string values max 1024 bytes.
payload
object
Event-specific data, kept with the event rather than the contact. Max 16 KB serialized. Nested objects allowed.
idempotencyKey
string
Your dedup key, max 256 characters. Strongly recommended — see Idempotency.
occurredAt
string
ISO 8601 timestamp of when the event happened. Defaults to now. Rejected if older than 7 days or more than 5 minutes in the future.
test
boolean
Dry run — see Testing safely.

attributes vs payload

This distinction matters more than it looks: Put things that describe the person in attributes, and things that describe the event in payload.

Batch ingestion

Send up to 100 events in one request:
Results come back in request order:
A batch always returns 202, even when individual events fail. One bad event never rejects the others — you must inspect each result. Rate-limit rejections also surface as per-item error strings rather than a top-level 429.

Identity resolution

Each event is matched to a contact in strict priority order:
1

Wallet address

Case-insensitive match on walletAddress.
2

Email

Matched through a blind index, so encrypted email still resolves.
3

External ID

Matched against externalId stored in contact metadata.
If no contact matches, one is created. A wallet-only contact is valid — email is not required. Any attributes you send are merged into the new contact’s metadata, and externalId is backfilled onto existing contacts that lack one.
Because the first matching identifier wins, sending both walletAddress and email resolves on the wallet. To merge an email onto an existing wallet contact, send both — the wallet finds the record, and the email is stored on it.

Idempotency

Every event gets an idempotency key, scoped to your organization. A repeat of the same key is accepted but not reprocessed, and returns deduplicated: true. When you don’t supply one, it’s derived by hashing the event name, all three identifiers, and occurredAt.
Retries are not deduplicated unless you send an explicit idempotencyKey or an explicit occurredAt. The derived key includes occurredAt, which defaults to now — so the same call made twice a second apart produces two different keys and two events, which can fire an automation twice.Always send an idempotencyKey built from something stable in your system, like deposit:<txHash>:<logIndex>.

Testing safely

Two ways to dry-run:
  • Send with a sk_test_* key — everything from that key is a dry run automatically.
  • Send "test": true with a live key.
A dry-run event reports how many automations it would have matched, but creates no automation entries, so nothing sends. Test events are also hidden from the event catalog and excluded from the app_events SQL table, so they never contaminate a segment or report.

Triggering automations

Custom events are a first-class automation trigger. Use trigger type app_event (aliases custom_event, app-event, custom-event, appevent are all accepted).
An empty event-name list is a wildcard. A trigger with no event and no eventNames matches every custom event in your organization. Always name the events you want.

Filter paths

Filters read a flat context where your payload keys are spread in at the top level. So the path is amountUsd, not payload.amountUsd:
Available alongside your payload keys: event, contactId, walletAddress, email, externalId. A payload key with one of those names overrides the built-in value, so avoid naming a payload field email. Operators are eq, neq, in, contains, and exists — the same set used by on-chain triggers, and all filters must pass. See Triggers and Conditions for the details, including the note that unrecognized operators are silently dropped.

Querying events in SQL

Events land in the app_events table in the Intelligence SQL editor:
Use occurred_at for time windows — it’s when the event happened in your system. created_at is when we received it.

Discovering event names

GET /api/v1/events/catalog returns the distinct event names seen in the last 30 days with counts, ordered by frequency. This powers autocomplete in the builder and is useful for spotting typos in event names.
Test events are excluded, and the list is capped at 200 names.

Limits and errors

Ingestion itself is free and not metered against your plan — only messages sent as a result are.
429 responses do not currently include a Retry-After header. Use exponential backoff, and keep the same idempotencyKey across retries so a late success doesn’t double-fire.

Things to know

There is currently no automatic pruning of custom events. Plan for the table to grow, and prefer payload fields you’ll actually query.
The 50/sec bucket is held in memory per API instance, so effective throughput scales with the number of running instances. Treat 50/sec as the safe floor rather than a hard ceiling.
Fields outside the documented set are stripped rather than rejected. If data seems to be missing, check that it’s nested inside attributes or payload rather than sitting at the top level.
Custom-event trigger nodes aren’t yet recognized by the builder’s trigger-node detection, which falls back to the first node in the graph. Keep your app_event trigger as the first node so run inspection labels steps correctly.