Designing Event Schemas for SaaS Integrations: What to Emit, What to Skip, and How to Not Regret It Later
Every SaaS integration starts with a decision most teams make too quickly: what does the event payload look like? This post covers what belongs in the payload, what to leave out, naming conventions, versioning, and the anti-patterns that cause problems in production.
Every SaaS integration starts with a decision most teams make too quickly: what does the event payload look like?
That decision gets baked into your product, your integration code, and eventually your customers' workflows. Changing it later is expensive. Getting it wrong early is a commitment you don't realize you're making until you try to add a third destination and nothing fits.
This post is about getting event schema design right the first time — or at least, right enough that future-you isn't debugging payload mismatches at 2am.
Why schema design matters more than teams think
When you're emitting events to one destination, schema design feels like a detail. You write what the destination's API expects. You call it done. It works.
The problem shows up later, when the same event needs to go somewhere else. That second destination wants different field names. A third destination needs fields you didn't include. A fourth wants the same data but nested differently. Suddenly the "event" you emit isn't a shared truth — it's whatever your first integration needed, and every new integration has to translate it.
The teams that get this right treat event schemas as a contract with their own future. The event payload describes what happened in the product, not what a specific destination expects to receive. Translation to destination-specific formats happens later, in a layer designed for it. The event itself stays stable.
This distinction sounds abstract until you've lived through its absence.
The event vs the integration
The first rule is the one most teams get wrong: the event is not the API call.
A subscription.started event describes a fact about your product — a customer upgraded from free to paid at this time, for this plan, with this billing period. That's all it is. A fact. The event doesn't know about HubSpot's contact lifecycle stages, Salesforce's opportunity stages, or Mailchimp's merge fields. It shouldn't.
When you conflate the event with the integration call, you end up with payloads that look like this:
This works for exactly as long as you have three destinations. When you add Intercom and it wants user_type: "paid", you're adding another destination-specific field to every future emission. Your event payload becomes a union of every integration's requirements, growing indefinitely.
Every destination translates these facts into its own format. HubSpot gets a lifecycle stage update. Salesforce gets an opportunity. Mailchimp gets tags. The event itself never mentions any of them.
This separation is what makes schemas survive destination changes, destination additions, and destination removals. You're not editing events when your CRM changes — you're editing the mapping layer.
What belongs in the payload
The right mental model: a payload should contain everything a reasonable destination might need, expressed in terms of your product's domain.
Identifiers. Every event needs a way to identify the entities involved. Customer IDs, user IDs, subscription IDs, order IDs — whatever your domain uses. Include all of them, even when you think only one is needed. Destinations often join on different identifiers than you expect, and adding an identifier to every future event is harder than including it now.
Facts about what happened. The state change itself. For a subscription.started event, this is the plan, the amount, the billing period, the start date. For a payment.failed event, this is the amount, the failure reason, the attempt count. These are the fields a destination uses to decide what to do.
Enough context to be useful without a lookup. If a destination receiving this event would immediately need to query your API to get more information, that's a signal you're missing context. Common examples: the customer's email address, their account tier before the change, the previous value of a field that just changed. These feel redundant at emission time; they save destinations from having to look up data they don't have.
Timestamps — plural. Not just timestamp. Include event_time (when the thing happened in your system) and emitted_at (when the event was generated). For state changes, include previous_state_at if it's meaningful. Timestamps are the hardest thing to add later because old events will forever lack them.
Here's what a payment.failed event looks like with these principles applied:
Notice what's here and what's not. The event includes everything a destination needs to act: enough identifiers to locate the customer anywhere, the exact failure reason, the retry context, and enough card detail to render a useful message. It doesn't include dunning_stage, hubspot_contact_id, or slack_channel_override — those are mapping decisions, not facts about the payment.
What doesn't belong
Some things look tempting to include but hurt more than they help.
Destination-specific fields. The example above. If a field name is tied to a specific destination, it doesn't belong in the event. It belongs in the mapping layer for that destination.
Derived values. Don't include fields that can be computed from other fields in the payload. total_with_tax is tempting, but if amount and tax_rate are already there, the derived field is a trap — it can be inconsistent with its inputs, and destinations that care will compute it themselves.
Large blobs. Full HTML email bodies, serialized database rows, image data. Events should describe what happened, not carry every artifact the change produced. If a destination needs the blob, include a reference (an ID or URL) and let the destination fetch it.
Internal implementation details. Your database table names, your internal service identifiers, your queue partition keys. These leak your architecture to external systems. If you refactor, suddenly every destination needs to update their mappings.
PII you don't need to send. The instinct is to include everything "just in case." The discipline is to include only what destinations actually need. Full addresses, phone numbers, and date-of-birth fields should be opt-in per destination, not default-in the event payload. Every field you emit is a field you have to protect in transit, at rest, and in every destination it reaches.
Naming conventions that age well
Event names and field names are harder to change than the values they carry. A few patterns worth adopting early:
Use object.verb_past_tense for event names.subscription.started, payment.failed, user.signed_up. Past tense because the event describes something that already happened — not something to do. Object-first because it groups naturally when you have many events for the same entity (subscription.started, subscription.upgraded, subscription.canceled).
Avoid ambiguous verbs.subscription.updated tells a destination that something changed, but not what. Prefer specific verbs: subscription.plan_changed, subscription.billing_period_changed, subscription.payment_method_updated. Specific events let destinations subscribe to exactly what they care about instead of filtering through every update.
Use snake_case consistently. Most destinations expect it, JSON handles it cleanly, and it's unambiguous. Mixed case (userId, user_id, UserID) across events is a maintenance nightmare.
Pluralize arrays, singularize scalars.tags for an array, tag for a single value. Obvious, but teams miss it and end up with tag: ["a", "b"] which is confusing for every destination that consumes it.
Never use field names that are destination terminology.lifecycle_stage is HubSpot's term. opportunity_stage is Salesforce's. Use your own product's vocabulary. If you don't have a term for it, invent one — just don't borrow the destination's.
Applied to a real event family, the pattern looks like this:
// Good — specific, past tense, object-first"subscription.started""subscription.plan_upgraded""subscription.plan_downgraded""subscription.payment_method_updated""subscription.canceled""subscription.reactivated"// Avoid — ambiguous or destination-flavored"subscriptionUpdated" // mixed case, vague"subscription_change" // not past tense, vague"update_hubspot_contact" // describes the action, not the fact"subscription.lifecycle_changed" // borrows HubSpot terminology
The specific events cost nothing extra to emit and give destinations precise subscription targets. The vague version forces every consumer to inspect the payload to decide whether they care.
Versioning from day one
The version you ship is the version you'll need to maintain. Plan for it.
Include a schema version in every event from the very first emission:
When you need to change the schema — and you will — new emissions carry schema_version: "1.1" or "2.0", and destinations can handle both during migration. Without a version field, schema changes are breaking changes by default, and every consumer has to coordinate updates with you.
A few rules that save pain:
Adding a field is non-breaking. Existing consumers ignore fields they don't know about. Incrementing the minor version (1.0 to 1.1) is the convention.
Removing or renaming a field is breaking. It requires a major version bump (1.x to 2.0), a migration period, and explicit consumer updates. Most of the time, the right move is to add a new field and deprecate the old one slowly, not remove it.
Changing a field's type or semantics is breaking. If amount used to be dollars and now it's cents, that's a new field (amount_cents) and a deprecation of the old one. Silently changing the meaning of an existing field is the worst kind of breakage because nothing flags it.
// v2.0 — switched to cents, renamed plan to plan_id, added currency{ "event": "subscription.started", "schema_version": "2.0", "customer_id": "cus_kenobi42", "amount_cents": 4900, "currency": "USD", "plan_id": "pro_monthly"}
During the migration period, both versions get emitted. Consumers handle them based on schema_version and migrate at their own pace. Once every consumer has confirmed it handles v2.0, you stop emitting v1.0. Skip the version field and you don't have the option — every schema change is a coordinated outage.
Test your schema against real destinations before committing
The single cheapest way to catch schema problems is to prototype against the destinations you actually care about before finalizing the event structure.
Pick your top three destinations. For each one, write out what fields you'd need to populate in their API from the event payload. If you find yourself reaching for fields that aren't in the event, the schema is incomplete. If you find yourself ignoring fields in the event, they probably shouldn't be there. If you find yourself transforming the same field three different ways for three destinations, that's fine — that's what the mapping layer is for.
Teams that skip this step usually discover the problems months later, after production events have already been emitted with the wrong shape. At that point, changing the schema means versioning, dual-emission, and coordinated consumer updates — weeks of work that a one-afternoon prototype would have avoided.
The schemas you'll regret
A quick catalog of patterns that look reasonable at design time and cause problems in production.
"The big event." A single event type that fires for many different state changes, with a change_type field inside.
Destinations that need the rest of the subscription state have to look it up, which adds latency, failure points, and race conditions. Include the full relevant state in every event.
"The chatty schema." Events fire for every small state transition, including intermediate states nobody cares about.
// Anti-pattern — internal steps leak into integrations"subscription.processing""subscription.validating""subscription.provisioning""subscription.started"// Better — emit only at meaningful boundaries"subscription.started" // after all internal steps complete"subscription.failed" // if something went wrong
Destinations only care about the terminal state. Emit events at meaningful boundaries, not every internal step.
"The snowflake event." Every event has different fields depending on context.
Some payment.failed events include decline_reason, some don't. Some include card_brand, some don't. This makes downstream processing guesswork. Either a field is always present (with null if unknown) or it's never present. Consistency beats completeness.
What good schemas give you
When schema design is done well, a few things become true:
Adding a new destination is a mapping exercise, not a schema change. Your event payload already has what the new destination needs — you just configure how its fields map into the destination's API.
Events become reusable beyond the original integration. A subscription.started event designed around your product's facts can feed HubSpot, Salesforce, analytics, internal dashboards, webhook subscribers, and future destinations you haven't imagined yet.
Debugging gets easier. When every event carries full context, reproducing an issue doesn't require reconstructing state from other sources. The event itself tells you what happened.
Replay becomes possible. Events that describe facts can be re-delivered later without ambiguity. Events that describe API calls can't — by the time you replay, the API has moved on.
The event layer outlives the destinations it feeds. Destinations come and go. Integration targets change. Products get deprecated. A well-designed event schema is durable in a way destination-specific code isn't.
Designing the schema is half the job.Join Meshes and get routing, retries, fan-out, and per-destination mapping so your event schemas stay clean while the integration layer handles everything else.