Syncing Stripe Customers to HubSpot with Python
You have paying customers in Stripe and a sales pipeline in HubSpot, and right now the two disagree about who exists. This page resolves one specific choice: how to build a one-way Stripe to HubSpot sync that keeps working when a batch half-fails at 3am, instead of a script that silently creates duplicate contacts every time it retries. Part of the Connecting CRM & Email APIs guide.
One-way is the right default and you should not argue with it. Bidirectional sync between a billing system and a CRM means every field needs a conflict-resolution rule, a last-writer timestamp, and a loop breaker — that is a week of work and a permanent source of 2am pages. Stripe is the system of record for billing identity, HubSpot is the system of record for sales activity, and the arrow points one way. Everything below assumes that.
The shape of a sync that survives partial failure
Five moving parts do the work: a watermark that remembers what you already processed, a reader that pulls changes out of Stripe, a mapper that turns a Stripe customer into HubSpot properties, a batcher that writes to HubSpot inside its rate limit, and a dead-letter table for the records that failed anyway. The dead-letter table is the part builders skip and the part that saves you. Without it, a single malformed email address kills a run of 4,000 records and you have no idea which ones landed.
Every stage must be independently restartable. If the batcher dies, rerunning must not double-write. If the mapper raises on one record, the other ninety-nine in that batch must still ship. That is the whole design brief.
Field mapping: decide what HubSpot is allowed to own
Map fewer fields than you think. Every property you sync is a property you must keep correct forever, and HubSpot workflows will start depending on it within a week. Sync billing identity and billing state; do not sync anything sales will edit by hand.
| Stripe source | HubSpot property | Owner after sync |
|---|---|---|
id | stripe_customer_id | Stripe (unique key) |
email | email | Stripe |
name | firstname / lastname | Stripe |
metadata.plan | subscription_plan | Stripe |
| — | lifecyclestage | HubSpot |
The critical row is the first one. Create stripe_customer_id in HubSpot as a single-line text property with unique values enabled, under a property group you own. That checkbox is what makes idempotent upserts possible, and you cannot add it retroactively to a property that already holds duplicates — so create it clean, before the first backfill.
Splitting name into first and last is lossy and everyone does it badly. Take the conservative approach: split on the first space, put the remainder in lastname, and never write back. Validate the mapped payload with a model rather than trusting Stripe's shape, using the same discipline as validating JSON with Pydantic v2.
import os
from pydantic import BaseModel, EmailStr, Field
PLAN_PROPERTY = os.getenv("HUBSPOT_PLAN_PROPERTY", "subscription_plan")
ID_PROPERTY = os.getenv("HUBSPOT_ID_PROPERTY", "stripe_customer_id")
class ContactProperties(BaseModel):
email: EmailStr
firstname: str = ""
lastname: str = ""
stripe_customer_id: str = Field(min_length=3)
subscription_plan: str = ""
def map_customer(customer: dict) -> dict:
"""Turn a Stripe customer object into a HubSpot upsert input."""
full_name = (customer.get("name") or "").strip()
first, _, last = full_name.partition(" ")
props = ContactProperties(
email=customer["email"],
firstname=first,
lastname=last,
stripe_customer_id=customer["id"],
subscription_plan=(customer.get("metadata") or {}).get("plan", ""),
)
payload = props.model_dump()
payload[PLAN_PROPERTY] = payload.pop("subscription_plan")
return {"idProperty": ID_PROPERTY, "id": customer["id"], "properties": payload}
A customer with no email raises a validation error here, in your code, where you can route it to the dead-letter table with a reason string. That is far better than HubSpot rejecting the whole batch with a message you have to reverse-engineer.
Upsert by external id, never search-then-create
The naive pattern is: search HubSpot for the email, create if missing, patch if found. It costs two API calls per record, races against itself under concurrency, and creates duplicates the moment a customer changes their email in Stripe. Use HubSpot's batch upsert endpoint with idProperty set to your unique stripe_customer_id instead. One call handles a hundred records, matching on an id that never changes.
A partial batch failure returns HTTP 207 with a per-record breakdown. Treat that as the normal case, not an exception. The writer below sends one hundred inputs per call, keeps a semaphore over concurrent calls, and returns the rows it could not write.
import asyncio
import os
import httpx
HUBSPOT_BASE = os.getenv("HUBSPOT_API_BASE", "https://api.hubapi.com")
HUBSPOT_TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BATCH_SIZE = int(os.getenv("SYNC_BATCH_SIZE", "100"))
CONCURRENCY = int(os.getenv("SYNC_CONCURRENCY", "4"))
async def upsert_batch(client: httpx.AsyncClient, inputs: list[dict]) -> list[dict]:
url = f"{HUBSPOT_BASE}/crm/v3/objects/contacts/batch/upsert"
response = await client.post(url, json={"inputs": inputs})
match response.status_code:
case 200:
return []
case 207:
body = response.json()
return [e for e in body.get("errors", [])]
case 429:
retry_after = float(response.headers.get("Retry-After", "10"))
await asyncio.sleep(retry_after)
return await upsert_batch(client, inputs)
case _:
response.raise_for_status()
return []
async def write_all(rows: list[dict]) -> list[dict]:
headers = {"Authorization": f"Bearer {HUBSPOT_TOKEN}"}
gate = asyncio.Semaphore(CONCURRENCY)
batches = [rows[i:i + BATCH_SIZE] for i in range(0, len(rows), BATCH_SIZE)]
async with httpx.AsyncClient(headers=headers, timeout=30.0) as client:
async def run(batch: list[dict]) -> list[dict]:
async with gate:
return await upsert_batch(client, batch)
results = await asyncio.gather(*(run(b) for b in batches))
return [err for group in results for err in group]
Using httpx rather than requests matters here because the batches are independent and the whole run is network-bound.
Batching against HubSpot's rate limits
HubSpot's private app limit is roughly 110 requests per ten seconds per app, plus a daily cap that depends on your tier. Those two numbers dictate your entire throughput budget, so compute against them before tuning anything. One record per call at four thousand customers is around 400 requests per ten-second window — four times over the ceiling, and you will spend the run in 429 backoff. Batching by one hundred drops it to sixteen requests per window at a leisurely pace, and even six concurrent batch calls stays comfortably under the line.
Read X-HubSpot-RateLimit-Remaining on every response and log it. When remaining drops under about twenty, sleep until the window rolls rather than sprinting into a 429. If you want the general shape of that control loop, the guidance on API rate limiting best practices applies directly, and the walkthrough on debugging 429 errors covers what to do when the header lies. Wrap transport-level failures with tenacity retries — but only connection errors and 5xx, never a 207.
Backfill once, then run incrementally
These are two different programs sharing one mapper, and conflating them is the most common way this project goes wrong. The backfill walks /v1/customers from the beginning of time with cursor pagination and writes everything. The incremental job reads /v1/events filtered to customer types since your last watermark, which is far cheaper because most days almost nothing changes.
Always subtract a lookback from the watermark — five minutes is plenty. Overlap is free because the upsert is idempotent, whereas a gap means a customer silently never reaches HubSpot. Stripe retains events for thirty days, so if your job is down longer than that you must fall back to a full backfill. Encode that rule in the job itself rather than in a runbook nobody reads.
import os
import time
import httpx
STRIPE_BASE = os.getenv("STRIPE_API_BASE", "https://api.stripe.com")
STRIPE_KEY = os.environ["STRIPE_SECRET_KEY"]
LOOKBACK_SECONDS = int(os.getenv("SYNC_LOOKBACK_SECONDS", "300"))
EVENT_TYPES = ("customer.created", "customer.updated")
async def fetch_changed_customers(since_ts: int) -> list[dict]:
"""Return Stripe customer objects changed since a watermark timestamp."""
auth = (STRIPE_KEY, "")
params: dict[str, object] = {
"limit": 100,
"created[gte]": max(0, since_ts - LOOKBACK_SECONDS),
}
seen: dict[str, dict] = {}
async with httpx.AsyncClient(auth=auth, timeout=30.0) as client:
for event_type in EVENT_TYPES:
cursor: str | None = None
while True:
query = {**params, "type": event_type}
if cursor:
query["starting_after"] = cursor
r = await client.get(f"{STRIPE_BASE}/v1/events", params=query)
r.raise_for_status()
page = r.json()
for event in page["data"]:
obj = event["data"]["object"]
seen[obj["id"]] = obj
if not page.get("has_more"):
break
cursor = page["data"][-1]["id"]
return list(seen.values())
def now_watermark() -> int:
return int(time.time())
Deduplicating into a dict keyed by customer id matters: a customer updated eight times in the window becomes one upsert, not eight. Schedule the incremental run with whatever you already operate — the trade-offs are covered in APScheduler vs Celery Beat, and the broader patterns live in scheduling data pipelines with cron. If you prefer push over poll, verifying Stripe webhook signatures plus an idempotent webhook receiver gives you sub-second latency — but keep the polling job anyway as the safety net.
Reconciliation: prove the two systems agree
A sync you cannot audit is a sync you do not trust. Run a nightly reconciliation that counts Stripe customers with an email against HubSpot contacts holding a non-empty stripe_customer_id, then classifies the difference. Three categories cover almost everything: missing in HubSpot, stale properties, and orphaned contacts whose Stripe customer was deleted. Each has a different remedy, and the report should say which.
Never let the reconciler delete HubSpot contacts. A deleted Stripe customer often means a churned account that sales still wants in the pipeline, and an automated delete loop is an unrecoverable mistake. Flag and report. Emit the counts as structured events so you can chart drift over time — the approach in structured logging with structlog works well, and a persistent drift above about 0.5 percent means your mapper is rejecting records you have not looked at.
When to build this and when to buy it
Build the Python sync when you have more than a few hundred customers, when you need custom properties Zapier cannot express cleanly, or when per-task pricing starts to bite. A Zapier task fires per record; at 4,000 customers plus daily updates you are into a paid tier fast, and the marginal cost keeps climbing with your customer count. Your own worker costs a few dollars a month of compute forever, regardless of volume.
Avoid building when you sync fewer than a hundred customers and nothing else in your stack is Python, or when nobody on the team will own the dead-letter table. An unmonitored custom sync is worse than a hosted one, because it fails quietly. The fuller argument sits in Zapier vs Make vs Python.
Migrating off a hosted connector is a four-step job. First, create the unique stripe_customer_id property and pause the hosted sync. Second, run a one-off script that backfills the id onto existing HubSpot contacts by matching on email, so your upsert key exists before you use it. Third, run the full backfill in dry-run mode and diff the payloads against live values until the diff is boring. Fourth, enable the incremental job on a fifteen-minute schedule, watch reconciliation for a week, then delete the hosted connector.
Builder verdict
Batch upsert by external id wins, and it is not close. Forty API calls instead of eight thousand, zero duplicate risk, per-record error reporting for free, and a rate-limit budget you barely touch. The extra work over search-then-create is one HubSpot property and about thirty lines of Python — call it two hours — and it buys you a sync you can restart at any point without thinking. Run the backfill once, poll events every fifteen minutes, reconcile nightly, and give yourself a dead-letter table you actually read on Mondays. Total operating cost is a small worker and a Postgres table; the alternative is per-task connector pricing that scales with the exact number you are trying to grow. Ship the custom sync as soon as billing data starts driving sales decisions, and treat webhooks as a latency optimisation you add later, not the foundation.
FAQ
What does this sync cost to operate at 10,000 customers? A backfill of 10,000 records is 100 batch calls, finishing in about a minute. The incremental job typically touches a few dozen records per run, so 96 runs a day cost well under 200 API calls total. That fits on the smallest worker any host sells — three to seven dollars a month — plus a Postgres table for watermarks and dead letters. Compare that to per-task connector pricing, which charges you for records that did not change.
Will this blow through HubSpot's daily API cap? Not at this pattern. A Professional tier private app allows hundreds of thousands of calls a day, and a batched sync at 10,000 customers uses a few hundred. The cap only becomes a real constraint if you drop back to one call per record or poll HubSpot for reads on every run. Keep reads out of the hot path by trusting the upsert to be authoritative.
How do I rotate the HubSpot private app token without a failed run? Read the token from the environment at process start, never from a file baked into the image, and deploy tokens as two env vars with a primary and fallback so a 401 on the primary retries with the fallback once. Then rotate, redeploy, and drop the old value. The same overlap technique used for rotating API keys without downtime applies to Stripe restricted keys too.
What is the migration risk if I later want two-way sync?
Low, if you keep stripe_customer_id unique and never write HubSpot-owned fields such as lifecyclestage from the sync. Those two rules mean a future reverse path has a clean join key and a clear ownership boundary. The expensive mistake is letting the sync overwrite fields sales edits by hand, because you then need conflict resolution before you can add any reverse flow at all.
Should I use Stripe webhooks instead of polling events? Use both. Webhooks give you sub-second updates and cost nothing extra, but a dropped delivery or a deploy window leaves permanent gaps. Keep the fifteen-minute event poll running as a reconciler; it costs a handful of API calls and it is the only thing that catches what webhooks lost.
Related
- Connecting CRM & Email APIs — the parent guide covering CRM and email integration patterns end to end.
- Building an Idempotent Webhook Receiver — the push-based counterpart to this polling job, with deduplication built in.
- Sync Shopify Orders to Google Sheets via API — the same watermark and batching pattern applied to a different pair of systems.
- Integrating Stripe with Python APIs — the billing side of the pipeline, including customer objects and subscription state.
- Mocking External APIs with respx — how to test the 207 and 429 branches of the batch writer without touching a live HubSpot account.