# 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; `send` auto-batches by install at 500 occurrences or 5 seconds, whichever comes first. 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

```ts
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],
  appEvents: {
    batching: { maxSize: 500, maxDelayMs: 5000 },
  },
});
```

`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. `appEvents.batching` is optional; omit it for the default `500` occurrences or `5000` milliseconds, lower either value for faster flushes, or set `batching: false` to use the single-event endpoint. `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`:

```ts
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 the configured batch size 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 `objectId` or 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.

**Where next**

- [App objects: the record types events anchor to](/docs/guides/app-objects)
- [Triggers: react to HubSpot events coming the other way](/docs/guides/triggers)
- [Local dev: emit and inspect events against a test portal](/docs/guides/dev-mode)

