DEV Community

FlareCanary
FlareCanary

Posted on

HubSpot Contacts v1 Goes Dark on April 30 — And the Worst Part Is the Endpoints That Keep Working

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/lists
  • GET /contacts/v1/lists/:list_id
  • POST /contacts/v1/lists
  • POST /contacts/v1/lists/:list_id/add
  • POST /contacts/v1/lists/:list_id/remove
  • DELETE /contacts/v1/lists/:list_id
  • GET /contacts/v1/lists/static
  • GET /contacts/v1/lists/dynamic
  • GET /contacts/v1/lists/:list_id/contacts/all
  • GET /contacts/v1/lists/:list_id/contacts/recent

Plus everything else under that path. On April 30 they return:

{
  "status": "error",
  "message": "Resource not found"
}
Enter fullscreen mode Exit fullscreen mode

Three Lists endpoints survive but lose membership context:

  • GET /contacts/v1/lists/all/contacts/all
  • GET /contacts/v1/lists/all/contacts/recent
  • GET /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/profile
  • GET /contacts/v1/contact/vids/batch
  • GET /contacts/v1/contact/email/:contact_email/profile
  • GET /contacts/v1/contact/emails/batch
  • GET /contacts/v1/contact/utk/:contact_utk/profile
  • GET /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 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

After April 30:

{
  "vid": 12345,
  "properties": { "...": "..." },
  "list-memberships": []
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Cache list memberships on your side, refreshed on a schedule
  2. Hit /crm/v3/lists/memberships/contacts/{contactId} (yes, this exists, but it's a different endpoint with different rate limits)
  3. 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:

  1. Your sandbox is the production HubSpot environment (it isn't)
  2. HubSpot deprecates the field in the sandbox before prod (they don't)
  3. 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:

  1. 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.

  2. Grep for list-memberships and list_memberships. Every read against this field is downstream-affected even if you can't change the v1 call site this week.

  3. 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)