All case studies

Keeping HubSpot and Stripe in sync, both ways, without losing events

A dothecode demo: a fictional subscription SaaS where billing lives in Stripe and the commercial relationship lives in HubSpot, kept in sync in both directions — with field ownership, a durable event inbox and periodic reconciliation.

Our own demo — a subscription SaaS that sells through HubSpot and bills through Stripe

The brief

A subscription business runs on two systems that each know half the story. Stripe knows who is paying, who is still on trial and whose card just bounced. HubSpot knows who the person is, what stage the deal is in and which emails they have received. When the two don't talk, sales calls a delinquent customer as if they were a fresh lead, and marketing sends a welcome offer to someone who cancelled last week.

We wanted something a prospect could actually click through rather than a slide about integrations. So we built the thing we would build for a client: a two-way sync where every field has one clear owner, where a dropped webhook doesn't leave the two systems disagreeing, and where a salesperson can trigger a billing action from the CRM without ever opening Stripe.

We also wanted the lifecycle emails — welcome, trial ending, payment failed, win-back — to go out from HubSpot driven by real billing data, with marketing consent honoured on both sides.

There was no client, so we built the SaaS too: signup with double opt-in, a fourteen-day free trial, Stripe checkout, and a panel where the visitor watches, live, every call travelling between the three systems. The HubSpot account is a developer account and Stripe runs in test mode. It is a demonstration, and we say so.

How we built it

  1. One registry of fields, with an owner for each

    Billing data — status, plan, trial end — is written by Stripe through Django and is read-only in HubSpot. The retention offer is written by a person in HubSpot and travels to Stripe. All of that lives in a single field registry, and from it we generate the HubSpot schema, the mapping and the webhook subscriptions. One list to maintain instead of three that quietly drift apart. On the HubSpot side there is a custom 'Stripe Subscription' object linked to the contact, created over the API by an idempotent bootstrap command, plus a summary property on the contact so HubSpot workflows have something to react to.

  2. An event inbox instead of webhook handlers that do work

    Every webhook from Stripe or HubSpot is verified — HMAC signature, five-minute window, constant-time comparison — stored once per external identifier and queued. The webhook endpoint does nothing else. Processing runs with up to eight retries and exponential backoff, respects Retry-After on 429s, and a sweeper every five minutes requeues anything the broker lost. If HubSpot is disconnected or broken, events wait rather than fail or die, and resume on their own once the connection is restored. A marketing-consent failure never blocks billing sync.

  3. The return path: CRM action, Stripe effect

    When someone changes the retention offer on a contact in HubSpot, HubSpot notifies Django and Django applies or removes a coupon in Stripe. It is idempotent by state, it keeps existing discounts by id and it won't re-grant an expired coupon. The point is that a salesperson acts where they already work, and nobody needs a Stripe login to do their job.

  4. Reconciliation as the safety net

    UI permissions in HubSpot only lock the screen; they don't stop an API call or an import. So a periodic reconciliation job — single-run advisory lock, cursor with a safety margin — catches Stripe events that never arrived, finds manual edits made in HubSpot to fields it doesn't own and reverts them while recording who made them, and picks up unsubscribes. The screen can be bypassed; the reconciliation can't.

  5. Credentials, consent and logs treated as part of the build

    OAuth tokens are encrypted at rest with rotatable keys and stored over a dedicated database connection, so a refresh commits even when the caller is inside a transaction; refresh happens under a row lock, with a five-minute brake when HubSpot rejects the configuration, and the authorization code never travels in redirects or logs. Marketing consent syncs both ways, and an unsubscribe made in HubSpot always wins. Emails, tokens and tracebacks are masked in logs — including inside the HTTP library, which otherwise writes email addresses into URLs.

  6. Lifecycle email defined as code

    One template, two dynamic lists, four emails and four workflows — welcome, trial ending, payment failed with an exit when the payment recovers, win-back after seven days — all created and repaired by an idempotent command. Nothing in the HubSpot account was clicked together by hand, which is also what makes the whole demo reproducible elsewhere.

Results

Online since 12 October 2026

The demo is live with access granted on request, behind an access key.

Both directions verified with test payments: a signup ends up as a trial contact with its subscription object attached in HubSpot, and an offer change in the CRM ends up as a coupon in Stripe.

2 real events

With HubSpot disconnected, Stripe events waited instead of being lost, and resumed on their own once the connection was made.

~370 automated tests across 48 files

Plus two rounds of security and code review per stage of the build.

2 commands

Everything the demo creates in HubSpot — custom object, properties, lists, emails, workflows — is created by code and can be recreated in another account.

Stack

  • Python 3.13
  • Django 6.1
  • Celery 5.6 + Redis
  • PostgreSQL 18
  • HubSpot (OAuth, CRM API, custom objects, webhooks, lists, emails and workflows over the API)
  • Stripe (Checkout, subscriptions, coupons, webhooks)
  • Resend (transactional email)
  • httpx
  • pytest
  • Docker

Need a tool like this?

Tell us what you need and we'll tell you how we'd approach it. We reply within one business day.

Start a project