Using the API

Authentication, pagination, retries, and the four rules for reading the numbers.

The API is at https://meryn.ai/api/v1/. The full endpoint reference is at /api/v1/docs/ — generated from the API itself, so it cannot drift from what the server actually does, and you can fire every call from there after pasting your key into Authorize.

This page is the part a reference can't tell you: how to authenticate, what the responses promise, and the four rules for reading the numbers.

Authenticate

curl https://meryn.ai/api/v1/models/ \
  -H "Authorization: Bearer mk_a1b2c3d4e5f6_Xk9..."

⚠️ There is no team in the URL, and that's the design. The team comes from the key's row. If it were also in the path, you'd have two sources for the same answer and they could disagree.

Error codes, and what each one means for you:

401 the key is missing, malformed, expired, or revoked — or its creator left the team
403 the key is fine but it isn't allowed: a read key tried to launch, or your plan doesn't include API access
402 your plan's quota is full. The body has upgrade_required: true and which feature ran out
404 it doesn't exist, or it belongs to another team — those two are deliberately the same answer
409 the state doesn't allow it (launching a model that's already running), or an idempotency conflict
429 you passed the daily request limit for your plan

Pagination

List endpoints return a page and always say the total:

{ "count": 148, "limit": 50, "offset": 0, "models": [ … ] }

A cap that doesn't announce itself lies, so count is the whole set and limit/offset move the window. The default is 50, the maximum 200.

Chart datasets are the exception: they come whole. GET /models/{id}/charts/{dataset}/ returns every row unless you pass limit, and says "truncated": false. The reason is that a series is drawn, not browsed — the first 50 of 104 weeks look like a complete chart of a model that stopped predicting in June. One response is one chart.

Launching runs safely

Every POST accepts an Idempotency-Key header:

curl -X POST https://meryn.ai/api/v1/models/{id}/run/ \
  -H "Authorization: Bearer mk_…" \
  -H "Idempotency-Key: nightly-2024-03-11"

Retry with the same key and you get the stored response back, with Idempotent-Replay: true, instead of a second run. Same key with a different body is a 409 — that's a bug on your side, not something to retry.

⚠️ Use it on anything that launches. A retry you didn't mean costs tens of minutes of compute and one of your concurrent-run slots. If you don't send the header, two POSTs are two runs, which is also the correct behaviour — it just isn't what a retry loop wants.

The four rules for reading the numbers

Every response that carries measurements also carries a units map. These four rules are the whole contract, and getting one wrong produces a wrong budget recommendation two steps later without anything failing.

1. fraction is 0–1, percent is 0–100. Both exist, in the same response, on purpose: we store what the framework gives us rather than converting on the way in. Read the unit, don't guess from the name — spend_share in a chart dataset is a fraction while share_of_spend in the underlying table is a percentage.

2. A null is "not measured", never a zero. An organic channel has contribution but no spend, so its ROI, marginal ROI, efficiency and share-of-spend are null — a 0 would read as "we measured it and it was zero". Averaging nulls as zeros drags the average down. Where it matters, the response says what the null means:

"null_means": { "roi": "the channel has no spend, so it has no ROI" }

3. What's in not_applicable doesn't exist for that engine. Robyn doesn't do MCMC, so it has no r-hat and no divergences. That is not missing data to go looking for.

4. pending and not_evaluated are not green. On the readiness verdict, pending means the check hasn't run yet and not_evaluated means it ran and couldn't measure anything. Treating either as a pass approves a model nobody looked at.

Charts, and what a market cut means

GET /models/{id}/charts/ tells you which datasets a model has and which axis it cuts on — you don't have to guess:

{ "engine": "meridian", "axis": "geo", "available": ["AR","BR","CL"], "charts": ["waterfall", …] }
  • axis: "geo" — pass ?geo=AR. Omit it for the national view.
  • axis: "sol_id" — Robyn, which doesn't model markets but has hundreds of solutions on its Pareto front. Omit it to get the one chosen in the app.
  • axis: null — nothing to choose. A national model holds one view.

⚠️ Watch national_only. Some charts can't be cut by market at all — Meridian estimates one decay per channel shared across markets, and r-hat belongs to the model, not to a region. Others can't be cut on your model because it was trained before we started saving them per market. Either way the response says so instead of handing you the national numbers under a market's label:

"national_only": "Meridian estimates one decay per channel, shared across markets"

If that field is set, the rows are national. Re-running the model clears the second kind.

When the chart isn't enough

A chart dataset aggregates on purpose: the waterfall sums over every date, and the decomposition folds intercept, trend and seasonality into one baseline series. So "contribution of TV by week" and "how much did trend add" aren't in it.

GET /models/{id}/results/ lists the finer-grained tables, and /results/{family}/ returns one of them whole — with the market and the date as columns rather than as parameters, so a single request gets you the cube and you group it yourself.

Takeaway: read units and null_means on every response instead of inferring scale from a field name, and send an Idempotency-Key on anything that launches.