A Klaviyo metric ID identifies a metric inside one account and nowhere else. The Placed Order metric in your parent brand and the Placed Order metric in the account you spun up last quarter have different IDs, even though both were created by the same Shopify integration on the same day. Anything that carries an ID across the boundary (a copied API call, a duplicated warehouse sync, a middleware filter, a report someone rebuilt from a screenshot) either errors in a place you are not watching or returns an empty series that your pipeline writes down as zero revenue. The fix is to resolve metrics by name and integration at run time and never to store an ID as a constant.
Key takeaways
- Metric IDs, flow IDs and list IDs are account scoped identifiers, so they carry no meaning outside the account that issued them.
- The usual symptom is not an error page. It is a chart at zero, a rollup that is short by one brand, or a conversion number that never moved after launch.
- Diagnose by fetching the ID directly. A 404 means wrong account, a 200 with an unexpected name means you are reading someone else's metric, and a 200 with the right name means the metric is genuinely quiet.
- Resolve by the pair of metric name and integration name, not by name alone, because the same name can exist twice in one account from two sources.
- Keep the resolved IDs in a mapping table that refreshes on a schedule, and fail the job loudly when a canonical metric does not resolve.
What breaks when you copy a metric ID into a second account
The break is silent because the destination account answers the request instead of refusing it. Klaviyo's API is a JSON:API implementation, and in that specification a resource ID is assigned by the server and is only unique within that server's own scope (jsonapi.org, resource identification). Your API key is also bound to a single account (Klaviyo, authenticate API requests), so there is no situation where a request can reach across accounts and fetch the metric you actually meant.
What you get instead depends on the call. A metric aggregate query, which takes a metric_id in the request body (Klaviyo, Query Metric Aggregates), will reject an ID that does not exist in that account. If your sync job catches exceptions per account and moves on, that rejection becomes a missing row, and a missing row in a warehouse table almost always renders as zero in the dashboard on top of it. Worse is the case where the ID does exist in the destination account but belongs to something else, because base62 style IDs collide across accounts more often than people assume when you have six or more accounts and hundreds of metrics in each. Then the chart is not empty. It is wrong, and it is plausible.
This is the same class of problem behind rollup reports that never line up. One brand contributes zero to the group total, nobody questions a small brand having a slow month, and the gap survives three quarterly reviews.
How to tell a wrong ID from a metric with no events
Fetch the ID on its own and read the name that comes back. A single request to the metric endpoint separates the three cases cleanly, and it costs you one call per metric per account.
| What the fetch returns | What it means | Next step |
|---|---|---|
| 404 or a not found error | The ID belongs to a different account | Re-resolve by name in this account |
| 200 with a name you did not expect | ID collision, you are reading another metric | Stop the job and audit every hardcoded ID |
| 200 with the right name, aggregate returns zeroes | Correct metric, no events in the window | Check the integration, not the reporting code |
Do this before you go looking at the integration. Half the time an operator spends a day checking whether Shopify is still pushing orders into a quiet account when the account was never being read in the first place. Run the same check on every account in the group rather than only the one that looks wrong, because the collision case does not look wrong from the outside.
Where hardcoded IDs hide
They hide in four places, and only one of them is code you own. First, warehouse and BI sync jobs, where someone pasted an ID into a config file during the first integration and copied that file for each new brand. Second, flows, where the trigger metric and the conversion metric are stored as references and a flow moved between accounts loses them. That is the failure described in why cloned Klaviyo flows break in the destination account, and it comes from the same root cause. Third, segment and profile definitions that a team exported as JSON from one account and reimported into another. Fourth, middleware between your platform and Klaviyo that filters or routes on metric ID before forwarding events.
Search your repositories and configuration for short opaque strings sitting next to the word metric. If you cannot find where an ID entered the system, treat that path as unverified rather than fine.
Resolve metrics by name at run time
List the account's metrics, build a lookup, and use it for the rest of the run. The list endpoint returns each metric's ID, its name, and the integration that created it (Klaviyo, Get Metrics), which is everything you need to key the map.
# once per account, cached for a day
metric_map = {}
for m in list_all_metrics(account_key):
attrs = m['attributes']
integration = (attrs.get('integration') or {}).get('name', 'api')
metric_map[(attrs['name'], integration)] = m['id']
placed_order = metric_map.get(('Placed Order', 'Shopify'))
if placed_order is None:
raise LookupError('Placed Order unresolved in ' + account_label)
Two operational notes. Paginate the list properly, since accounts that have been running for years accumulate more metrics than one page holds. And cache the result, because doing this resolution at the top of every job across six accounts adds a burst of calls to an account limit you may already be close to. If that burst matters to you, the sequencing in how one API key throttles six brands applies here directly.
Why name matching alone still gets it wrong
Because one account can hold two metrics with the same name from different sources. A store running Shopify plus a subscription app, or a headless build that pushes its own order events through the API alongside the native integration, will show two entries called Placed Order. Match on name alone and you get whichever one the API happened to return first, which means your revenue number is a subset of orders and stays a subset forever. Keying on the pair of name and integration removes that ambiguity.
The other failure is renaming. A metric name is editable, and a well meaning person tidying up an account will break every job that resolves by name at the moment they save. That is an argument for a naming standard held across the group rather than an argument for going back to IDs. If you do not have one yet, the conventions in the multi-account Klaviyo schema are the version I use. Also expect that custom metrics do not exist in an account until the first event arrives, so a brand new brand will fail resolution for reasons that are correct.
Keep the metric map as data, not as code
Store the resolution output in a table you can query, with one row per canonical key per account: canonical key, account label, metric ID, integration name, resolved timestamp. Refresh it on a schedule and diff it against the previous run. That diff is the most useful artefact in the whole setup, because it tells you the day a metric was renamed, the day a second Placed Order appeared, and the day a new account stopped resolving a key that every other account has.
Treat every stored ID as disposable. It is a cache of a lookup, not a fact about the business. When someone asks you to point a report at a brand, they are giving you a canonical key and an account, and your code turns that into an ID at run time. Nobody should ever be pasting an ID into a ticket.
How to make the failure loud
Add two assertions and you will never lose a quarter to this again. The first runs on deploy and after each refresh: every canonical key resolves in every account, or the job fails. Unresolved must never fall through to null and then to zero. The second runs on the data: for each account, core metrics such as Placed Order and Started Checkout must have a non zero event count in the trailing seven days, and anything that goes flat gets an alert rather than a blank chart.
Then close the loop against a system outside Klaviyo. Comparing metric revenue per account against the platform's own order totals catches both the wrong ID case and the partial match case, since both produce a number that is too low rather than one that is absent. The reconciliation method in reconciling Klaviyo revenue with Shopify numbers is the check I run monthly on every account in a group.
Trade-offs and what I would do
Resolving by name is not free. It adds a list call per account, it introduces a dependency on names staying stable, and it fails on accounts where a custom metric has not fired yet. Hardcoding IDs has none of that overhead and works perfectly for as long as you have one account. The asymmetry is in how each one fails. A name miss fails at the top of the job with a message naming the account and the key. An ID miss fails as a plausible number in a report that an executive reads.
My rule is that the moment a second account exists, IDs stop being allowed in configuration. Between those two points there is a middle path worth knowing about: keep the stored IDs, but verify each one with a cheap fetch at the start of the run and re-resolve only the ones whose name no longer matches. That gives you one call per metric instead of a full list pull, and it turns the collision case into an immediate failure. It is the version I use on accounts with heavy job schedules where the extra list calls would compete with the sync itself.
What I would not do is try to keep the IDs aligned across accounts by creating metrics in a particular order or by any other trick that depends on the vendor's ID assignment. Those identifiers are opaque by design and Klaviyo makes no promise about how they are generated (Klaviyo, API overview). Build for the fact that they differ, and the whole class of problem stops appearing.