view .md
Guides · Build

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.

Outcome
Email Guard's email-verified event on the contact timeline: declared once, emitted from the validate-email handler, with typed properties and batch sending handled for you.
Prerequisites
  • An HS-X project with marketplace distribution and OAuth auth (platform version 2025.2 or 2026.03)
  • HubSpot approval for app events through the app objects and events request form; HubSpot describes the feature as intended for technology partners
  • Optionally, an app object to anchor events to — see the app objects guide

TL;DR — Declare the type with appEvent (a name, a description, the objectType it belongs to, typed properties, optional timeline templates), attach it via the events array in defineApp, add 'timeline' to scopes, and emit occurrences from handlers with ctx.appEvents.send or sendBatch. send batches by install at 500 occurrences or 5 seconds, and its promise resolves when the batch flushes. The feature needs HubSpot approval before the build accepts it. 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',
  description: 'Recorded every time Email Guard scores a contact address.',
  objectType: 'CONTACT',
  headerTemplate: 'Email verified: {{status}}',
  detailTemplate: 'Email Guard scored this address **{{score}}** ({{status}}).',
  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', 'timeline'],
  events: [emailVerified],
  appEvents: {
    batching: false,
  },
});

name is what HubSpot displays for the event type (up to 50 characters) and must equal the event’s uid, which HS-X derives from the id (email-verified becomes EMAIL_VERIFIED), because the runtime sends name as the eventTypeName on every occurrence. HubSpot requires a description. objectType must be one of HubSpot’s fixed values: CONTACT, COMPANY, DEAL, TICKET, LEAD, ORDER, APPOINTMENT, COURSE, LISTING, PROJECT, SERVICE, or APP_OBJECT for events anchored to your app object. To anchor events to portal custom objects set supportsCustomObject: true instead of an objectType. It cannot be changed after the type is created. The property map types every occurrence you will emit; status here carries the same enumeration the validate-email action outputs, and a property’s type is immutable once created.

headerTemplate and detailTemplate control how HubSpot renders the event in the timeline. They are Markdown (no inline HTML) plus Handlebars: {{propertyName}} works in either, {{extraData.field}} only in the detail template; headers cap at 1,000 characters and details at 10,000. HS-X passes them 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, OAuth auth, the timeline scope, and HubSpot’s approval (the same in-app request form as app objects; HubSpot describes the feature as intended for technology partners). Until approval lands the HubSpot build rejects the app-events component. The declaration above flips distribution and auth accordingly.

Codegen writes the app-events metadata and guarantees timeline is in the app’s requiredScopes; hs-x check still expects you to list 'timeline' in scopes explicitly and errors if it is missing, so add it alongside your other scopes. appEvents.batching decides how send behaves: omit it for the default of 500 occurrences or 5000 milliseconds per install, lower either value for faster flushes, or set batching: false (Email Guard’s choice, explained below) to use the single-event endpoint. hs-x check validates the distribution, auth, platform version, and scope requirements; the event schema itself is validated by HubSpot’s build on hs-x deploy.

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 and execution destructures and the send:

worker.action('validate-email', {
  // ...label, objectType, input, output: unchanged from the workflow-actions guide
  async handler({ input, enrolledObject, execution, 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, {
      id: `verify-${enrolledObject.id}-${execution?.callbackId ?? Date.now()}`,
      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 email (optionally with utk) works instead, but HubSpot creates a new contact when nothing matches, so prefer objectId whenever you have it. Set id to a stable value (here the workflow execution’s callback id) so a retried handler cannot write the same occurrence twice: HubSpot rejects a duplicate occurrence id for a year. timestamp (ISO 8601) 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. extraData and timelineIFrame feed the detail template and the iframe link respectively.

Now the batching choice. With batching on, send enqueues the occurrence in a per-install batcher and the returned promise resolves only when that batch flushes: at 500 items or after maxDelayMs, whichever comes first. Nothing flushes at the end of a request. So await appEvents.send(...) inside a request-scoped handler like validate-email waits up to five seconds unless the batch fills, and occurrences still queued when the isolate is evicted are lost. In a workflow action or card backend either set appEvents: { batching: false } (one request per occurrence, returns immediately), lower maxDelayMs, or call sendBatch with the occurrences you have. Email Guard turns batching off because every action invocation emits exactly one event and should return promptly.

For volume, sendBatch takes an array, flushes anything the batcher is holding, and chunks the array at 500 against HubSpot’s batch endpoint, sending chunks sequentially. A backfill of 10,000 historical verifications is one call in your code. HubSpot validates each chunk atomically: one malformed occurrence rejects the whole 500-item chunk, and chunks already sent stay accepted, so validate before batching.

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 required. HubSpot rejects an occurrence with no objectId unless the event is contact-anchored and carries email or utk, and in that case it may create a contact. 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.
  • Property stamping is not declared here. HubSpot can copy an occurrence property onto the record through objectProperty in the event schema; the HS-X property definition does not carry that field yet.
  • Limits are HubSpot’s. 750 event types per app, 500 properties per type, 1 MB per occurrence and 512 KB per property value.
  • Marketplace distribution, OAuth, the timeline scope, and HubSpot approval are requirements, and events ride platform versions 2025.2 and 2026.03.