view .md
Guides · Build

Syncs

HubSpot never shipped a first-class sync primitive, so every integration team rebuilds the same machinery: a scheduler, cursor storage, batching, backoff, and a pile of property-creation scripts. An HS-X sync collapses that to a declaration. You describe the source and the destination; the runtime owns the plumbing, in your own Cloudflare account.

Outcome
A typed sync upserting external rows into a HubSpot object, keyed on a unique property, with cursors and checkpoints persisted across runs on a linked deploy and writes paced by the shared rate limiter.
Prerequisites
  • The Email Guard project from the Getting started guide, or any HS-X project (hs-x init --type sync-source scaffolds a push source into a portal custom object; add idProperty and bind the webhook secret before deploying)
  • A HubSpot portal you can develop against
  • A project linked to an HS-X account if you want scheduled runs and durable cursors (unlinked deploys serve push webhooks with in-memory state only)

TL;DR — Declare a source with defineSource (pull: a fetch that returns { key, data } rows and a cursor; push: a receive behind a signed webhook). Attach it with worker.sync: a schedule, a destination object (into), the unique property each row key carries (idProperty, for example email on contacts), a schema mapping source fields to HubSpot properties, and a manageSchema mode. Without idProperty the runtime refuses to deliver. On a linked deploy the runtime schedules runs, persists the cursor in your tenant database, upserts in batches of 100, and shares the portal’s rate budget. The worked pull example is Email Guard’s suppression-list sync.

A sync is a declaration, not a service

The integration you have probably written before looks like this: a cron job somewhere, a database row holding “last synced at,” a loop that pages an API, a batch upsert against HubSpot, and retry code you wrote at 2am after the first rate-limit incident. None of that logic was your product. It was the cost of moving rows.

HS-X splits a sync into the two parts you actually care about and absorbs the rest:

  1. A source produces rows. Pull sources fetch pages from an external API on a schedule. Push sources receive rows when the external system calls a signed webhook.
  2. A sync binds that source to a HubSpot destination: which object the rows land in, which unique property identifies a row, how source fields map to HubSpot properties, and whether HS-X manages that schema in the portal at deploy time.

Scheduling, cursor persistence, batching, retry, and rate-limit fairness belong to the runtime, which runs in your own Cloudflare account. One sync misbehaving cannot starve your workflow actions; the limiter shares the portal budget across capabilities.

Where that state lives depends on whether the project is linked to an HS-X account. A linked deploy provisions a tenant D1 database for the Worker, and that is where cursors, run leases, chunk checkpoints, and the poison-row queue persist. An unlinked deploy has no durable store: cursors live in isolate memory and vanish on eviction, and the generated cron handler refuses to run because the bindings it needs (TENANT_DB, HSX_SYNC_GRANT_KEY, HSX_APP_ID) are absent. Push webhooks still verify and deliver on an unlinked deploy; scheduled pull syncs do not fire until you link.

Syncs are one-way into HubSpot in the current version. Your handlers can always read HubSpot through ctx.hubspot, but pushing changes back out to the external system is not what this primitive does today.

Push sources: rows arrive on a signed webhook

A push source, pointed at a standard object:

import { defineSource, defineWorker } from "@hs-x/sdk";
 
const incomingCustomers = defineSource.push({
  name: "incoming-customers",
  // `secret` names a Worker secret binding; the runtime reads the value from env at request time.
  auth: { type: "hmac", secret: "INCOMING_CUSTOMERS_WEBHOOK_SECRET" },
  async receive({ event }) {
    const customer = event as { external_id: string; email: string };
    return { rows: [{ key: customer.email, data: customer }] };
  },
});
 
const worker = defineWorker("sync");
 
worker.sync(incomingCustomers, {
  schedule: "event",
  into: "contacts",
  idProperty: "email", // the unique contact property each row.key carries
  schema: {
    // source field -> HubSpot property
    email: "email",
    external_id: "external_id",
  },
  manageSchema: "properties",
});
 
export default worker;

Reading it top to bottom. auth.secret is the name of a Worker secret, never the value; the runtime looks the binding up when a delivery arrives and answers 503 push_source_unconfigured if nothing is bound under that name. Set it before you point anything at the endpoint:

wrangler secret put INCOMING_CUSTOMERS_WEBHOOK_SECRET   # deployed Worker
echo 'INCOMING_CUSTOMERS_WEBHOOK_SECRET=...' >> .dev.vars  # hs-x dev, never committed

receive turns one event into rows, each a { key, data } pair. key must be the value of the idProperty on the sync (here the contact’s email), because the runtime upserts rows through HubSpot’s batch endpoint keyed on that property. That is what makes a re-delivered event update the same contact instead of duplicating it, and it is why idProperty is required: a source-backed sync without idProperty fails closed with SyncDeliveryUnconfigured and delivers nothing. schedule: "event" is the only valid schedule for a push source. schema maps each source field to the HubSpot property it lands in; fields absent from the map are not written. manageSchema: "properties" lets hs-x deploy --portal-schema-live --apply-schema create the missing external_id contact property; the runtime itself never mutates portal schema.

The deployed Worker serves the webhook at /webhooks/incoming-customers (the source’s name). The endpoint expects HS-X’s own signature, not a third party’s: the sender puts a millisecond epoch in x-hsx-timestamp and, in x-hsx-signature, the base64 HMAC-SHA256 of "<timestamp>\n" followed by the exact request body bytes, computed with the shared secret. Deliveries older than five minutes, or whose signature has already been accepted, are rejected with 401. A system that signs with its own scheme (Stripe, GitHub, a SaaS webhook) cannot call this route directly; put a small relay in front that verifies their signature and re-signs. signPushSourceRequest from @hs-x/runtime produces the exact value:

import { signPushSourceRequest } from "@hs-x/runtime";
 
const secret = process.env.INCOMING_CUSTOMERS_WEBHOOK_SECRET ?? "";
const body = JSON.stringify({ external_id: "cus_123", email: "mia@example.com" });
const timestamp = String(Date.now());
const signature = await signPushSourceRequest({ secret, timestamp, body });
 
await fetch("https://your-worker.example.workers.dev/webhooks/incoming-customers", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-hsx-timestamp": timestamp,
    "x-hsx-signature": signature,
  },
  body,
});

The local dev server does not host /webhooks/*, and hs-x dev invoke <sync-id> only runs the sync’s handler with fixture input, which for a source-backed sync is a no-op: it does not call receive and writes no rows. To exercise a push source end to end, deploy once and send a signed request to the deployed route as above.

Pull sources: fetch pages on a schedule

A pull source implements one method, fetch, which receives the cursor from the previous run and returns a page: { key, data } rows plus the cursor for the next run. This is Email Guard’s suppression-list source from the Getting started guide, the verification provider’s list of addresses that hard-bounced or complained, pulled into HubSpot contacts every five minutes:

type SuppressionPage = {
  next?: string;
  entries: Array<{ email: string; reason: string; suppressed_at: string }>;
};
 
const suppressionList = defineSource({
  name: "suppression-list",
  auth: { type: "bearer", token: process.env.EMAILCHECK_API_KEY },
  async fetch({ cursor, http }) {
    const res = await http.get("https://api.emailcheck.example/v1/suppressions", {
      query: { pageSize: 100, after: cursor },
    });
    const page = res.body as SuppressionPage;
    return {
      cursor: page.next,
      rows: page.entries.map((entry) => ({
        key: entry.email,
        data: {
          email: entry.email,
          email_suppressed: true,
          email_suppression_reason: entry.reason,
          email_suppressed_at: entry.suppressed_at,
        },
      })),
    };
  },
});
 
worker.sync(suppressionList, {
  into: "contacts",
  idProperty: "email",
  schedule: "5m",
  manageSchema: "properties",
  schema: {
    email: "email",
    email_suppressed: "email_suppressed",
    email_suppression_reason: "email_suppression_reason",
    email_suppressed_at: "email_suppressed_at",
  },
});

Two things differ from the push shape. The source declares auth: { type: "bearer", ... } and gets an injected http client. That client builds the query string, JSON-encodes bodies, and adds the Authorization header when the declared token is a string at module load; the generated Worker populates process.env from its secret bindings, so wrangler secret put EMAILCHECK_API_KEY is what that line reads in production and .dev.vars is what it reads under hs-x dev. It does not retry or rate-limit the provider call. If the provider answers 429, throw: the run fails with its cursor and checkpoints intact and the next tick picks up where it left off.

And the cursor drives the pagination. It is whatever type your source needs (a string id, a timestamp, an opaque token like the provider’s next here), persisted in your tenant D1 database between runs on a linked deploy and handed back on the next fetch. Returning undefined for cursor ends the run without advancing past that page, so the next run re-fetches it; upserts are idempotent, so that is safe. A run also stops after 50 pages, or when a page returns no rows, and the cursor only advances after a page has been fully delivered.

Custom sync handlers (the worker.sync("id", { handler }) form, where you write the loop yourself) get ctx.sync: cursor(), setCursor(cursor) to durably mark a page done and clear its chunk checkpoints, and checkpoints() plus checkpoint(chunkId, state) for chunk-level resume after an interrupted run. That is how backfills and resets are done deliberately instead of by deleting state.

schema is the map the runtime uses to turn each row’s data into HubSpot properties: the key is the source field, the value is the HubSpot property name, and only mapped scalar fields are written. The deploy-time schema plan currently reads the same object differently, as property name to property type, so keep every key equal to the HubSpot property name it maps to until that is reconciled, and treat WILL CREATE lines for sync properties as advisory rather than applying them blind.

schedule takes an interval shorthand like "5m", a standard five-field cron expression, "manual" for a sync that only runs on an explicit signed POST /sync/<id>/run, or "event" for push sources. At deploy time HS-X normalizes each cadence to a Cloudflare cron trigger and bakes a scheduled handler that fires the run. A schedule it cannot honor exactly is rejected when the worker module loads (HSX_E_SYNC_SCHEDULE_INVALID) rather than silently rounded: minute intervals must divide 60 (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, or 60), hour intervals must divide 24 (1, 2, 3, 4, 6, 8, 12, or 24), and the only day shorthand is 1d. A sub-minute cadence or */7 minutes is an error you see before it ships.

manageSchema has three settings, and all of them act only at deploy time:

  • "properties" — HS-X manages properties on an object that already exists. The right mode for every standard object, including contacts here.
  • "full" — HS-X also creates the destination object itself when it is missing. Because into is then a portal custom object (p_…), this creates a per-portal custom object on hs-x deploy --portal-schema-live --apply-schema; it is not an app object (the app objects guide covers the difference).
  • false — HS-X touches no portal schema; you own it. This is the default: leaving the field off means HS-X never reads or mutates the portal.

The reconciliation is explicit: hs-x deploy --portal-schema-live diffs your declaration against the portal and prints a WILL CREATE / WILL ALTER plan line per difference, and adding --apply-schema applies that plan. --apply-schema needs a personal access key with the property-settings write scope for the object, is skipped under --plan, and refuses to run when the plan contains errors (for example a missing custom object under manageSchema: "properties").

What the runtime does with your rows

Between your source returning rows and records appearing in HubSpot, the runtime in your Cloudflare account does the unglamorous work. Rows are written in chunks of 100 through HubSpot’s batch upsert endpoint, keyed on idProperty. 429, 423, and 5xx responses are retried with backoff; rows that fail validation or hit a permanent 4xx go to a poison-row queue in tenant D1 instead of stalling the run. Each (install, sync) pair runs under a single-winner lease, so an overlapping cron tick exits cleanly instead of running twice, and every delivered chunk is checkpointed so an evicted isolate resumes rather than restarts. Writes are paced through the same hierarchical rate limiter every other capability shares; the rate limits guide covers how that budget is split when a sync and a workflow action want the same portal at the same time.

Once linked and deployed, the cron trigger, the run grant, and the state all live in your Cloudflare account; HS-X’s control plane is not on the path of a scheduled run. The cursor state, the destination data, and the Worker are yours.

Inspect, preview, run, pause, and recover

Linked deploys keep an operator-visible run record in tenant D1 for every portal run: start and finish time, trigger (scheduled, manual, or webhook), outcome, pages, delivered rows, quarantined rows, target object, and the terminal error. The CLI and dashboard read that same tenant-owned state through short-lived deployment-bound grants; neither creates a control-plane shadow copy.

Start with status and recent runs:

hs-x sync status suppression-list --portal 12345678
hs-x sync runs suppression-list --portal 12345678 --limit 20
hs-x sync logs suppression-list <run-id> --portal 12345678
hs-x sync state get suppression-list --portal 12345678
hs-x sync poison list suppression-list --portal 12345678

status combines the current cursor/lease, pause state, latest run, and poison count. state get also exposes in-flight chunk checkpoints. logs is the durable lifecycle timeline derived from the run record, so it remains available after Cloudflare Worker log retention expires. Use the project’s Syncs dashboard tab for the same status, checkpoint, and run-history view.

A manual run uses the same one-time signed route as generated cron handlers:

hs-x sync run suppression-list --portal 12345678

For a declarative pull source, add --preview first. Preview fetches and locally validates one source page against the mapping, but does not acquire a lease, write HubSpot, update cursor/checkpoints, meter rows, or touch the poison queue:

hs-x sync run suppression-list --portal 12345678 --preview --limit 20

Push sources and custom handlers cannot be previewed truthfully without replaying developer code, so HS-X refuses that shape instead of pretending it is dry-run safe.

Recovery writes require an interactive default-No confirmation. CI, JSON, and non-interactive runs must pass --yes explicitly:

hs-x sync state pause suppression-list --portal 12345678
hs-x sync state resume suppression-list --portal 12345678
hs-x sync state reset suppression-list --portal 12345678
hs-x sync poison retry suppression-list <row-key> --portal 12345678

Pause blocks future lease acquisitions while allowing a run that already holds the fencing token to finish safely. Reset clears the cursor, checkpoints, attempts, and last error but preserves monotonic fencing; it refuses a live lease, and the next run starts from the source’s initial cursor. Poison retry revalidates and upserts exactly one quarantined row. HS-X deletes that DLQ row only after HubSpot confirms success; validation, permanent, and transient failures leave the evidence in place.

The HS-X CRM destination is a separate, layered projection

The worker.sync primitive on this page belongs to one app and runs in that app's tenant Worker. The HS-X CRM destination in account settings is different: it gives the team building several apps one shared HubSpot view of their customer portals. It runs in the HS-X control plane, while signed, scope-limited reads fetch installer users and lifecycle occurrences from tenant D1 only for the current request. Customer OAuth tokens and decrypted install secrets never enter the control plane.

That account projection has two parts:

  • Standard objects — always on, no special HubSpot approval. One Company represents a customer portal, keyed by unique hsx_portal_id; installer Contacts attach with an Installer association label. Versioned hsx_ properties roll up install lifecycle, enabled flags, and optional billing state across every enabled project. Sparse Notes record meaningful lifecycle and invoice transitions.
  • App objects — additive and approval-gated. App Installation, Feature Flag, Billing Subscription, and Billing Invoice app objects add per-install, catalog, and reportable row detail when their schema and conditional OAuth scopes are present. If either is absent, that writer no-ops. The standard-object records are always computed from source records, never from the app objects, so approval cannot change their values.

Connect the destination under Account settings → HubSpot CRM destination, preview and repair the additive property catalog, then enable Company, Contact, billing, or event projection per project. A run upserts Companies by hsx_portal_id and Contacts by email, skips unchanged hashes, retries rate-limit and server failures, and surfaces permanent validation errors instead of wedging the destination.

This account feature never creates portal custom objects. The manageSchema: "full" mode described above is still available for a developer-authored project sync whose destination portal owns its own custom-object shape; it is not used by the HS-X CRM destination.

Test it before it touches a portal

hs-x init customer-sync --type sync-source
cd customer-sync && bun install
hs-x check                 # validates the project and app config
hs-x deploy --plan --portal-schema-live   # diffs the schema declaration against the portal
hs-x dev                   # local dev server + live logs

hs-x check validates the project and app configuration; it does not inspect sync schemas or idProperty. Schedules are rejected by the SDK as soon as the worker module loads, and schema-versus-portal drift only shows up under hs-x deploy --plan --portal-schema-live. hs-x dev invoke <sync-id> dispatches the sync through the production router on your machine with fixture input, which exercises a custom handler but not a source-backed delivery; hs-x dev invoke <sync-id> --remote dispatches at the deployed Worker, where install tokens live. The local dev guide covers that loop in depth.