On April 30, 2026, HubSpot sunsets the Contact Lists v1 API. The headline is simple: most /contacts/v1/lists/* endpoints start returning HTTP 404. The dangerous part is everything that doesn't 404.
Straight from HubSpot's sunset announcement, six Contacts v1 read endpoints will:
continue to function but will no longer return list memberships
That's a 200 response with the list-memberships array silently gone. Code that does contact["list-memberships"] gets a KeyError. Code that does contact.get("list-memberships", []) swallows the empty list and now thinks the contact is in zero lists. Both are broken. One of them looks broken.
This is incident #6 in our silent-breakage series (GitHub PushEvent, Stripe Basil, Shopify 2025-01, OpenAI Responses input_text, Twilio regional domains). Same pattern: the breaking change lives in a field you weren't asserting against.
What returns 404 on April 30
The full Contact Lists v1 surface goes away:
GET /contacts/v1/listsGET /contacts/v1/lists/:list_idPOST /contacts/v1/listsPOST /contacts/v1/lists/:list_id/addPOST /contacts/v1/lists/:list_id/removeDELETE /contacts/v1/lists/:list_idGET /contacts/v1/lists/staticGET /contacts/v1/lists/dynamicGET /contacts/v1/lists/:list_id/contacts/allGET /contacts/v1/lists/:list_id/contacts/recent
Plus everything else under that path. On April 30 they return:
{
"status": "error",
"message": "Resource not found"
}
Three Lists endpoints survive but lose membership context:
GET /contacts/v1/lists/all/contacts/allGET /contacts/v1/lists/all/contacts/recentGET /contacts/v1/lists/recently_updated/contacts/recent
These return contact records, but each contact's list-memberships array stops being populated.
The endpoints that bite — they keep returning 200
Here's the list every audit script needs to grep for:
GET /contacts/v1/contact/vid/:vid/profileGET /contacts/v1/contact/vids/batchGET /contacts/v1/contact/email/:contact_email/profileGET /contacts/v1/contact/emails/batchGET /contacts/v1/contact/utk/:contact_utk/profileGET /contacts/v1/contact/byUtk/batch
These all keep returning HTTP 200 on May 1. The difference is in the body.
Before April 30:
{
"vid": 12345,
"properties": { "...": "..." },
"list-memberships": [
{ "static-list-id": 8, "internal-list-id": 8, "timestamp": 1714492800000, "vid": 12345, "is-member": true },
{ "static-list-id": 14, "internal-list-id": 14, "timestamp": 1714493000000, "vid": 12345, "is-member": true }
]
}
After April 30:
{
"vid": 12345,
"properties": { "...": "..." },
"list-memberships": []
}
Status code: 200. JSON parses cleanly. Field is present. It's just empty.
If your sync job uses these endpoints to figure out which CRM lists a contact belongs to — Marketo, Salesforce, ActiveCampaign, custom data warehouse, anything — every contact looks like it belongs to nothing. Conditional sends fire (or don't) based on phantom list membership. Audience exclusion lists don't exclude.
The fix path: v3 Lists API
The replacement is the Lists v3 API. For membership lookups, the new shape is per-list, not per-contact:
# v1 (going away or going empty)
GET /contacts/v1/contact/vid/12345/profile
# → list-memberships array on the contact object
# v3 (the replacement)
GET /crm/v3/lists/{listId}/memberships
# → paginated array of {recordId, membershipTimestamp}
The semantic flip matters. v1 answered "what lists is this contact in?" — efficient when you have a contact and want their lists. v3 answers "who is in this list?" — efficient when you have a list and want its contacts. If your code's hot path is the contact-to-lists direction, you have to either:
- Cache list memberships on your side, refreshed on a schedule
- Hit
/crm/v3/lists/memberships/contacts/{contactId}(yes, this exists, but it's a different endpoint with different rate limits) - Subscribe to membership-change webhooks and maintain the inverse index yourself
Pick before April 30. The default — "we'll figure it out when something breaks" — picks option 4: silently shipping a sync job that thinks every contact is in zero lists.
Why your tests didn't catch it
Your integration tests hit a HubSpot sandbox. The contact fixture in the sandbox is in three lists. The test asserts len(contact["list-memberships"]) == 3.
Three things have to be true for that test to fail before May 1:
- Your sandbox is the production HubSpot environment (it isn't)
- HubSpot deprecates the field in the sandbox before prod (they don't)
- The test runs after April 30 with no caching (sometimes)
So the test stays green. The same pattern as every other silent drift incident:
| # | API | What changed | Where tests missed it |
|---|---|---|---|
| 1 | GitHub PushEvent |
commits field silently dropped |
Tests didn't assert field presence |
| 2 | Stripe Basil |
current_period_end moved to items
|
Tests used Checkout fixtures |
| 3 | Shopify 2025-01 |
fulfillmentHold type change |
Tests mocked the response |
| 4 | OpenAI Responses |
input_text removed for assistants |
Tests covered request role=user |
| 5 | Twilio regional | Regional domains stop resolving | Tests don't hit prod DNS paths |
| 6 | HubSpot Contacts v1 |
list-memberships returns empty |
Tests asserted against sandbox fixtures |
The breaking change always lives in a layer the test suite isn't watching. For HubSpot, that layer is "field that used to be populated and now isn't, while the request and response otherwise look identical."
What to do this week
April 30 is seven days out. Three actions:
Grep your codebase for
contacts/v1/. Every match is a potential failure. Endpoints in the 404 group break loudly. Endpoints in the 200-but-empty group break quietly.Grep for
list-membershipsandlist_memberships. Every read against this field is downstream-affected even if you can't change the v1 call site this week.Decide between cache-side and webhook-side. v3's per-list shape forces an architectural choice. Picking it under deadline pressure is worse than picking it on Monday.
If your platform integrations team is on PTO, the failure mode on May 1 is "every CRM sync job runs to completion with empty audiences." That's a quiet kind of broken — one your monitoring probably doesn't catch.
We built FlareCanary for exactly this layer: poll third-party APIs on a schedule, watch the response, page when a field that used to populate stops populating. The HubSpot deprecation is the textbook case — same status code, same JSON shape, semantically broken. The APIs change whether you're watching or not.
We track API drift incidents in real time. If your stack syncs HubSpot list memberships and you haven't audited the read paths yet, April 30 is a hard date — and the dangerous part is the endpoints that don't return 404.
Top comments (0)