[{"data":1,"prerenderedAt":2511},["ShallowReactive",2],{"page-\u002Fautomating-side-hustle-operations-with-apis\u002Fconnecting-crm-email-apis\u002Fsyncing-stripe-customers-to-hubspot-with-python\u002F":3,"faq-schema-\u002Fautomating-side-hustle-operations-with-apis\u002Fconnecting-crm-email-apis\u002Fsyncing-stripe-customers-to-hubspot-with-python\u002F":2490},{"id":4,"title":5,"body":6,"description":2481,"extension":2482,"meta":2483,"navigation":362,"path":2486,"seo":2487,"stem":2488,"__hash__":2489},"content\u002Fautomating-side-hustle-operations-with-apis\u002Fconnecting-crm-email-apis\u002Fsyncing-stripe-customers-to-hubspot-with-python\u002Findex.md","Syncing Stripe Customers to HubSpot with Python",{"type":7,"value":8,"toc":2469},"minimark",[9,13,28,31,36,39,42,193,197,200,298,308,322,758,761,765,775,864,867,1456,1464,1468,1471,1555,1577,1581,1591,1682,1685,2218,2241,2245,2251,2345,2353,2357,2360,2367,2373,2377,2380,2384,2390,2396,2407,2419,2425,2429,2465],[10,11,5],"h1",{"id":12},"syncing-stripe-customers-to-hubspot-with-python",[14,15,16,17,21,22,27],"p",{},"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 ",[18,19,20],"strong",{},"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 ",[23,24,26],"a",{"href":25},"\u002Fautomating-side-hustle-operations-with-apis\u002Fconnecting-crm-email-apis\u002F","Connecting CRM & Email APIs"," guide.",[14,29,30],{},"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.",[32,33,35],"h2",{"id":34},"the-shape-of-a-sync-that-survives-partial-failure","The shape of a sync that survives partial failure",[14,37,38],{},"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.",[14,40,41],{},"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.",[43,44,52,53,52,57,52,61,52,80,52,87,52,95,52,103,52,109,52,114,52,121,52,125,52,129,52,132,52,136,52,140,52,144,52,147,52,151,52,154,52,158,52,161,52,164,52,169,52,174,52,178,52,180,52,182,52,185,52,188],"svg",{"viewBox":45,"role":46,"ariaLabelledBy":47,"xmlns":50,"style":51},"0 0 720 300","img",[48,49],"sths-arch-t","sths-arch-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[54,55,56],"title",{"id":48},"One-way Stripe to HubSpot sync pipeline",[58,59,60],"desc",{"id":49},"Stripe events feed a field mapper, which feeds a batch upsert writer, which writes to HubSpot. A watermark store sits under the reader and a dead-letter table under the writer.",[62,63,64,65,52],"defs",{},"\n    ",[66,67,74,75,64],"marker",{"id":68,"viewBox":69,"refX":70,"refY":71,"markerWidth":72,"markerHeight":72,"orient":73},"sths-arch-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[76,77],"path",{"d":78,"fill":79},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[81,82],"rect",{"x":83,"y":83,"width":84,"height":85,"fill":86},"0","720","300","var(--c-surface)",[88,89,94],"text",{"x":90,"y":91,"fill":92,"style":93},"20","30","var(--c-text)","font-size:14;font-family:var(--font-sans);","Restartable stages, one direction",[81,96],{"x":90,"y":97,"width":98,"height":99,"rx":100,"fill":86,"stroke":101,"style":102},"60","140","58","10","var(--c-blue)","stroke-width:2;",[88,104,108],{"x":105,"y":106,"fill":92,"style":107},"90","84","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Stripe reader",[88,110,113],{"x":105,"y":111,"fill":79,"style":112},"102","text-anchor:middle;font-size:11;font-family:var(--font-sans);","\u002Fv1\u002Fevents",[115,116],"line",{"x1":117,"y1":118,"x2":119,"y2":118,"stroke":79,"style":120},"160","89","185","stroke-width:2;marker-end:url(#sths-arch-arrow);",[81,122],{"x":123,"y":97,"width":98,"height":99,"rx":100,"fill":86,"stroke":124,"style":102},"190","var(--c-teal)",[88,126,128],{"x":127,"y":106,"fill":92,"style":107},"260","Field mapper",[88,130,131],{"x":127,"y":111,"fill":79,"style":112},"pydantic model",[115,133],{"x1":134,"y1":118,"x2":135,"y2":118,"stroke":79,"style":120},"330","355",[81,137],{"x":138,"y":97,"width":98,"height":99,"rx":100,"fill":86,"stroke":139,"style":102},"360","var(--c-coral)",[88,141,143],{"x":142,"y":106,"fill":92,"style":107},"430","Batch writer",[88,145,146],{"x":142,"y":111,"fill":79,"style":112},"100 per call",[115,148],{"x1":149,"y1":118,"x2":150,"y2":118,"stroke":79,"style":120},"500","525",[81,152],{"x":153,"y":97,"width":98,"height":99,"rx":100,"fill":86,"stroke":101,"style":102},"530",[88,155,157],{"x":156,"y":106,"fill":92,"style":107},"600","HubSpot CRM",[88,159,160],{"x":156,"y":111,"fill":79,"style":112},"contacts",[115,162],{"x1":105,"y1":163,"x2":105,"y2":119,"stroke":79,"style":120},"118",[81,165],{"x":90,"y":123,"width":98,"height":166,"rx":100,"fill":167,"stroke":168,"style":102},"54","var(--c-surface-alt)","var(--c-border)",[88,170,173],{"x":105,"y":171,"fill":92,"style":172},"212","text-anchor:middle;font-size:12;font-family:var(--font-sans);","Watermark store",[88,175,177],{"x":105,"y":176,"fill":79,"style":112},"230","last event id",[115,179],{"x1":142,"y1":163,"x2":142,"y2":119,"stroke":79,"style":120},[81,181],{"x":138,"y":123,"width":98,"height":166,"rx":100,"fill":167,"stroke":168,"style":102},[88,183,184],{"x":142,"y":171,"fill":92,"style":172},"Dead letters",[88,186,187],{"x":142,"y":176,"fill":79,"style":112},"per-record errors",[88,189,192],{"x":90,"y":190,"fill":79,"style":191},"275","font-size:11;font-family:var(--font-sans);","Watermark advances only after a batch is fully acknowledged.",[32,194,196],{"id":195},"field-mapping-decide-what-hubspot-is-allowed-to-own","Field mapping: decide what HubSpot is allowed to own",[14,198,199],{},"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.",[201,202,203,219],"table",{},[204,205,206],"thead",{},[207,208,209,213,216],"tr",{},[210,211,212],"th",{},"Stripe source",[210,214,215],{},"HubSpot property",[210,217,218],{},"Owner after sync",[220,221,222,239,253,271,285],"tbody",{},[207,223,224,231,236],{},[225,226,227],"td",{},[228,229,230],"code",{},"id",[225,232,233],{},[228,234,235],{},"stripe_customer_id",[225,237,238],{},"Stripe (unique key)",[207,240,241,246,250],{},[225,242,243],{},[228,244,245],{},"email",[225,247,248],{},[228,249,245],{},[225,251,252],{},"Stripe",[207,254,255,260,269],{},[225,256,257],{},[228,258,259],{},"name",[225,261,262,265,266],{},[228,263,264],{},"firstname"," \u002F ",[228,267,268],{},"lastname",[225,270,252],{},[207,272,273,278,283],{},[225,274,275],{},[228,276,277],{},"metadata.plan",[225,279,280],{},[228,281,282],{},"subscription_plan",[225,284,252],{},[207,286,287,290,295],{},[225,288,289],{},"—",[225,291,292],{},[228,293,294],{},"lifecyclestage",[225,296,297],{},"HubSpot",[14,299,300,301,303,304,307],{},"The critical row is the first one. Create ",[228,302,235],{}," in HubSpot as a single-line text property with ",[18,305,306],{},"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.",[14,309,310,311,313,314,316,317,321],{},"Splitting ",[228,312,259],{}," into first and last is lossy and everyone does it badly. Take the conservative approach: split on the first space, put the remainder in ",[228,315,268],{},", and never write back. Validate the mapped payload with a model rather than trusting Stripe's shape, using the same discipline as ",[23,318,320],{"href":319},"\u002Fgetting-started-with-python-apis-for-builders\u002Fparsing-json-responses\u002Fvalidating-json-with-pydantic-v2\u002F","validating JSON with Pydantic v2",".",[323,324,329],"pre",{"className":325,"code":326,"language":327,"meta":328,"style":328},"language-python shiki shiki-themes github-light github-dark","import os\nfrom pydantic import BaseModel, EmailStr, Field\n\nPLAN_PROPERTY = os.getenv(\"HUBSPOT_PLAN_PROPERTY\", \"subscription_plan\")\nID_PROPERTY = os.getenv(\"HUBSPOT_ID_PROPERTY\", \"stripe_customer_id\")\n\n\nclass ContactProperties(BaseModel):\n    email: EmailStr\n    firstname: str = \"\"\n    lastname: str = \"\"\n    stripe_customer_id: str = Field(min_length=3)\n    subscription_plan: str = \"\"\n\n\ndef map_customer(customer: dict) -> dict:\n    \"\"\"Turn a Stripe customer object into a HubSpot upsert input.\"\"\"\n    full_name = (customer.get(\"name\") or \"\").strip()\n    first, _, last = full_name.partition(\" \")\n    props = ContactProperties(\n        email=customer[\"email\"],\n        firstname=first,\n        lastname=last,\n        stripe_customer_id=customer[\"id\"],\n        subscription_plan=(customer.get(\"metadata\") or {}).get(\"plan\", \"\"),\n    )\n    payload = props.model_dump()\n    payload[PLAN_PROPERTY] = payload.pop(\"subscription_plan\")\n    return {\"idProperty\": ID_PROPERTY, \"id\": customer[\"id\"], \"properties\": payload}\n","python","",[228,330,331,343,357,364,390,410,415,420,439,445,459,471,496,508,513,518,541,547,573,589,600,617,628,639,654,686,692,703,723],{"__ignoreMap":328},[332,333,335,339],"span",{"class":115,"line":334},1,[332,336,338],{"class":337},"szBVR","import",[332,340,342],{"class":341},"sVt8B"," os\n",[332,344,346,349,352,354],{"class":115,"line":345},2,[332,347,348],{"class":337},"from",[332,350,351],{"class":341}," pydantic ",[332,353,338],{"class":337},[332,355,356],{"class":341}," BaseModel, EmailStr, Field\n",[332,358,360],{"class":115,"line":359},3,[332,361,363],{"emptyLinePlaceholder":362},true,"\n",[332,365,367,371,374,377,381,384,387],{"class":115,"line":366},4,[332,368,370],{"class":369},"sj4cs","PLAN_PROPERTY",[332,372,373],{"class":337}," =",[332,375,376],{"class":341}," os.getenv(",[332,378,380],{"class":379},"sZZnC","\"HUBSPOT_PLAN_PROPERTY\"",[332,382,383],{"class":341},", ",[332,385,386],{"class":379},"\"subscription_plan\"",[332,388,389],{"class":341},")\n",[332,391,393,396,398,400,403,405,408],{"class":115,"line":392},5,[332,394,395],{"class":369},"ID_PROPERTY",[332,397,373],{"class":337},[332,399,376],{"class":341},[332,401,402],{"class":379},"\"HUBSPOT_ID_PROPERTY\"",[332,404,383],{"class":341},[332,406,407],{"class":379},"\"stripe_customer_id\"",[332,409,389],{"class":341},[332,411,413],{"class":115,"line":412},6,[332,414,363],{"emptyLinePlaceholder":362},[332,416,418],{"class":115,"line":417},7,[332,419,363],{"emptyLinePlaceholder":362},[332,421,423,426,430,433,436],{"class":115,"line":422},8,[332,424,425],{"class":337},"class",[332,427,429],{"class":428},"sScJk"," ContactProperties",[332,431,432],{"class":341},"(",[332,434,435],{"class":428},"BaseModel",[332,437,438],{"class":341},"):\n",[332,440,442],{"class":115,"line":441},9,[332,443,444],{"class":341},"    email: EmailStr\n",[332,446,448,451,454,456],{"class":115,"line":447},10,[332,449,450],{"class":341},"    firstname: ",[332,452,453],{"class":369},"str",[332,455,373],{"class":337},[332,457,458],{"class":379}," \"\"\n",[332,460,462,465,467,469],{"class":115,"line":461},11,[332,463,464],{"class":341},"    lastname: ",[332,466,453],{"class":369},[332,468,373],{"class":337},[332,470,458],{"class":379},[332,472,474,477,479,481,484,488,491,494],{"class":115,"line":473},12,[332,475,476],{"class":341},"    stripe_customer_id: ",[332,478,453],{"class":369},[332,480,373],{"class":337},[332,482,483],{"class":341}," Field(",[332,485,487],{"class":486},"s4XuR","min_length",[332,489,490],{"class":337},"=",[332,492,493],{"class":369},"3",[332,495,389],{"class":341},[332,497,499,502,504,506],{"class":115,"line":498},13,[332,500,501],{"class":341},"    subscription_plan: ",[332,503,453],{"class":369},[332,505,373],{"class":337},[332,507,458],{"class":379},[332,509,511],{"class":115,"line":510},14,[332,512,363],{"emptyLinePlaceholder":362},[332,514,516],{"class":115,"line":515},15,[332,517,363],{"emptyLinePlaceholder":362},[332,519,521,524,527,530,533,536,538],{"class":115,"line":520},16,[332,522,523],{"class":337},"def",[332,525,526],{"class":428}," map_customer",[332,528,529],{"class":341},"(customer: ",[332,531,532],{"class":369},"dict",[332,534,535],{"class":341},") -> ",[332,537,532],{"class":369},[332,539,540],{"class":341},":\n",[332,542,544],{"class":115,"line":543},17,[332,545,546],{"class":379},"    \"\"\"Turn a Stripe customer object into a HubSpot upsert input.\"\"\"\n",[332,548,550,553,555,558,561,564,567,570],{"class":115,"line":549},18,[332,551,552],{"class":341},"    full_name ",[332,554,490],{"class":337},[332,556,557],{"class":341}," (customer.get(",[332,559,560],{"class":379},"\"name\"",[332,562,563],{"class":341},") ",[332,565,566],{"class":337},"or",[332,568,569],{"class":379}," \"\"",[332,571,572],{"class":341},").strip()\n",[332,574,576,579,581,584,587],{"class":115,"line":575},19,[332,577,578],{"class":341},"    first, _, last ",[332,580,490],{"class":337},[332,582,583],{"class":341}," full_name.partition(",[332,585,586],{"class":379},"\" \"",[332,588,389],{"class":341},[332,590,592,595,597],{"class":115,"line":591},20,[332,593,594],{"class":341},"    props ",[332,596,490],{"class":337},[332,598,599],{"class":341}," ContactProperties(\n",[332,601,603,606,608,611,614],{"class":115,"line":602},21,[332,604,605],{"class":486},"        email",[332,607,490],{"class":337},[332,609,610],{"class":341},"customer[",[332,612,613],{"class":379},"\"email\"",[332,615,616],{"class":341},"],\n",[332,618,620,623,625],{"class":115,"line":619},22,[332,621,622],{"class":486},"        firstname",[332,624,490],{"class":337},[332,626,627],{"class":341},"first,\n",[332,629,631,634,636],{"class":115,"line":630},23,[332,632,633],{"class":486},"        lastname",[332,635,490],{"class":337},[332,637,638],{"class":341},"last,\n",[332,640,642,645,647,649,652],{"class":115,"line":641},24,[332,643,644],{"class":486},"        stripe_customer_id",[332,646,490],{"class":337},[332,648,610],{"class":341},[332,650,651],{"class":379},"\"id\"",[332,653,616],{"class":341},[332,655,657,660,662,665,668,670,672,675,678,680,683],{"class":115,"line":656},25,[332,658,659],{"class":486},"        subscription_plan",[332,661,490],{"class":337},[332,663,664],{"class":341},"(customer.get(",[332,666,667],{"class":379},"\"metadata\"",[332,669,563],{"class":341},[332,671,566],{"class":337},[332,673,674],{"class":341}," {}).get(",[332,676,677],{"class":379},"\"plan\"",[332,679,383],{"class":341},[332,681,682],{"class":379},"\"\"",[332,684,685],{"class":341},"),\n",[332,687,689],{"class":115,"line":688},26,[332,690,691],{"class":341},"    )\n",[332,693,695,698,700],{"class":115,"line":694},27,[332,696,697],{"class":341},"    payload ",[332,699,490],{"class":337},[332,701,702],{"class":341}," props.model_dump()\n",[332,704,706,709,711,714,716,719,721],{"class":115,"line":705},28,[332,707,708],{"class":341},"    payload[",[332,710,370],{"class":369},[332,712,713],{"class":341},"] ",[332,715,490],{"class":337},[332,717,718],{"class":341}," payload.pop(",[332,720,386],{"class":379},[332,722,389],{"class":341},[332,724,726,729,732,735,738,740,742,744,747,749,752,755],{"class":115,"line":725},29,[332,727,728],{"class":337},"    return",[332,730,731],{"class":341}," {",[332,733,734],{"class":379},"\"idProperty\"",[332,736,737],{"class":341},": ",[332,739,395],{"class":369},[332,741,383],{"class":341},[332,743,651],{"class":379},[332,745,746],{"class":341},": customer[",[332,748,651],{"class":379},[332,750,751],{"class":341},"], ",[332,753,754],{"class":379},"\"properties\"",[332,756,757],{"class":341},": payload}\n",[14,759,760],{},"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.",[32,762,764],{"id":763},"upsert-by-external-id-never-search-then-create","Upsert by external id, never search-then-create",[14,766,767,768,771,772,774],{},"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 ",[228,769,770],{},"idProperty"," set to your unique ",[228,773,235],{}," instead. One call handles a hundred records, matching on an id that never changes.",[43,776,52,781,52,784,52,787,52,790,52,796,52,802,52,805,52,809,52,813,52,817,52,821,52,825,52,828,52,831,52,835,52,839,52,842,52,845,52,848,52,852,52,855,52,858,52,860],{"viewBox":777,"role":46,"ariaLabelledBy":778,"xmlns":50,"style":51},"0 0 720 250",[779,780],"sths-upsert-t","sths-upsert-d",[54,782,783],{"id":779},"Search-then-create compared with batch upsert by external id",[58,785,786],{"id":780},"A three-row matrix comparing API calls, duplicate risk and partial-failure behaviour for the two write strategies across four thousand records.",[81,788],{"x":83,"y":83,"width":84,"height":789,"fill":86},"250",[81,791],{"x":90,"y":91,"width":792,"height":793,"rx":794,"fill":167,"stroke":168,"style":795},"200","46","8","stroke-width:1.5;",[88,797,801],{"x":798,"y":799,"fill":79,"style":800},"34","59","font-size:12;font-family:var(--font-sans);","4,000 customers",[81,803],{"x":176,"y":91,"width":804,"height":793,"rx":794,"fill":167,"stroke":139,"style":102},"220",[88,806,808],{"x":807,"y":799,"fill":92,"style":107},"340","Search then create",[81,810],{"x":811,"y":91,"width":812,"height":793,"rx":794,"fill":167,"stroke":124,"style":102},"460","240",[88,814,816],{"x":815,"y":799,"fill":92,"style":107},"580","Batch upsert by id",[115,818],{"x1":90,"y1":819,"x2":820,"y2":819,"stroke":168,"style":795},"86","700",[88,822,824],{"x":798,"y":823,"fill":92,"style":800},"112","API calls",[88,826,827],{"x":807,"y":823,"fill":92,"style":107},"8,000",[88,829,830],{"x":815,"y":823,"fill":92,"style":107},"40",[115,832],{"x1":90,"y1":833,"x2":820,"y2":833,"stroke":168,"style":834},"128","stroke-width:1;",[88,836,838],{"x":798,"y":837,"fill":92,"style":800},"154","Duplicates",[88,840,841],{"x":807,"y":837,"fill":92,"style":172},"likely on retry",[88,843,844],{"x":815,"y":837,"fill":92,"style":172},"impossible",[115,846],{"x1":90,"y1":847,"x2":820,"y2":847,"stroke":168,"style":834},"170",[88,849,851],{"x":798,"y":850,"fill":92,"style":800},"196","Half a batch fails",[88,853,854],{"x":807,"y":850,"fill":92,"style":172},"silent skew",[88,856,857],{"x":815,"y":850,"fill":92,"style":172},"207 with errors",[115,859],{"x1":90,"y1":171,"x2":820,"y2":171,"stroke":168,"style":795},[88,861,863],{"x":90,"y":862,"fill":79,"style":191},"236","HTTP 207 returns a per-record status, so good rows commit while bad rows go to dead letters.",[14,865,866],{},"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.",[323,868,870],{"className":325,"code":869,"language":327,"meta":328,"style":328},"import asyncio\nimport os\nimport httpx\n\nHUBSPOT_BASE = os.getenv(\"HUBSPOT_API_BASE\", \"https:\u002F\u002Fapi.hubapi.com\")\nHUBSPOT_TOKEN = os.environ[\"HUBSPOT_ACCESS_TOKEN\"]\nBATCH_SIZE = int(os.getenv(\"SYNC_BATCH_SIZE\", \"100\"))\nCONCURRENCY = int(os.getenv(\"SYNC_CONCURRENCY\", \"4\"))\n\n\nasync def upsert_batch(client: httpx.AsyncClient, inputs: list[dict]) -> list[dict]:\n    url = f\"{HUBSPOT_BASE}\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\u002Fbatch\u002Fupsert\"\n    response = await client.post(url, json={\"inputs\": inputs})\n    match response.status_code:\n        case 200:\n            return []\n        case 207:\n            body = response.json()\n            return [e for e in body.get(\"errors\", [])]\n        case 429:\n            retry_after = float(response.headers.get(\"Retry-After\", \"10\"))\n            await asyncio.sleep(retry_after)\n            return await upsert_batch(client, inputs)\n        case _:\n            response.raise_for_status()\n            return []\n\n\nasync def write_all(rows: list[dict]) -> list[dict]:\n    headers = {\"Authorization\": f\"Bearer {HUBSPOT_TOKEN}\"}\n    gate = asyncio.Semaphore(CONCURRENCY)\n    batches = [rows[i:i + BATCH_SIZE] for i in range(0, len(rows), BATCH_SIZE)]\n\n    async with httpx.AsyncClient(headers=headers, timeout=30.0) as client:\n        async def run(batch: list[dict]) -> list[dict]:\n            async with gate:\n                return await upsert_batch(client, batch)\n\n        results = await asyncio.gather(*(run(b) for b in batches))\n    return [err for group in results for err in group]\n",[228,871,872,879,885,892,896,915,931,955,976,980,984,1008,1027,1054,1062,1072,1080,1089,1099,1124,1133,1156,1164,1173,1180,1185,1191,1195,1199,1219,1248,1263,1309,1314,1350,1372,1383,1394,1399,1428],{"__ignoreMap":328},[332,873,874,876],{"class":115,"line":334},[332,875,338],{"class":337},[332,877,878],{"class":341}," asyncio\n",[332,880,881,883],{"class":115,"line":345},[332,882,338],{"class":337},[332,884,342],{"class":341},[332,886,887,889],{"class":115,"line":359},[332,888,338],{"class":337},[332,890,891],{"class":341}," httpx\n",[332,893,894],{"class":115,"line":366},[332,895,363],{"emptyLinePlaceholder":362},[332,897,898,901,903,905,908,910,913],{"class":115,"line":392},[332,899,900],{"class":369},"HUBSPOT_BASE",[332,902,373],{"class":337},[332,904,376],{"class":341},[332,906,907],{"class":379},"\"HUBSPOT_API_BASE\"",[332,909,383],{"class":341},[332,911,912],{"class":379},"\"https:\u002F\u002Fapi.hubapi.com\"",[332,914,389],{"class":341},[332,916,917,920,922,925,928],{"class":115,"line":412},[332,918,919],{"class":369},"HUBSPOT_TOKEN",[332,921,373],{"class":337},[332,923,924],{"class":341}," os.environ[",[332,926,927],{"class":379},"\"HUBSPOT_ACCESS_TOKEN\"",[332,929,930],{"class":341},"]\n",[332,932,933,936,938,941,944,947,949,952],{"class":115,"line":417},[332,934,935],{"class":369},"BATCH_SIZE",[332,937,373],{"class":337},[332,939,940],{"class":369}," int",[332,942,943],{"class":341},"(os.getenv(",[332,945,946],{"class":379},"\"SYNC_BATCH_SIZE\"",[332,948,383],{"class":341},[332,950,951],{"class":379},"\"100\"",[332,953,954],{"class":341},"))\n",[332,956,957,960,962,964,966,969,971,974],{"class":115,"line":422},[332,958,959],{"class":369},"CONCURRENCY",[332,961,373],{"class":337},[332,963,940],{"class":369},[332,965,943],{"class":341},[332,967,968],{"class":379},"\"SYNC_CONCURRENCY\"",[332,970,383],{"class":341},[332,972,973],{"class":379},"\"4\"",[332,975,954],{"class":341},[332,977,978],{"class":115,"line":441},[332,979,363],{"emptyLinePlaceholder":362},[332,981,982],{"class":115,"line":447},[332,983,363],{"emptyLinePlaceholder":362},[332,985,986,989,992,995,998,1000,1003,1005],{"class":115,"line":461},[332,987,988],{"class":337},"async",[332,990,991],{"class":337}," def",[332,993,994],{"class":428}," upsert_batch",[332,996,997],{"class":341},"(client: httpx.AsyncClient, inputs: list[",[332,999,532],{"class":369},[332,1001,1002],{"class":341},"]) -> list[",[332,1004,532],{"class":369},[332,1006,1007],{"class":341},"]:\n",[332,1009,1010,1013,1015,1018,1021,1024],{"class":115,"line":473},[332,1011,1012],{"class":341},"    url ",[332,1014,490],{"class":337},[332,1016,1017],{"class":337}," f",[332,1019,1020],{"class":379},"\"",[332,1022,1023],{"class":369},"{HUBSPOT_BASE}",[332,1025,1026],{"class":379},"\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts\u002Fbatch\u002Fupsert\"\n",[332,1028,1029,1032,1034,1037,1040,1043,1045,1048,1051],{"class":115,"line":498},[332,1030,1031],{"class":341},"    response ",[332,1033,490],{"class":337},[332,1035,1036],{"class":337}," await",[332,1038,1039],{"class":341}," client.post(url, ",[332,1041,1042],{"class":486},"json",[332,1044,490],{"class":337},[332,1046,1047],{"class":341},"{",[332,1049,1050],{"class":379},"\"inputs\"",[332,1052,1053],{"class":341},": inputs})\n",[332,1055,1056,1059],{"class":115,"line":510},[332,1057,1058],{"class":337},"    match",[332,1060,1061],{"class":341}," response.status_code:\n",[332,1063,1064,1067,1070],{"class":115,"line":515},[332,1065,1066],{"class":337},"        case",[332,1068,1069],{"class":369}," 200",[332,1071,540],{"class":341},[332,1073,1074,1077],{"class":115,"line":520},[332,1075,1076],{"class":337},"            return",[332,1078,1079],{"class":341}," []\n",[332,1081,1082,1084,1087],{"class":115,"line":543},[332,1083,1066],{"class":337},[332,1085,1086],{"class":369}," 207",[332,1088,540],{"class":341},[332,1090,1091,1094,1096],{"class":115,"line":549},[332,1092,1093],{"class":341},"            body ",[332,1095,490],{"class":337},[332,1097,1098],{"class":341}," response.json()\n",[332,1100,1101,1103,1106,1109,1112,1115,1118,1121],{"class":115,"line":575},[332,1102,1076],{"class":337},[332,1104,1105],{"class":341}," [e ",[332,1107,1108],{"class":337},"for",[332,1110,1111],{"class":341}," e ",[332,1113,1114],{"class":337},"in",[332,1116,1117],{"class":341}," body.get(",[332,1119,1120],{"class":379},"\"errors\"",[332,1122,1123],{"class":341},", [])]\n",[332,1125,1126,1128,1131],{"class":115,"line":591},[332,1127,1066],{"class":337},[332,1129,1130],{"class":369}," 429",[332,1132,540],{"class":341},[332,1134,1135,1138,1140,1143,1146,1149,1151,1154],{"class":115,"line":602},[332,1136,1137],{"class":341},"            retry_after ",[332,1139,490],{"class":337},[332,1141,1142],{"class":369}," float",[332,1144,1145],{"class":341},"(response.headers.get(",[332,1147,1148],{"class":379},"\"Retry-After\"",[332,1150,383],{"class":341},[332,1152,1153],{"class":379},"\"10\"",[332,1155,954],{"class":341},[332,1157,1158,1161],{"class":115,"line":619},[332,1159,1160],{"class":337},"            await",[332,1162,1163],{"class":341}," asyncio.sleep(retry_after)\n",[332,1165,1166,1168,1170],{"class":115,"line":630},[332,1167,1076],{"class":337},[332,1169,1036],{"class":337},[332,1171,1172],{"class":341}," upsert_batch(client, inputs)\n",[332,1174,1175,1177],{"class":115,"line":641},[332,1176,1066],{"class":337},[332,1178,1179],{"class":341}," _:\n",[332,1181,1182],{"class":115,"line":656},[332,1183,1184],{"class":341},"            response.raise_for_status()\n",[332,1186,1187,1189],{"class":115,"line":688},[332,1188,1076],{"class":337},[332,1190,1079],{"class":341},[332,1192,1193],{"class":115,"line":694},[332,1194,363],{"emptyLinePlaceholder":362},[332,1196,1197],{"class":115,"line":705},[332,1198,363],{"emptyLinePlaceholder":362},[332,1200,1201,1203,1205,1208,1211,1213,1215,1217],{"class":115,"line":725},[332,1202,988],{"class":337},[332,1204,991],{"class":337},[332,1206,1207],{"class":428}," write_all",[332,1209,1210],{"class":341},"(rows: list[",[332,1212,532],{"class":369},[332,1214,1002],{"class":341},[332,1216,532],{"class":369},[332,1218,1007],{"class":341},[332,1220,1222,1225,1227,1229,1232,1234,1237,1240,1243,1245],{"class":115,"line":1221},30,[332,1223,1224],{"class":341},"    headers ",[332,1226,490],{"class":337},[332,1228,731],{"class":341},[332,1230,1231],{"class":379},"\"Authorization\"",[332,1233,737],{"class":341},[332,1235,1236],{"class":337},"f",[332,1238,1239],{"class":379},"\"Bearer ",[332,1241,1242],{"class":369},"{HUBSPOT_TOKEN}",[332,1244,1020],{"class":379},[332,1246,1247],{"class":341},"}\n",[332,1249,1251,1254,1256,1259,1261],{"class":115,"line":1250},31,[332,1252,1253],{"class":341},"    gate ",[332,1255,490],{"class":337},[332,1257,1258],{"class":341}," asyncio.Semaphore(",[332,1260,959],{"class":369},[332,1262,389],{"class":341},[332,1264,1266,1269,1271,1274,1277,1280,1282,1284,1287,1289,1292,1294,1296,1298,1301,1304,1306],{"class":115,"line":1265},32,[332,1267,1268],{"class":341},"    batches ",[332,1270,490],{"class":337},[332,1272,1273],{"class":341}," [rows[i:i ",[332,1275,1276],{"class":337},"+",[332,1278,1279],{"class":369}," BATCH_SIZE",[332,1281,713],{"class":341},[332,1283,1108],{"class":337},[332,1285,1286],{"class":341}," i ",[332,1288,1114],{"class":337},[332,1290,1291],{"class":369}," range",[332,1293,432],{"class":341},[332,1295,83],{"class":369},[332,1297,383],{"class":341},[332,1299,1300],{"class":369},"len",[332,1302,1303],{"class":341},"(rows), ",[332,1305,935],{"class":369},[332,1307,1308],{"class":341},")]\n",[332,1310,1312],{"class":115,"line":1311},33,[332,1313,363],{"emptyLinePlaceholder":362},[332,1315,1317,1320,1323,1326,1329,1331,1334,1337,1339,1342,1344,1347],{"class":115,"line":1316},34,[332,1318,1319],{"class":337},"    async",[332,1321,1322],{"class":337}," with",[332,1324,1325],{"class":341}," httpx.AsyncClient(",[332,1327,1328],{"class":486},"headers",[332,1330,490],{"class":337},[332,1332,1333],{"class":341},"headers, ",[332,1335,1336],{"class":486},"timeout",[332,1338,490],{"class":337},[332,1340,1341],{"class":369},"30.0",[332,1343,563],{"class":341},[332,1345,1346],{"class":337},"as",[332,1348,1349],{"class":341}," client:\n",[332,1351,1353,1356,1358,1361,1364,1366,1368,1370],{"class":115,"line":1352},35,[332,1354,1355],{"class":337},"        async",[332,1357,991],{"class":337},[332,1359,1360],{"class":428}," run",[332,1362,1363],{"class":341},"(batch: list[",[332,1365,532],{"class":369},[332,1367,1002],{"class":341},[332,1369,532],{"class":369},[332,1371,1007],{"class":341},[332,1373,1375,1378,1380],{"class":115,"line":1374},36,[332,1376,1377],{"class":337},"            async",[332,1379,1322],{"class":337},[332,1381,1382],{"class":341}," gate:\n",[332,1384,1386,1389,1391],{"class":115,"line":1385},37,[332,1387,1388],{"class":337},"                return",[332,1390,1036],{"class":337},[332,1392,1393],{"class":341}," upsert_batch(client, batch)\n",[332,1395,1397],{"class":115,"line":1396},38,[332,1398,363],{"emptyLinePlaceholder":362},[332,1400,1402,1405,1407,1409,1412,1415,1418,1420,1423,1425],{"class":115,"line":1401},39,[332,1403,1404],{"class":341},"        results ",[332,1406,490],{"class":337},[332,1408,1036],{"class":337},[332,1410,1411],{"class":341}," asyncio.gather(",[332,1413,1414],{"class":337},"*",[332,1416,1417],{"class":341},"(run(b) ",[332,1419,1108],{"class":337},[332,1421,1422],{"class":341}," b ",[332,1424,1114],{"class":337},[332,1426,1427],{"class":341}," batches))\n",[332,1429,1431,1433,1436,1438,1441,1443,1446,1448,1451,1453],{"class":115,"line":1430},40,[332,1432,728],{"class":337},[332,1434,1435],{"class":341}," [err ",[332,1437,1108],{"class":337},[332,1439,1440],{"class":341}," group ",[332,1442,1114],{"class":337},[332,1444,1445],{"class":341}," results ",[332,1447,1108],{"class":337},[332,1449,1450],{"class":341}," err ",[332,1452,1114],{"class":337},[332,1454,1455],{"class":341}," group]\n",[14,1457,1458,1459,1463],{},"Using ",[23,1460,1462],{"href":1461},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fhttpx-vs-requests-for-async\u002F","httpx rather than requests"," matters here because the batches are independent and the whole run is network-bound.",[32,1465,1467],{"id":1466},"batching-against-hubspots-rate-limits","Batching against HubSpot's rate limits",[14,1469,1470],{},"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.",[43,1472,52,1477,52,1480,52,1483,52,1486,52,1490,52,1494,52,1497,52,1502,52,1506,52,1510,52,1514,52,1517,52,1521,52,1524,52,1528,52,1531,52,1535,52,1540,52,1543,52,1546,52,1551],{"viewBox":1473,"role":46,"ariaLabelledBy":1474,"xmlns":50,"style":51},"0 0 720 310",[1475,1476],"sths-rate-t","sths-rate-d",[54,1478,1479],{"id":1475},"Requests per ten-second window under three write strategies",[58,1481,1482],{"id":1476},"Bar chart showing 400 requests for one record per call, 16 for serial batching and 96 for six concurrent batches, against a limit line of 110.",[81,1484],{"x":83,"y":83,"width":84,"height":1485,"fill":86},"310",[88,1487,1489],{"x":90,"y":1488,"fill":92,"style":93},"28","Requests per 10-second window",[115,1491],{"x1":105,"y1":1492,"x2":1493,"y2":1492,"stroke":168,"style":795},"252","690",[115,1495],{"x1":105,"y1":1496,"x2":105,"y2":1492,"stroke":168,"style":795},"52",[88,1498,83],{"x":1499,"y":1500,"fill":79,"style":1501},"80","256","text-anchor:end;font-size:11;font-family:var(--font-sans);",[88,1503,1505],{"x":1499,"y":1504,"fill":79,"style":1501},"204","110",[88,1507,1509],{"x":1499,"y":1508,"fill":79,"style":1501},"62","420",[81,1511],{"x":1512,"y":1508,"width":1505,"height":123,"rx":1513,"fill":139},"130","4",[88,1515,1516],{"x":119,"y":1496,"fill":92,"style":172},"400",[88,1518,1520],{"x":119,"y":1519,"fill":92,"style":172},"274","1 record \u002F call",[81,1522],{"x":1485,"y":1523,"width":1505,"height":794,"rx":493,"fill":101},"244",[88,1525,1527],{"x":1526,"y":862,"fill":92,"style":172},"365","16",[88,1529,1530],{"x":1526,"y":1519,"fill":92,"style":172},"batch 100, serial",[81,1532],{"x":1533,"y":1534,"width":1505,"height":793,"rx":1513,"fill":124},"490","206",[88,1536,1539],{"x":1537,"y":1538,"fill":92,"style":172},"545","198","96",[88,1541,1542],{"x":1537,"y":1519,"fill":92,"style":172},"batch 100, x6",[115,1544],{"x1":105,"y1":792,"x2":1493,"y2":792,"stroke":79,"style":1545},"stroke-width:2;stroke-dasharray:6 4;",[88,1547,1550],{"x":1548,"y":1549,"fill":79,"style":112},"626","194","app limit 110",[88,1552,1554],{"x":90,"y":1553,"fill":79,"style":191},"298","4,000 customers. Batching cuts calls by 100x and leaves headroom for the rest of your app.",[14,1556,1557,1558,1561,1562,1566,1567,1571,1572,1576],{},"Read ",[228,1559,1560],{},"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 ",[23,1563,1565],{"href":1564},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fbest-practices-for-api-rate-limiting\u002F","API rate limiting best practices"," applies directly, and the walkthrough on ",[23,1568,1570],{"href":1569},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fdebugging-429-too-many-requests-errors\u002F","debugging 429 errors"," covers what to do when the header lies. Wrap transport-level failures with ",[23,1573,1575],{"href":1574},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fretrying-failed-http-requests-with-tenacity\u002F","tenacity retries"," — but only connection errors and 5xx, never a 207.",[32,1578,1580],{"id":1579},"backfill-once-then-run-incrementally","Backfill once, then run incrementally",[14,1582,1583,1584,1587,1588,1590],{},"These are two different programs sharing one mapper, and conflating them is the most common way this project goes wrong. The backfill walks ",[228,1585,1586],{},"\u002Fv1\u002Fcustomers"," from the beginning of time with cursor pagination and writes everything. The incremental job reads ",[228,1589,113],{}," filtered to customer types since your last watermark, which is far cheaper because most days almost nothing changes.",[43,1592,52,1596,52,1599,52,1602,52,1604,52,1607,52,1609,52,1613,52,1616,52,1620,52,1624,52,1630,52,1635,52,1638,52,1641,52,1643,52,1646,52,1648,52,1651,52,1656,52,1659,52,1662,52,1666,52,1669,52,1672,52,1675,52,1678],{"viewBox":777,"role":46,"ariaLabelledBy":1593,"xmlns":50,"style":51},[1594,1595],"sths-time-t","sths-time-d",[54,1597,1598],{"id":1594},"Backfill followed by incremental runs with a lookback overlap",[58,1600,1601],{"id":1595},"A timeline showing a single wide backfill block, then repeating fifteen-minute incremental runs each with a five-minute lookback overlap.",[81,1603],{"x":83,"y":83,"width":84,"height":789,"fill":86},[88,1605,1606],{"x":90,"y":91,"fill":92,"style":93},"Run once, then every 15 minutes",[115,1608],{"x1":91,"y1":117,"x2":1493,"y2":117,"stroke":168,"style":102},[81,1610],{"x":91,"y":1611,"width":792,"height":1612,"rx":794,"fill":167,"stroke":139,"style":102},"108","44",[88,1614,1615],{"x":1512,"y":833,"fill":92,"style":172},"Backfill",[88,1617,1619],{"x":1512,"y":1618,"fill":79,"style":112},"145","\u002Fv1\u002Fcustomers, all",[88,1621,1623],{"x":1512,"y":1622,"fill":79,"style":112},"182","one long run",[81,1625],{"x":1626,"y":1627,"width":819,"height":1628,"rx":1629,"fill":167,"stroke":124,"style":102},"270","120","32","6",[88,1631,1634],{"x":1632,"y":1633,"fill":92,"style":112},"313","141","events",[81,1636],{"x":1637,"y":1627,"width":819,"height":1628,"rx":1629,"fill":167,"stroke":124,"style":102},"380",[88,1639,1634],{"x":1640,"y":1633,"fill":92,"style":112},"423",[81,1642],{"x":1533,"y":1627,"width":819,"height":1628,"rx":1629,"fill":167,"stroke":124,"style":102},[88,1644,1634],{"x":1645,"y":1633,"fill":92,"style":112},"533",[81,1647],{"x":156,"y":1627,"width":819,"height":1628,"rx":1629,"fill":167,"stroke":124,"style":102},[88,1649,1634],{"x":1650,"y":1633,"fill":92,"style":112},"643",[115,1652],{"x1":1653,"y1":1654,"x2":1637,"y2":1654,"stroke":101,"style":1655},"356","100","stroke-width:3;",[115,1657],{"x1":1658,"y1":1654,"x2":1533,"y2":1654,"stroke":101,"style":1655},"466",[115,1660],{"x1":1661,"y1":1654,"x2":156,"y2":1654,"stroke":101,"style":1655},"576",[88,1663,1665],{"x":1664,"y":819,"fill":92,"style":112},"470","5-minute lookback overlap",[88,1667,1668],{"x":1632,"y":1622,"fill":79,"style":112},"t+15",[88,1670,1671],{"x":1640,"y":1622,"fill":79,"style":112},"t+30",[88,1673,1674],{"x":1645,"y":1622,"fill":79,"style":112},"t+45",[88,1676,1677],{"x":1650,"y":1622,"fill":79,"style":112},"t+60",[88,1679,1681],{"x":90,"y":1680,"fill":79,"style":191},"222","Overlap is safe because upserts are idempotent; gaps are not recoverable without a rescan.",[14,1683,1684],{},"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.",[323,1686,1688],{"className":325,"code":1687,"language":327,"meta":328,"style":328},"import os\nimport time\nimport httpx\n\nSTRIPE_BASE = os.getenv(\"STRIPE_API_BASE\", \"https:\u002F\u002Fapi.stripe.com\")\nSTRIPE_KEY = os.environ[\"STRIPE_SECRET_KEY\"]\nLOOKBACK_SECONDS = int(os.getenv(\"SYNC_LOOKBACK_SECONDS\", \"300\"))\nEVENT_TYPES = (\"customer.created\", \"customer.updated\")\n\n\nasync def fetch_changed_customers(since_ts: int) -> list[dict]:\n    \"\"\"Return Stripe customer objects changed since a watermark timestamp.\"\"\"\n    auth = (STRIPE_KEY, \"\")\n    params: dict[str, object] = {\n        \"limit\": 100,\n        \"created[gte]\": max(0, since_ts - LOOKBACK_SECONDS),\n    }\n    seen: dict[str, dict] = {}\n    async with httpx.AsyncClient(auth=auth, timeout=30.0) as client:\n        for event_type in EVENT_TYPES:\n            cursor: str | None = None\n            while True:\n                query = {**params, \"type\": event_type}\n                if cursor:\n                    query[\"starting_after\"] = cursor\n                r = await client.get(f\"{STRIPE_BASE}\u002Fv1\u002Fevents\", params=query)\n                r.raise_for_status()\n                page = r.json()\n                for event in page[\"data\"]:\n                    obj = event[\"data\"][\"object\"]\n                    seen[obj[\"id\"]] = obj\n                if not page.get(\"has_more\"):\n                    break\n                cursor = page[\"data\"][-1][\"id\"]\n    return list(seen.values())\n\n\ndef now_watermark() -> int:\n    return int(time.time())\n",[228,1689,1690,1696,1703,1709,1713,1732,1746,1767,1787,1791,1795,1817,1822,1839,1858,1870,1895,1900,1918,1946,1961,1979,1989,2010,2018,2033,2065,2070,2080,2098,2118,2133,2148,2153,2177,2187,2191,2195,2209],{"__ignoreMap":328},[332,1691,1692,1694],{"class":115,"line":334},[332,1693,338],{"class":337},[332,1695,342],{"class":341},[332,1697,1698,1700],{"class":115,"line":345},[332,1699,338],{"class":337},[332,1701,1702],{"class":341}," time\n",[332,1704,1705,1707],{"class":115,"line":359},[332,1706,338],{"class":337},[332,1708,891],{"class":341},[332,1710,1711],{"class":115,"line":366},[332,1712,363],{"emptyLinePlaceholder":362},[332,1714,1715,1718,1720,1722,1725,1727,1730],{"class":115,"line":392},[332,1716,1717],{"class":369},"STRIPE_BASE",[332,1719,373],{"class":337},[332,1721,376],{"class":341},[332,1723,1724],{"class":379},"\"STRIPE_API_BASE\"",[332,1726,383],{"class":341},[332,1728,1729],{"class":379},"\"https:\u002F\u002Fapi.stripe.com\"",[332,1731,389],{"class":341},[332,1733,1734,1737,1739,1741,1744],{"class":115,"line":412},[332,1735,1736],{"class":369},"STRIPE_KEY",[332,1738,373],{"class":337},[332,1740,924],{"class":341},[332,1742,1743],{"class":379},"\"STRIPE_SECRET_KEY\"",[332,1745,930],{"class":341},[332,1747,1748,1751,1753,1755,1757,1760,1762,1765],{"class":115,"line":417},[332,1749,1750],{"class":369},"LOOKBACK_SECONDS",[332,1752,373],{"class":337},[332,1754,940],{"class":369},[332,1756,943],{"class":341},[332,1758,1759],{"class":379},"\"SYNC_LOOKBACK_SECONDS\"",[332,1761,383],{"class":341},[332,1763,1764],{"class":379},"\"300\"",[332,1766,954],{"class":341},[332,1768,1769,1772,1774,1777,1780,1782,1785],{"class":115,"line":422},[332,1770,1771],{"class":369},"EVENT_TYPES",[332,1773,373],{"class":337},[332,1775,1776],{"class":341}," (",[332,1778,1779],{"class":379},"\"customer.created\"",[332,1781,383],{"class":341},[332,1783,1784],{"class":379},"\"customer.updated\"",[332,1786,389],{"class":341},[332,1788,1789],{"class":115,"line":441},[332,1790,363],{"emptyLinePlaceholder":362},[332,1792,1793],{"class":115,"line":447},[332,1794,363],{"emptyLinePlaceholder":362},[332,1796,1797,1799,1801,1804,1807,1810,1813,1815],{"class":115,"line":461},[332,1798,988],{"class":337},[332,1800,991],{"class":337},[332,1802,1803],{"class":428}," fetch_changed_customers",[332,1805,1806],{"class":341},"(since_ts: ",[332,1808,1809],{"class":369},"int",[332,1811,1812],{"class":341},") -> list[",[332,1814,532],{"class":369},[332,1816,1007],{"class":341},[332,1818,1819],{"class":115,"line":473},[332,1820,1821],{"class":379},"    \"\"\"Return Stripe customer objects changed since a watermark timestamp.\"\"\"\n",[332,1823,1824,1827,1829,1831,1833,1835,1837],{"class":115,"line":498},[332,1825,1826],{"class":341},"    auth ",[332,1828,490],{"class":337},[332,1830,1776],{"class":341},[332,1832,1736],{"class":369},[332,1834,383],{"class":341},[332,1836,682],{"class":379},[332,1838,389],{"class":341},[332,1840,1841,1844,1846,1848,1851,1853,1855],{"class":115,"line":510},[332,1842,1843],{"class":341},"    params: dict[",[332,1845,453],{"class":369},[332,1847,383],{"class":341},[332,1849,1850],{"class":369},"object",[332,1852,713],{"class":341},[332,1854,490],{"class":337},[332,1856,1857],{"class":341}," {\n",[332,1859,1860,1863,1865,1867],{"class":115,"line":515},[332,1861,1862],{"class":379},"        \"limit\"",[332,1864,737],{"class":341},[332,1866,1654],{"class":369},[332,1868,1869],{"class":341},",\n",[332,1871,1872,1875,1877,1880,1882,1884,1887,1890,1893],{"class":115,"line":520},[332,1873,1874],{"class":379},"        \"created[gte]\"",[332,1876,737],{"class":341},[332,1878,1879],{"class":369},"max",[332,1881,432],{"class":341},[332,1883,83],{"class":369},[332,1885,1886],{"class":341},", since_ts ",[332,1888,1889],{"class":337},"-",[332,1891,1892],{"class":369}," LOOKBACK_SECONDS",[332,1894,685],{"class":341},[332,1896,1897],{"class":115,"line":543},[332,1898,1899],{"class":341},"    }\n",[332,1901,1902,1905,1907,1909,1911,1913,1915],{"class":115,"line":549},[332,1903,1904],{"class":341},"    seen: dict[",[332,1906,453],{"class":369},[332,1908,383],{"class":341},[332,1910,532],{"class":369},[332,1912,713],{"class":341},[332,1914,490],{"class":337},[332,1916,1917],{"class":341}," {}\n",[332,1919,1920,1922,1924,1926,1929,1931,1934,1936,1938,1940,1942,1944],{"class":115,"line":575},[332,1921,1319],{"class":337},[332,1923,1322],{"class":337},[332,1925,1325],{"class":341},[332,1927,1928],{"class":486},"auth",[332,1930,490],{"class":337},[332,1932,1933],{"class":341},"auth, ",[332,1935,1336],{"class":486},[332,1937,490],{"class":337},[332,1939,1341],{"class":369},[332,1941,563],{"class":341},[332,1943,1346],{"class":337},[332,1945,1349],{"class":341},[332,1947,1948,1951,1954,1956,1959],{"class":115,"line":591},[332,1949,1950],{"class":337},"        for",[332,1952,1953],{"class":341}," event_type ",[332,1955,1114],{"class":337},[332,1957,1958],{"class":369}," EVENT_TYPES",[332,1960,540],{"class":341},[332,1962,1963,1966,1968,1971,1974,1976],{"class":115,"line":602},[332,1964,1965],{"class":341},"            cursor: ",[332,1967,453],{"class":369},[332,1969,1970],{"class":337}," |",[332,1972,1973],{"class":369}," None",[332,1975,373],{"class":337},[332,1977,1978],{"class":369}," None\n",[332,1980,1981,1984,1987],{"class":115,"line":619},[332,1982,1983],{"class":337},"            while",[332,1985,1986],{"class":369}," True",[332,1988,540],{"class":341},[332,1990,1991,1994,1996,1998,2001,2004,2007],{"class":115,"line":630},[332,1992,1993],{"class":341},"                query ",[332,1995,490],{"class":337},[332,1997,731],{"class":341},[332,1999,2000],{"class":337},"**",[332,2002,2003],{"class":341},"params, ",[332,2005,2006],{"class":379},"\"type\"",[332,2008,2009],{"class":341},": event_type}\n",[332,2011,2012,2015],{"class":115,"line":641},[332,2013,2014],{"class":337},"                if",[332,2016,2017],{"class":341}," cursor:\n",[332,2019,2020,2023,2026,2028,2030],{"class":115,"line":656},[332,2021,2022],{"class":341},"                    query[",[332,2024,2025],{"class":379},"\"starting_after\"",[332,2027,713],{"class":341},[332,2029,490],{"class":337},[332,2031,2032],{"class":341}," cursor\n",[332,2034,2035,2038,2040,2042,2045,2047,2049,2052,2055,2057,2060,2062],{"class":115,"line":688},[332,2036,2037],{"class":341},"                r ",[332,2039,490],{"class":337},[332,2041,1036],{"class":337},[332,2043,2044],{"class":341}," client.get(",[332,2046,1236],{"class":337},[332,2048,1020],{"class":379},[332,2050,2051],{"class":369},"{STRIPE_BASE}",[332,2053,2054],{"class":379},"\u002Fv1\u002Fevents\"",[332,2056,383],{"class":341},[332,2058,2059],{"class":486},"params",[332,2061,490],{"class":337},[332,2063,2064],{"class":341},"query)\n",[332,2066,2067],{"class":115,"line":694},[332,2068,2069],{"class":341},"                r.raise_for_status()\n",[332,2071,2072,2075,2077],{"class":115,"line":705},[332,2073,2074],{"class":341},"                page ",[332,2076,490],{"class":337},[332,2078,2079],{"class":341}," r.json()\n",[332,2081,2082,2085,2088,2090,2093,2096],{"class":115,"line":725},[332,2083,2084],{"class":337},"                for",[332,2086,2087],{"class":341}," event ",[332,2089,1114],{"class":337},[332,2091,2092],{"class":341}," page[",[332,2094,2095],{"class":379},"\"data\"",[332,2097,1007],{"class":341},[332,2099,2100,2103,2105,2108,2110,2113,2116],{"class":115,"line":1221},[332,2101,2102],{"class":341},"                    obj ",[332,2104,490],{"class":337},[332,2106,2107],{"class":341}," event[",[332,2109,2095],{"class":379},[332,2111,2112],{"class":341},"][",[332,2114,2115],{"class":379},"\"object\"",[332,2117,930],{"class":341},[332,2119,2120,2123,2125,2128,2130],{"class":115,"line":1250},[332,2121,2122],{"class":341},"                    seen[obj[",[332,2124,651],{"class":379},[332,2126,2127],{"class":341},"]] ",[332,2129,490],{"class":337},[332,2131,2132],{"class":341}," obj\n",[332,2134,2135,2137,2140,2143,2146],{"class":115,"line":1265},[332,2136,2014],{"class":337},[332,2138,2139],{"class":337}," not",[332,2141,2142],{"class":341}," page.get(",[332,2144,2145],{"class":379},"\"has_more\"",[332,2147,438],{"class":341},[332,2149,2150],{"class":115,"line":1311},[332,2151,2152],{"class":337},"                    break\n",[332,2154,2155,2158,2160,2162,2164,2166,2168,2171,2173,2175],{"class":115,"line":1316},[332,2156,2157],{"class":341},"                cursor ",[332,2159,490],{"class":337},[332,2161,2092],{"class":341},[332,2163,2095],{"class":379},[332,2165,2112],{"class":341},[332,2167,1889],{"class":337},[332,2169,2170],{"class":369},"1",[332,2172,2112],{"class":341},[332,2174,651],{"class":379},[332,2176,930],{"class":341},[332,2178,2179,2181,2184],{"class":115,"line":1352},[332,2180,728],{"class":337},[332,2182,2183],{"class":369}," list",[332,2185,2186],{"class":341},"(seen.values())\n",[332,2188,2189],{"class":115,"line":1374},[332,2190,363],{"emptyLinePlaceholder":362},[332,2192,2193],{"class":115,"line":1385},[332,2194,363],{"emptyLinePlaceholder":362},[332,2196,2197,2199,2202,2205,2207],{"class":115,"line":1396},[332,2198,523],{"class":337},[332,2200,2201],{"class":428}," now_watermark",[332,2203,2204],{"class":341},"() -> ",[332,2206,1809],{"class":369},[332,2208,540],{"class":341},[332,2210,2211,2213,2215],{"class":115,"line":1401},[332,2212,728],{"class":337},[332,2214,940],{"class":369},[332,2216,2217],{"class":341},"(time.time())\n",[14,2219,2220,2221,2225,2226,2230,2231,2235,2236,2240],{},"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 ",[23,2222,2224],{"href":2223},"\u002Fautomating-side-hustle-operations-with-apis\u002Fscheduling-data-pipelines-with-cron\u002Fapscheduler-vs-celery-beat\u002F","APScheduler vs Celery Beat",", and the broader patterns live in ",[23,2227,2229],{"href":2228},"\u002Fautomating-side-hustle-operations-with-apis\u002Fscheduling-data-pipelines-with-cron\u002F","scheduling data pipelines with cron",". If you prefer push over poll, ",[23,2232,2234],{"href":2233},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures\u002F","verifying Stripe webhook signatures"," plus an ",[23,2237,2239],{"href":2238},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fbuilding-an-idempotent-webhook-receiver\u002F","idempotent webhook receiver"," gives you sub-second latency — but keep the polling job anyway as the safety net.",[32,2242,2244],{"id":2243},"reconciliation-prove-the-two-systems-agree","Reconciliation: prove the two systems agree",[14,2246,2247,2248,2250],{},"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 ",[228,2249,235],{},", 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.",[43,2252,52,2256,52,2259,52,2262,52,2269,52,2271,52,2275,52,2278,52,2281,52,2283,52,2287,52,2289,52,2291,52,2293,52,2297,52,2300,52,2302,52,2305,52,2308,52,2312,52,2315,52,2318,52,2320,52,2322,52,2324,52,2327,52,2331,52,2333,52,2336,52,2338,52,2341],{"viewBox":45,"role":46,"ariaLabelledBy":2253,"xmlns":50,"style":51},[2254,2255],"sths-recon-t","sths-recon-d",[54,2257,2258],{"id":2254},"Decision tree for classifying reconciliation drift",[58,2260,2261],{"id":2255},"Drift detected branches into missing in HubSpot, property mismatch and orphaned contact, each mapping to re-upsert, force update or flag for review.",[62,2263,64,2264,52],{},[66,2265,74,2267,64],{"id":2266,"viewBox":69,"refX":70,"refY":71,"markerWidth":72,"markerHeight":72,"orient":73},"sths-recon-arrow",[76,2268],{"d":78,"fill":79},[81,2270],{"x":83,"y":83,"width":84,"height":85,"fill":86},[81,2272],{"x":1626,"y":2273,"width":2274,"height":793,"rx":100,"fill":167,"stroke":101,"style":102},"26","180",[88,2276,2277],{"x":138,"y":166,"fill":92,"style":107},"Drift detected",[115,2279],{"x1":138,"y1":2280,"x2":138,"y2":1539,"stroke":79,"style":102},"72",[115,2282],{"x1":1627,"y1":1539,"x2":156,"y2":1539,"stroke":79,"style":102},[115,2284],{"x1":1627,"y1":1539,"x2":1627,"y2":2285,"stroke":79,"style":2286},"126","stroke-width:2;marker-end:url(#sths-recon-arrow);",[115,2288],{"x1":138,"y1":1539,"x2":138,"y2":2285,"stroke":79,"style":2286},[115,2290],{"x1":156,"y1":1539,"x2":156,"y2":2285,"stroke":79,"style":2286},[81,2292],{"x":91,"y":1512,"width":2274,"height":1496,"rx":100,"fill":86,"stroke":139,"style":102},[88,2294,2296],{"x":1627,"y":2295,"fill":92,"style":172},"152","In Stripe only",[88,2298,2299],{"x":1627,"y":847,"fill":79,"style":112},"never landed",[81,2301],{"x":1626,"y":1512,"width":2274,"height":1496,"rx":100,"fill":86,"stroke":124,"style":102},[88,2303,2304],{"x":138,"y":2295,"fill":92,"style":172},"Values differ",[88,2306,2307],{"x":138,"y":847,"fill":79,"style":112},"stale properties",[81,2309],{"x":2310,"y":1512,"width":2274,"height":1496,"rx":100,"fill":86,"stroke":2311,"style":102},"510","var(--c-yellow)",[88,2313,2314],{"x":156,"y":2295,"fill":92,"style":172},"In HubSpot only",[88,2316,2317],{"x":156,"y":847,"fill":79,"style":112},"customer deleted",[115,2319],{"x1":1627,"y1":1622,"x2":1627,"y2":171,"stroke":79,"style":2286},[115,2321],{"x1":138,"y1":1622,"x2":138,"y2":171,"stroke":79,"style":2286},[115,2323],{"x1":156,"y1":1622,"x2":156,"y2":171,"stroke":79,"style":2286},[81,2325],{"x":91,"y":2326,"width":2274,"height":1612,"rx":100,"fill":167,"stroke":168,"style":102},"216",[88,2328,2330],{"x":1627,"y":2329,"fill":92,"style":172},"243","Re-upsert",[81,2332],{"x":1626,"y":2326,"width":2274,"height":1612,"rx":100,"fill":167,"stroke":168,"style":102},[88,2334,2335],{"x":138,"y":2329,"fill":92,"style":172},"Force update",[81,2337],{"x":2310,"y":2326,"width":2274,"height":1612,"rx":100,"fill":167,"stroke":168,"style":102},[88,2339,2340],{"x":156,"y":2329,"fill":92,"style":172},"Flag, never delete",[88,2342,2344],{"x":90,"y":2343,"fill":79,"style":191},"286","Only the first two auto-heal. Deletion is a human decision.",[14,2346,2347,2348,2352],{},"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 ",[23,2349,2351],{"href":2350},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","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.",[32,2354,2356],{"id":2355},"when-to-build-this-and-when-to-buy-it","When to build this and when to buy it",[14,2358,2359],{},"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.",[14,2361,2362,2363,321],{},"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 ",[23,2364,2366],{"href":2365},"\u002Fautomating-side-hustle-operations-with-apis\u002Fbuilding-zapier-alternatives-with-python\u002Fzapier-vs-make-vs-python\u002F","Zapier vs Make vs Python",[14,2368,2369,2370,2372],{},"Migrating off a hosted connector is a four-step job. First, create the unique ",[228,2371,235],{}," 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.",[32,2374,2376],{"id":2375},"builder-verdict","Builder verdict",[14,2378,2379],{},"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.",[32,2381,2383],{"id":2382},"faq","FAQ",[14,2385,2386,2389],{},[18,2387,2388],{},"What does this sync cost to operate at 10,000 customers?","\nA 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.",[14,2391,2392,2395],{},[18,2393,2394],{},"Will this blow through HubSpot's daily API cap?","\nNot 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.",[14,2397,2398,2401,2402,2406],{},[18,2399,2400],{},"How do I rotate the HubSpot private app token without a failed run?","\nRead 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 ",[23,2403,2405],{"href":2404},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime\u002F","rotating API keys without downtime"," applies to Stripe restricted keys too.",[14,2408,2409,2412,2413,2415,2416,2418],{},[18,2410,2411],{},"What is the migration risk if I later want two-way sync?","\nLow, if you keep ",[228,2414,235],{}," unique and never write HubSpot-owned fields such as ",[228,2417,294],{}," 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.",[14,2420,2421,2424],{},[18,2422,2423],{},"Should I use Stripe webhooks instead of polling events?","\nUse 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.",[32,2426,2428],{"id":2427},"related","Related",[2430,2431,2432,2438,2444,2451,2458],"ul",{},[2433,2434,2435,2437],"li",{},[23,2436,26],{"href":25}," — the parent guide covering CRM and email integration patterns end to end.",[2433,2439,2440,2443],{},[23,2441,2442],{"href":2238},"Building an Idempotent Webhook Receiver"," — the push-based counterpart to this polling job, with deduplication built in.",[2433,2445,2446,2450],{},[23,2447,2449],{"href":2448},"\u002Fautomating-side-hustle-operations-with-apis\u002Fautomating-social-media-posting\u002Fsync-shopify-orders-to-google-sheets-via-api\u002F","Sync Shopify Orders to Google Sheets via API"," — the same watermark and batching pattern applied to a different pair of systems.",[2433,2452,2453,2457],{},[23,2454,2456],{"href":2455},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002F","Integrating Stripe with Python APIs"," — the billing side of the pipeline, including customer objects and subscription state.",[2433,2459,2460,2464],{},[23,2461,2463],{"href":2462},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Fmocking-external-apis-with-respx\u002F","Mocking External APIs with respx"," — how to test the 207 and 429 branches of the batch writer without touching a live HubSpot account.",[2466,2467,2468],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":328,"searchDepth":345,"depth":345,"links":2470},[2471,2472,2473,2474,2475,2476,2477,2478,2479,2480],{"id":34,"depth":345,"text":35},{"id":195,"depth":345,"text":196},{"id":763,"depth":345,"text":764},{"id":1466,"depth":345,"text":1467},{"id":1579,"depth":345,"text":1580},{"id":2243,"depth":345,"text":2244},{"id":2355,"depth":345,"text":2356},{"id":2375,"depth":345,"text":2376},{"id":2382,"depth":345,"text":2383},{"id":2427,"depth":345,"text":2428},"Build a one-way Stripe to HubSpot customer sync in Python that survives partial failure: field mapping, upsert by external id, batching, backfill and reconciliation.","md",{"pageTitle":5,"type":2484,"datePublished":2485,"dateModified":2485},"article","2026-07-23","\u002Fautomating-side-hustle-operations-with-apis\u002Fconnecting-crm-email-apis\u002Fsyncing-stripe-customers-to-hubspot-with-python",{"title":5,"description":2481},"automating-side-hustle-operations-with-apis\u002Fconnecting-crm-email-apis\u002Fsyncing-stripe-customers-to-hubspot-with-python\u002Findex","0fi3Eollt13rPq3DKBVfmjh-vggBRJfAPsPEZi6vmps",{"@context":2491,"@type":2492,"mainEntity":2493},"https:\u002F\u002Fschema.org","FAQPage",[2494,2499,2502,2505,2508],{"@type":2495,"name":2388,"acceptedAnswer":2496},"Question",{"@type":2497,"text":2498},"Answer","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.",{"@type":2495,"name":2394,"acceptedAnswer":2500},{"@type":2497,"text":2501},"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.",{"@type":2495,"name":2400,"acceptedAnswer":2503},{"@type":2497,"text":2504},"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.",{"@type":2495,"name":2411,"acceptedAnswer":2506},{"@type":2497,"text":2507},"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.",{"@type":2495,"name":2423,"acceptedAnswer":2509},{"@type":2497,"text":2510},"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.",1784887028387]