App events
When your app does something that matters — a verification completes, a score recalculates, a suppression lands — the place your user looks is the record timeline. App events are how your app writes there: an event type you declare once, occurrences your handlers emit, and rendering you control.
TL;DR — Declare the type with appEvent (a name, the objectType it belongs to, typed properties, optional timeline templates), attach it via the events array in defineApp, and emit occurrences from handlers with ctx.appEvents.send or sendBatch. Codegen adds the timeline scope for you; batches chunk at 500 automatically. The worked example is Email Guard's email-verified event, a contact-timeline record of every verification the app runs.
Define the event next to your app declaration
import { appEvent, defineApp } from '@hs-x/sdk';
export const emailVerified = appEvent('email-verified', {
name: 'EMAIL_VERIFIED',
label: 'Email verified',
objectType: 'CONTACT',
properties: {
status: {
type: 'enumeration',
label: 'Status',
options: ['deliverable', 'risky', 'undeliverable'],
},
score: { type: 'number', label: 'Score' },
},
});
export default defineApp({
name: 'Email Guard',
distribution: 'marketplace',
auth: 'oauth',
platformVersion: '2026.03',
scopes: ['crm.objects.contacts.read', 'crm.objects.contacts.write'],
events: [emailVerified],
});objectType names the record type this event belongs to: a standard CRM type like CONTACT (Email Guard's case, since a verdict is a fact about a contact), or one of your app objects. The property map types every occurrence you will emit; status here carries the same enumeration the validate-email action outputs. Two optional fields, headerTemplate and detailTemplate, control how HubSpot renders the event in the timeline; they pass through as written, so test them against a real portal rather than trusting them sight unseen.
One change from the Getting started scaffold: Email Guard started life as a private app, and events require marketplace distribution with OAuth auth. The declaration above flips distribution and auth accordingly; the boundaries section below covers why.
Codegen turns the declaration into the app-events metadata in your generated project and, when any events exist, adds the timeline scope to the app automatically. hs-x check validates the declarations.
Send occurrences from handlers
Email Guard emits email-verified from the validate-email handler, right after the verdict lands on the contact. The declaration and the verification call are unchanged from the workflow-actions guide; the new lines are the appEvents destructure and the send:
worker.action('validate-email', {
// ...label, objectType, input, output: unchanged from the workflow-actions guide
async handler({ input, enrolledObject, appEvents, env, hubspot }) {
const verdict = await scoreEmail(String(input.email), env); // the verification fetch, factored out
await hubspot.crm.objects.contacts.update(enrolledObject.id, {
properties: {
email_health_status: verdict.status,
email_health_score: String(verdict.score),
},
});
await appEvents.send(emailVerified, {
objectId: enrolledObject.id,
properties: { status: verdict.status, score: verdict.score },
});
return ok({ status: verdict.status, score: verdict.score });
},
});An occurrence carries the record identity and your typed properties. objectId targets a specific record; for contact-anchored events like this one, email works as identity instead. timestamp is optional and defaults to now, which matters for backfills: emit historical occurrences with their real timestamps and the timeline orders them where they belong.
For volume, sendBatch takes an array and chunks it at 500 occurrences per request against HubSpot's batch endpoint, sending chunks sequentially. A backfill of ten thousand historical verifications is one call in your code.
One design habit worth keeping: events are facts, not state. Emit what happened with the values at the time it happened; read current state from the record when you need it. Email Guard already splits it that way: email_health_status on the contact is the current verdict, and the timeline is the history of every check that produced one. Timelines that try to be state machines age badly.
What to know before relying on it
- Identity is on you. An occurrence without
objectIdor a contact identity is accepted by the API but may not render anywhere useful. Anchor every event deliberately. - Event types are not discoverable at runtime. Handlers emit the types you declared; there is no runtime catalog to query.
- Templates are opaque to HS-X. They go to HubSpot exactly as written, with no validation or preview on our side.
- Marketplace distribution and OAuth are requirements, and events ride platform versions 2025.2 and 2026.03.
