EarlyTell

API Reference

Everything you need to integrate EarlyTell's prediction, outcome, and drift-monitoring endpoints.

On this page

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_reason
  • refund_issued, refund_amount
  • closed_won_date, close_reason, final_contract_value
  • escalated_to, resolution_notes
  • any status field that was rewritten when the outcome landed (an account_status of "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.

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:

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/outcomes request.
  • 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

  • target string required

    a 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_type string

    "regression" (default) or "classification".

  • mode string

    "sync" is the only supported value today; "async" returns unsupported_mode.

  • callback_url string

    reserved for async mode; accepted but not yet used.

  • target_horizon string

    optional free-form label (for example "30_days") stored alongside each prediction.

  • context array of objects required

    labeled history to learn from.

    • entity_id string required

      an id for the row (doesn't need to be unique across context).

    • features object required

      the same feature keys on every row in both context and query.

    • outcome number or boolean required

      the known result for this row. For classification, must be 0/1 or true/false, and both classes must appear somewhere in context.

  • query array of objects required

    the entities you want predictions for.

    • entity_id string required

      unique within query.

    • features object required

      same 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_id string

    id for this prediction request; useful when contacting support about a specific batch.

  • predictions array

    one entry per query row, in the same order.

    • entity_id string

      echoes the query row's entity_id.

    • prediction number

      the predicted value (regression) or the probability of class 1 (classification).

    • label number

      0 or 1; present only for classification.

  • warnings array

    advisory notes about the data you sent, present only when there's something to flag. A request that looks fine has no warnings key 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.

    • code string

      a stable machine-readable code from the table below.

    • message string

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

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

  • outcomes array required

    up to 1,000 per request.

    • entity_id string required

      matches the entity_id you sent to /v1/predict.

    • actual_value number required

      the real outcome (use 1/0 for a classification result you originally predicted as a probability).

    • observed_at string

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

    • target string

      narrows matching to predictions made with this target. Send it whenever the same entity_id can appear under more than one target (see Matching rules below) — without it, matching can't tell your targets apart.

    • features object

      narrows 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

  • matched number

    count of outcomes that found an open prediction.

  • unmatched array

    outcomes that didn't. Order follows processing order (sorted by entity_id, then target), not the order you sent them.

    • entity_id string

      the entity that couldn't be matched.

    • reason string

      "no_open_prediction" (no open prediction matched within the match window) or "ambiguous_target" (no target was given, and the entity has open predictions under more than one target — send target and retry).

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

  • target string

    filters to one target. Required once your account has predicted more than one target — omitting it returns target_required rather than silently averaging, say, churn_risk and upsell_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

  • window string

    always "last_30_days".

  • target string

    echoed back when you passed one; omitted otherwise.

  • n_predictions_with_outcome number

    predictions with outcomes in the current window.

  • mae number

    mean absolute error for the current window, or null if there's no data.

  • previous_mae number

    same, for the prior window.

  • trend string

    "stable", "degrading", "improving", or "insufficient_data".

Response

{
  "window": "last_30_days",
  "target": "churn_risk",
  "n_predictions_with_outcome": 214,
  "mae": 0.183,
  "previous_mae": 0.171,
  "trend": "stable"
}