API Reference
Everything you need to integrate EarlyTell's prediction, outcome, and drift-monitoring endpoints.
On this page
Getting started
Endpoints
Overview
EarlyTell turns your labeled history into predictions for the customers,
accounts, or other entities you care about right now — no model training
pipeline of your own required. Send us recent examples with known outcomes
(context) alongside the entities you want scored (query), and the API
returns a prediction for each one.
target is a label you pick, not a fixed vocabulary the API
understands. The model has no built-in notion of churn — or of anything
else. It fits whatever pattern your labeled rows show, and nothing about
the request or response shape changes with what you're predicting. The
directions clients ask for most:
- Expansion / upsell likelihood — which accounts are ripe for a plan upgrade or a paid add-on.
- Trial-to-paid conversion — which active trial signups are likely to convert.
- Lead scoring — which inbound leads are worth working first.
- Churn risk — which current customers are likely to cancel.
- Payment / dunning failure risk — which upcoming invoices are likely to fail.
Support-ticket escalation risk works the same way, and so does an outcome of your own. All of them share one shape: one row per entity, a mix of numeric and categorical columns, and a known result on the rows you've already decided. Choosing what to send gives a starter column list for each.
Churn is this reference's running example throughout — it's the use case
we've measured the model against on a public dataset — but nothing in the
API is specific to it. So, concretely: cust_1 and cust_2 already
churned or didn't, so they're context — reference rows the model learns
from. cust_3 hasn't been decided yet, so it's a query row — one you
want a prediction for. One /v1/predict call can carry any number of each;
this is just the shape of it:
| Row | entity_id | plan | logins_30d | outcome (known) | predicted |
|---|---|---|---|---|---|
| Reference | cust_1 | growth | 4 | 1 (churned) | — |
| Reference | cust_2 | starter | 22 | 0 (active) | — |
| Query | cust_3 | starter | 2 | — | 0.71 → likely to churn |
The greenish rows are context — history you already know the outcome
for. The blue row is query — what you're asking the model to score; its
predicted value is what /v1/predict hands back for that row. Once you
later learn what cust_3 actually did, report it to /v1/outcomes and it
becomes reference data for next time.
A second worked example, in the opposite direction — and the reason the
feature columns matter more than the outcome you've picked. The model
looks at all of a row's features together, not one column at a time: it
can pick up on how columns relate to each other within a row, not just
each column's effect in isolation. Predicting upsell likelihood with
plan and usage_pct (percent of the plan's usage limit consumed) as
features: neither column alone is enough to separate the upgraders below —
heavy usage on the growth plan doesn't mean anything (there's nowhere
further to upgrade to), and light usage on starter doesn't either. It's a
starter-plan customer running up against their limit that upgrades, and
cust_5 matches that combination:
| Row | entity_id | plan | usage_pct | upgraded (known) | predicted |
|---|---|---|---|---|---|
| Reference | cust_1 | starter | 92 | 1 (upgraded) | — |
| Reference | cust_2 | starter | 18 | 0 (didn't) | — |
| Reference | cust_3 | growth | 95 | 0 (didn't) | — |
| Reference | cust_4 | growth | 22 | 0 (didn't) | — |
| Query | cust_5 | starter | 88 | — | 0.81 → likely to upgrade |
More relevant feature columns generally help, as long as they're populated and mean the same thing in every row.
The usual loop is: predict, report outcomes, check drift. A
typical integration runs this as a nightly or weekly batch job: send your
most recent labeled history as context plus your current customers as
query, store the returned scores, report each outcome to
/v1/outcomes once you know it, and check /v1/drift weekly to see
whether the model's error rate is holding steady. You don't need to call
the API in a request/response path for every user action — most
integrations call it on a schedule.
/v1/drift is pull, not push: we don't email or webhook you when
trend turns "degrading" today — your own scheduled job is what checks
it and decides what to do with the answer (page someone, open a ticket,
whatever fits your stack). Build that check into whatever already runs
your /v1/outcomes reporting job, rather than waiting for a signal we
don't yet send.
The base URL is https://beta.earlytell.eu (in development, use
http://localhost:3070). If you want to try a request payload without
writing any code first, the dashboard's Try it page runs real
predictions against your account's quota and shows you the response.
Choosing what to send
What decides whether this works for you isn't the model — it's what's in each row. An entity id and one or two columns will still return numbers, but they won't be numbers worth acting on. This is the part to get right before you write any integration code.
How much is enough
One row per entity — one per customer, lead, trial, invoice, or ticket — never one row per event. Roll your event tables up into counts first.
- 8 to 30 feature columns, and at least 4 meaningful ones beyond the entity id. Two columns is a coin flip with extra steps.
- A few hundred to a few thousand labeled rows in
context. The model starts working from around 300 rows and keeps improving up to roughly 10,000. The practical ceiling on one synchronous request is your plan's rows-per-request limit (see Limits) — send your most recent history rather than everything you have.
More columns generally help, as long as each one is populated in most rows and means the same thing in every row.
Four buckets to fill
Take each bucket in turn and pull whatever your product actually records. The column names below are examples, not a schema we expect — you pick the names, and the model reads them as opaque columns.
| Bucket | What it captures | Example columns |
|---|---|---|
| Who they are | Stable facts about the account | plan, tenure_months, seats, industry, country, signup_channel |
| What they do | Activity counts over a recent, fixed window | posts_30d, orders_30d, api_calls_30d, logins_30d, projects_created_30d, active_seats_30d |
| Money | What they pay and how reliably | mrr, discount_pct, failed_payments_90d, days_since_last_invoice |
| Friction | Where things are going wrong for them | support_tickets_90d, errors_30d, days_since_last_activity |
The activity bucket is the one worth spending time on, and it's the most
product-specific: a publishing tool counts posts_30d, a store counts
orders_30d, a developer tool counts api_calls_30d, a project tool
counts projects_created_30d. Wherever you can, send a trend pair
rather than a single number — orders_30d alongside orders_prev_30d
lets the model see a customer going quiet, which neither number can show
on its own.
Starter columns per use case
Six shapes we see most often. Treat each list as a first draft to swap your own equivalents into — the point is the mix of buckets, not the exact names.
| Use case | target and outcome |
Starter feature columns |
|---|---|---|
| Churn risk | churn_risk; 1 if the account cancelled within 90 days of the row |
plan, tenure_months, seats, mrr, logins_30d, logins_prev_30d, active_seats_30d, support_tickets_90d, days_since_last_activity |
| Upsell / expansion | upsell_likelihood; 1 if they upgraded or added a paid add-on |
plan, tenure_months, seats, usage_pct, seats_added_90d, mrr, api_calls_30d, api_calls_prev_30d, days_since_last_upgrade |
| Trial-to-paid | trial_conversion; 1 if the trial converted to a paid plan |
signup_channel, industry, company_size, days_into_trial, activation_steps_done, logins_7d, invites_sent, projects_created, support_tickets |
| Dunning / payment failure | payment_failure_risk; 1 if the next invoice failed |
plan, mrr, payment_method_type, card_expires_in_days, failed_payments_90d, retries_last_invoice, days_since_last_successful_payment, country, currency |
| Lead scoring | lead_score; 1 if the lead closed |
source, industry, company_size, seniority, country, pages_viewed_14d, demo_requested, email_replies, days_since_first_touch |
| Ticket escalation | escalation_risk; 1 if the ticket escalated or breached SLA |
plan, priority, channel, hours_open, first_response_minutes, replies_so_far, reopened_count, customer_tickets_90d, agent_changes |
The point-in-time rule
This is the one that quietly ruins otherwise good integrations.
Every labeled row must describe the entity as it was just before the
outcome happened — not as it is today. And every query row must be
built the same way, as of today, because today is the moment you're asking
about.
Say you're predicting churn and you compute logins_30d now for a
customer who cancelled six months ago. That customer's login count is
0 — not because they were disengaged before they left, but because
they've been gone half a year. The model dutifully learns "zero logins
means churn" from a fact that didn't exist at the moment the decision was
being made. It will look excellent in your testing, because your test rows
carry the same giveaway, and then fail in production, where nobody has
cancelled yet and no row has it.
So: pick a cutoff per row — the moment the outcome became known — and
compute every window relative to that row's cutoff, not to now().
Never send outcome-derived columns
The same failure in a more obvious form: a column that only exists because the outcome happened. Leave these out entirely.
cancellation_date,cancellation_reason,churn_reasonrefund_issued,refund_amountclosed_won_date,close_reason,final_contract_valueescalated_to,resolution_notes- any
statusfield that was rewritten when the outcome landed (anaccount_statusof"cancelled"is the answer, not a feature)
A good test: could you have filled this column in, truthfully, the day before you knew the outcome? If not, drop it.
Building the rows: a SQL sketch
A sketch over a typical Postgres schema — an accounts table and an
events table — to adapt, not to copy. decided_at is whatever column
records the moment the outcome became known (the cancellation, or the
renewal the account survived).
-- Context rows: each account as it stood just before its own decision point.
select a.id as entity_id,
a.plan,
(a.decided_at::date - a.created_at::date) / 30 as tenure_months,
a.seats,
a.mrr,
count(*) filter (where e.created_at > a.decided_at - interval '30 days') as logins_30d,
count(*) filter (where e.created_at <= a.decided_at - interval '30 days'
and e.created_at > a.decided_at - interval '60 days') as logins_prev_30d,
(a.cancelled_at is not null)::int as outcome
from accounts a
left join events e on e.account_id = a.id
and e.name = 'login'
and e.created_at < a.decided_at -- nothing after the cutoff
where a.decided_at is not null
group by a.id;
The query version is the same select with now() in place of
a.decided_at, no outcome, and the rows that haven't been decided yet:
-- Query rows: the same columns, as of today, for accounts still open.
select a.id as entity_id,
a.plan,
(current_date - a.created_at::date) / 30 as tenure_months,
a.seats,
a.mrr,
count(*) filter (where e.created_at > now() - interval '30 days') as logins_30d,
count(*) filter (where e.created_at <= now() - interval '30 days'
and e.created_at > now() - interval '60 days') as logins_prev_30d
from accounts a
left join events e on e.account_id = a.id and e.name = 'login'
where a.cancelled_at is null
group by a.id;
Keep the two queries next to each other in your codebase. When you add a
column to one, add it to the other the same day — a feature computed
differently between context and query hurts accuracy without raising
an error.
What "too thin" looks like
The payloads that come back with disappointing predictions almost always look like one of these:
- An id plus
plan, and nothing else. Two columns can't separate customers who behave differently. - A column with the same value in every row. It costs you nothing and tells the model nothing; either fill it properly or drop it.
- A column with a different value in every row — an internal reference, a company name, a URL, a free-text note. It's an id in disguise: there's nothing to generalise from a value the model only ever sees once.
- Fewer than about 50 labeled rows. There isn't enough history there for a pattern to exist.
- For classification, fewer than about 10 rows of either class. Fifty rows where only two churned tells the model almost nothing about churning.
You don't have to guess whether you've hit one of these: /v1/predict
returns a warnings array describing exactly what it saw,
without ever blocking the request. The dashboard's Try it page shows
the same warnings above its results.
{
"target": "churn_risk",
"task_type": "classification",
"context": [
{
"entity_id": "acct_1042",
"features": {
"plan": "growth", "tenure_months": 19, "seats": 12, "country": "FR",
"logins_30d": 3, "logins_prev_30d": 41, "active_seats_30d": 2,
"mrr": 240, "failed_payments_90d": 1,
"support_tickets_90d": 4, "days_since_last_activity": 21
},
"outcome": 1
}
],
"query": [
{
"entity_id": "acct_2288",
"features": {
"plan": "growth", "tenure_months": 7, "seats": 15, "country": "DE",
"logins_30d": 9, "logins_prev_30d": 33, "active_seats_30d": 5,
"mrr": 300, "failed_payments_90d": 0,
"support_tickets_90d": 2, "days_since_last_activity": 6
}
}
]
}
Authentication
Every request needs an API key, sent as a bearer token:
We recommend one key per environment (for example, one for staging and one for production) so you can tell their traffic apart and revoke one without affecting the other. Keys are shown once, at creation — store it somewhere your team can retrieve it, because we can't show it to you again. If a key leaks, revoke it and issue a new one.
Key creation and revocation from the dashboard is coming soon; until then, contact us to get a key or to revoke one.
A missing, malformed, or revoked key gets a 401:
curl https://beta.earlytell.eu/v1/predict \
-H "Authorization: Bearer rs_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "...": "..." }'
Response
{ "error": { "code": "unauthorized", "message": "Missing, invalid or revoked API key" } }
Errors
Every error response has the same shape:
| Code | HTTP status | What to do |
|---|---|---|
unauthorized |
401 | Check the key is present, current, and not revoked. |
plan_forbidden |
403 | Your plan doesn't include this feature; upgrade or contact us. |
trial_expired |
403 | Your trial period ended; upgrade to keep making predictions. |
subscription_canceled |
403 | Your subscription was canceled; reactivate billing to keep making predictions. |
subscription_past_due |
402 | A renewal payment has been failing for more than 7 days; update your payment method from the dashboard's Billing page. |
invalid_request |
422 | Fix the payload — the message says which field. |
unsupported_mode |
422 | Only mode: "sync" is available today; drop mode or set it explicitly to "sync". |
row_limit_exceeded |
422 | Your context + query rows exceed your plan's per-request limit; split the batch or upgrade. |
target_required |
422 | GET /v1/drift needs a target param — your account has predicted more than one. |
monthly_quota_exceeded |
429 | You've hit your plan's monthly request quota; wait for the next billing cycle or upgrade. |
rate_limited |
429 | You're sending requests too fast; retry with backoff. |
provider_error |
502 | Something failed upstream of us; retry with backoff. |
internal_error |
500 | Something failed on our side; retry with backoff, and tell us if it persists. |
malformed_json |
400 | The request body isn't valid JSON; check for stray commas or unescaped characters. |
As a rule: provider_error, rate_limited, and internal_error are worth
retrying with exponential backoff; invalid_request, malformed_json,
and target_required mean the request needs fixing before you retry;
plan_forbidden, trial_expired, subscription_canceled,
subscription_past_due, row_limit_exceeded, and
monthly_quota_exceeded mean you need a different plan (or active
billing), not a retry.
Response
{ "error": { "code": "row_limit_exceeded", "message": "request has 12,000 rows; the starter plan allows 10,000 per request" } }
Limits
| Plan | Rows per request | Requests per month | Drift monitoring |
|---|---|---|---|
| Trial | 2,000 | 200 | Included |
| Starter | 10,000 | 5,000 | Not included |
| Growth | 50,000 | 50,000 | Included |
The trial plan runs for 14 days from account creation, card-free. Once it
ends, /v1/predict (and the dashboard's Try it page) return
trial_expired until you subscribe to a paid plan. Subscribing during the
trial ends it immediately — remaining trial days aren't carried over.
If a renewal payment fails on a paid plan, Stripe retries it and you keep
making predictions for a 7-day grace period. If it's still unpaid after 7
days, /v1/predict returns subscription_past_due (402) until the payment
goes through. Once the subscription is canceled (by you, or by Stripe after
its retries run out), /v1/predict returns subscription_canceled until
you subscribe again from the Billing page.
A canceled, past-due-beyond-grace, or expired-trial account only loses new
predictions: POST /v1/outcomes and GET /v1/drift keep working, and
already-recorded predictions, outcomes, and drift history are unaffected.
Manage billing (payment method, plan, invoices, or cancellation) from the
dashboard's Billing page; anyone on the account can do this.
A few limits apply regardless of plan:
- Rate limit: 120 requests per minute per API key, independent of your monthly quota.
- Outcome batches: at most 1,000 outcomes per
/v1/outcomesrequest. - Try it page: at most 5,000 rows per run (or your plan's per-request limit, whichever is lower) — use the API directly for larger batches. Also rate limited to 20 runs per minute per signed-in user.
Pending and completed prediction requests both count against your monthly quota; only failed requests (ones that errored before or during inference) don't.
Data & privacy
What we store: the entity ids you send, a one-way fingerprint of each query row's feature values (not the values themselves), the predictions we return, the outcomes you report, and request metadata (target, task type, row counts, timing, and — if a request fails — an error message).
What we don't store: your raw context and query rows, or the feature
values inside them. Raw payloads exist only in memory for the duration of
the request, long enough to run inference and compute a fingerprint; they
are never written to our database. That fingerprint is a one-way digest,
so we can later match a reported outcome back to the prediction it belongs
to without ever holding on to the features that produced it. Our logs also
filter known-sensitive fields (including context, query, and
features) so they never appear in application logs either.
The same rule applies to the warnings in a predict response: they name
your column names (support_tickets_90d, crm_stage) and report counts,
and never quote a feature value back at you. Nothing about a warning is
stored — they're computed from the payload in memory and returned with
that one response.
End-to-end example
A minimal script that predicts, reports an outcome, and checks drift — enough to adapt for a nightly job. The endpoint reference below covers every parameter and error in detail; this is just the shape of it end to end.
import requests
API_KEY = "rs_live_xxxxxxxxxxxxxxxx"
BASE_URL = "https://beta.earlytell.eu"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
# 1. Predict: score current customers against recent labeled history.
predict_response = requests.post(
f"{BASE_URL}/v1/predict",
headers=HEADERS,
json={
"target": "churn_risk",
"task_type": "classification",
"context": [
{"entity_id": "cust_1", "features": {"plan": "growth", "logins_30d": 4}, "outcome": 1},
{"entity_id": "cust_2", "features": {"plan": "starter", "logins_30d": 22}, "outcome": 0},
],
"query": [
{"entity_id": "cust_3", "features": {"plan": "starter", "logins_30d": 2}},
],
},
)
predict_response.raise_for_status()
predictions = predict_response.json()["predictions"]
for p in predictions:
print(f"{p['entity_id']}: {p['prediction']:.2f} (label={p.get('label')})")
# ... store p["entity_id"] and p["prediction"] wherever you track scores.
# 2. Report outcomes: once you know what actually happened.
outcomes_response = requests.post(
f"{BASE_URL}/v1/outcomes",
headers=HEADERS,
json={
"outcomes": [
{"entity_id": "cust_3", "actual_value": 1, "target": "churn_risk"},
],
},
)
outcomes_response.raise_for_status()
print(outcomes_response.json())
# 3. Check drift: run this on a schedule (e.g. weekly), not per-prediction.
drift_response = requests.get(f"{BASE_URL}/v1/drift", headers=HEADERS, params={"target": "churn_risk"})
drift_response.raise_for_status()
print(drift_response.json())
Predict — POST /v1/predict
Send recent labeled examples and the entities you want scored; get a prediction back for each one.
Parameters
-
targetstring requireda name for what you're predicting (for example
churn_risk). Used to group outcomes and drift together, so keep it stable across requests for the same use case. -
task_typestring"regression"(default) or"classification". -
modestring"sync"is the only supported value today;"async"returnsunsupported_mode. -
callback_urlstringreserved for async mode; accepted but not yet used.
-
target_horizonstringoptional free-form label (for example
"30_days") stored alongside each prediction. -
contextarray of objects requiredlabeled history to learn from.
-
entity_idstring requiredan id for the row (doesn't need to be unique across
context). -
featuresobject requiredthe same feature keys on every row in both
contextandquery. -
outcomenumber or boolean requiredthe known result for this row. For classification, must be
0/1ortrue/false, and both classes must appear somewhere incontext.
-
-
queryarray of objects requiredthe entities you want predictions for.
-
entity_idstring requiredunique within
query. -
featuresobject requiredsame feature keys as
context.
-
Every feature column must hold one type across every row it appears in —
number, string, boolean, or null — and can't be null in every row. If
context[3].features and query[1].features don't have exactly the same
keys as context[0].features, the request is rejected with the specific
mismatch.
How much context to send: hundreds to tens of thousands of labeled rows
work, and more recent history tends to predict better than older history.
Whatever feature engineering you do to build context rows, do the same
for query rows — a feature that's computed differently between the two
will hurt accuracy without raising an error.
Which columns to put in those rows is the thing most worth getting right: Choosing what to send covers how many columns to aim for, what to fill them with per use case, and the point-in-time rule that keeps a labeled row honest.
Returns
-
request_idstringid for this prediction request; useful when contacting support about a specific batch.
-
predictionsarrayone entry per
queryrow, in the same order.-
entity_idstringechoes the
queryrow'sentity_id. -
predictionnumberthe predicted value (regression) or the probability of class
1(classification). -
labelnumber0or1; present only for classification.
-
-
warningsarrayadvisory notes about the data you sent, present only when there's something to flag. A request that looks fine has no
warningskey at all, so this is safe to ignore in existing clients. Warnings never block a prediction and never change the result — they explain why the numbers may be weaker than they could be.-
codestringa stable machine-readable code from the table below.
-
messagestringa human-readable explanation, naming the columns involved (column names only — never your feature values).
-
code |
What triggered it | What to do |
|---|---|---|
few_feature_columns |
Fewer than 4 feature columns in the request. | Add columns from the four buckets in Choosing what to send; aim for 8–30. |
constant_feature |
A column holds the same value in every row. The message names it (up to three, then "and N more"). | Drop it, or populate it properly — a column that never varies can't separate anything. |
identifier_like_feature |
A string column whose values are distinct in at least 95% of rows, in a request of at least 10 rows. Numeric columns are never flagged, since a genuinely continuous measure is also near-unique. | It's most likely an id, name, URL, or free-text note. Replace it with something comparable across rows: a category, a count, a number of days. |
few_context_rows |
Fewer than 50 labeled context rows. |
Widen the history window you pull from. A few hundred rows is where this starts working well. |
imbalanced_classes |
Classification only: fewer than 10 context rows of either class. |
Go further back for the rarer outcome, or keep every rare row and sample the common one. |
sparse_feature |
A column is null in more than half the rows. | Backfill it or leave it out; a mostly-empty column crowds out the ones that matter. |
When present, warnings sits alongside predictions:
{
"request_id": "018f2f4e-1a2b-7c3d-9e4f-abc123456789",
"predictions": [ { "entity_id": "cust_3", "prediction": 0.71, "label": 1 } ],
"warnings": [
{ "code": "few_feature_columns", "message": "This request has 2 feature columns. …" },
{ "code": "few_context_rows", "message": "Only 2 labeled context rows. …" }
]
}
Common errors here: invalid_request (a row is missing entity_id or
features, feature keys don't match across rows, or a classification
outcome isn't binary), row_limit_exceeded (too many rows for your plan),
monthly_quota_exceeded, and provider_error (inference failed
upstream — safe to retry).
curl https://beta.earlytell.eu/v1/predict \
-H "Authorization: Bearer rs_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"target": "churn_risk",
"task_type": "classification",
"context": [
{ "entity_id": "cust_1", "features": { "plan": "growth", "logins_30d": 4 }, "outcome": 1 },
{ "entity_id": "cust_2", "features": { "plan": "starter", "logins_30d": 22 }, "outcome": 0 }
],
"query": [
{ "entity_id": "cust_3", "features": { "plan": "starter", "logins_30d": 2 } }
]
}'
import requests
response = requests.post(
"https://beta.earlytell.eu/v1/predict",
headers={"Authorization": "Bearer rs_live_xxxxxxxxxxxxxxxx"},
json={
"target": "churn_risk",
"task_type": "classification",
"context": [
{"entity_id": "cust_1", "features": {"plan": "growth", "logins_30d": 4}, "outcome": 1},
{"entity_id": "cust_2", "features": {"plan": "starter", "logins_30d": 22}, "outcome": 0},
],
"query": [
{"entity_id": "cust_3", "features": {"plan": "starter", "logins_30d": 2}},
],
},
)
response.raise_for_status()
print(response.json())
require "net/http"
require "json"
uri = URI("https://beta.earlytell.eu/v1/predict")
request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer rs_live_xxxxxxxxxxxxxxxx")
request.body = {
target: "churn_risk",
task_type: "classification",
context: [
{ entity_id: "cust_1", features: { plan: "growth", logins_30d: 4 }, outcome: 1 },
{ entity_id: "cust_2", features: { plan: "starter", logins_30d: 22 }, outcome: 0 }
],
query: [
{ entity_id: "cust_3", features: { plan: "starter", logins_30d: 2 } }
]
}.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.body
Response
{
"request_id": "018f2f4e-1a2b-7c3d-9e4f-abc123456789",
"predictions": [
{ "entity_id": "cust_3", "prediction": 0.71, "label": 1 }
]
}
Report outcomes — POST /v1/outcomes
Once you know what actually happened for an entity you scored, report it here. This is what powers drift monitoring — without outcomes, we have no way to tell whether predictions are still accurate.
Parameters
-
outcomesarray requiredup to 1,000 per request.
-
entity_idstring requiredmatches the
entity_idyou sent to/v1/predict. -
actual_valuenumber requiredthe real outcome (use
1/0for a classification result you originally predicted as a probability). -
observed_atstringISO 8601 date or timestamp; defaults to now. A date with no time component (
"2026-10-01") is treated as the end of that day, so it still matches predictions made earlier the same day. -
targetstringnarrows matching to predictions made with this
target. Send it whenever the sameentity_idcan appear under more than onetarget(see Matching rules below) — without it, matching can't tell your targets apart. -
featuresobjectnarrows matching to the prediction made with exactly these features (useful if you predict the same entity more than once with different inputs).
-
Matching rules
We match each outcome to the most recent prediction for that entity_id
that doesn't already have an outcome and was made within 180 days before
observed_at, scoped to your account only — never to another client's
predictions. A prediction that already has an outcome is never
overwritten by a later report — if none of your open predictions match, the
outcome comes back unmatched rather than silently attaching to the wrong
one. The whole batch is validated before anything is written, and sending
the same (entity_id, target) pair twice in one batch is rejected outright.
If you predict more than one target (say, churn_risk and
upsell_likelihood) and the same entity_id can have an open prediction
under each at once, always send target when reporting that entity's
outcome. Without it, and without a target, we check whether all of that
entity's open predictions in the match window share one target — if they
don't, we can't tell which one you meant, so the outcome comes back
unmatched ("ambiguous_target") rather than guessing.
Returns
-
matchednumbercount of outcomes that found an open prediction.
-
unmatchedarrayoutcomes that didn't. Order follows processing order (sorted by
entity_id, thentarget), not the order you sent them.-
entity_idstringthe entity that couldn't be matched.
-
reasonstring"no_open_prediction"(no open prediction matched within the match window) or"ambiguous_target"(notargetwas given, and the entity has open predictions under more than one target — sendtargetand retry).
-
curl https://beta.earlytell.eu/v1/outcomes \
-H "Authorization: Bearer rs_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"outcomes": [
{ "entity_id": "cust_3", "actual_value": 1, "observed_at": "2026-10-01", "target": "churn_risk" }
]
}'
import requests
response = requests.post(
"https://beta.earlytell.eu/v1/outcomes",
headers={"Authorization": "Bearer rs_live_xxxxxxxxxxxxxxxx"},
json={
"outcomes": [
{"entity_id": "cust_3", "actual_value": 1, "observed_at": "2026-10-01", "target": "churn_risk"},
],
},
)
print(response.json())
require "net/http"
require "json"
uri = URI("https://beta.earlytell.eu/v1/outcomes")
request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer rs_live_xxxxxxxxxxxxxxxx")
request.body = {
outcomes: [
{ entity_id: "cust_3", actual_value: 1, observed_at: "2026-10-01", target: "churn_risk" }
]
}.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.body
Response
{
"matched": 1,
"unmatched": []
}
Drift — GET /v1/drift
Compares the accuracy of predictions made in the last 30 days against the 30 days before that, so you can see when reality stops matching the model — often the first sign something changed in your business.
Parameters
-
targetstringfilters to one target. Required once your account has predicted more than one target — omitting it returns
target_requiredrather than silently averaging, say,churn_riskandupsell_likelihood's errors into one meaningless MAE. Optional while your account has only ever predicted a single target — still applied as a filter if you pass it anyway.
We compute mean absolute error (MAE) for each 30-day window, using only
predictions that have a reported outcome, and compare the two. Each window
needs at least 10 predictions with outcomes, or trend comes back
"insufficient_data". Otherwise trend is "degrading" when the recent
window's error is more than 20% worse, "improving" when it's more than
20% better, and "stable" otherwise.
Drift monitoring isn't available on the starter plan; requesting it there
returns plan_forbidden.
If trend is "degrading", it usually means something changed in the
world your model is scoring, not a bug in your integration — a new
customer segment, a pricing change, a competitor's move. Refresh the
context you send with more recent history and investigate what shifted
before assuming the model itself is broken.
Returns
-
windowstringalways
"last_30_days". -
targetstringechoed back when you passed one; omitted otherwise.
-
n_predictions_with_outcomenumberpredictions with outcomes in the current window.
-
maenumbermean absolute error for the current window, or
nullif there's no data. -
previous_maenumbersame, for the prior window.
-
trendstring"stable","degrading","improving", or"insufficient_data".
curl "https://beta.earlytell.eu/v1/drift?target=churn_risk" \
-H "Authorization: Bearer rs_live_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://beta.earlytell.eu/v1/drift",
headers={"Authorization": "Bearer rs_live_xxxxxxxxxxxxxxxx"},
params={"target": "churn_risk"},
)
print(response.json())
require "net/http"
require "uri"
uri = URI("https://beta.earlytell.eu/v1/drift?target=churn_risk")
request = Net::HTTP::Get.new(uri, "Authorization" => "Bearer rs_live_xxxxxxxxxxxxxxxx")
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.body
Response
{
"window": "last_30_days",
"target": "churn_risk",
"n_predictions_with_outcome": 214,
"mae": 0.183,
"previous_mae": 0.171,
"trend": "stable"
}