Reference

POST /v1/decide

One call at the signup form, one answer. Sortance verifies the address, reads your active policy, buys an enrichment profile only if a rule that can still fire needs one, and returns the decision with the rule and the reasons behind it.

What happens, in order

  1. Verify. The same pipeline as /v1/verify, your Blocklists included. Its answer is embedded unchanged as verification.
  2. Load configuration. The active policy for the slug, any running experiment, the lists the policy names, and the ICP rubric.
  3. Decide whether to enrich. Rules are walked in order. If an earlier rule settles the request — a disposable address denied by rule one — no profile is bought, and a policy that reads only verification signals costs the same as a bare verify.
  4. Enrich, only for the fields the rules read, under what is left of the cost ceiling.
  5. Score ICP, when the account has a rubric.
  6. Evaluate. First matching rule wins; no match means the policy’s default action. Shadow variants are evaluated alongside and recorded on the decision, but do not change the answer.

No model runs anywhere on this path. A model helps you write a policy, once, when you compile it; every decision is a pure function of typed inputs, which is what makes it replayable.

Request

curl -X POST https://api.sortance.com/v1/decide \
  -H "Authorization: Bearer $SORTANCE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "mia@acme.com",
    "context": {
      "purpose": "signup",
      "enrich": "auto",
      "max_cost_usd": 0.5,
      "attributes": { "plan_selected": "pro", "utm_source": "producthunt" }
    }
  }'

Malformed optional fields are ignored rather than rejected: on the signup path a typo in context must not become a failed signup. Only a missing or invalid address is a 400.

FieldTypeMeaning
emailstringRequired. The address to decide on.
context.purposeenumsignup, outbound or list_hygiene — passed to the verify lane exactly as /v1/verify takes it.
context.policystringPolicy slug to evaluate. Defaults to default.
context.enrichenumauto (default), always or never. auto buys a profile only if evaluation reaches a rule that reads an enrichment or ICP field.
context.max_cost_usdnumberOne ceiling across both lanes. Verification spends first; enrichment gets what is left. Degrade, never exceed.
context.max_latency_msnumberLatency ceiling, applied the same way.
context.attributesobjectYour own string values, readable in rules as attributes.<name>. Names are lowercase snake_case, at most 32 of them, values up to 256 characters. A value that looks like an email address is dropped.
context.signalsobjectTyped for agent attestation (signature_agent_header, signup_ip, otp_delivered_at, otp_submitted_at) but ignored today: the lane that would read it is not built, so actor.* fields are always unknown.
context.sender_domainstringForwarded to the verify lane exactly as /v1/verify takes it.
Set max_cost_usd if you want enrichment

With no ceiling, verification keeps the deployment default and enrichment gets its own $0.10 allowance on top. A person lookup can cost more than that, so a policy that reads person fields should pass an explicit max_cost_usd; otherwise the lookup is skipped, the field is unknown, and the reasons say why.

Response

Example shape. The values are illustrative, not measured.

{
  "decision": "review",
  "flow": null,
  "rule_id": "r_industry_fit",
  "tags": [],
  "policy": { "slug": "default", "version": 14 },
  "reasons": [
    "company.industry is 'logistics', not one of 'fintech', 'insurance'"
  ],
  "verification": { /* VerifyResponse, verbatim */ },
  "enrichment": { /* EnrichmentProfile, or null */ },
  "icp": null,
  "lists": { "matched": [] },
  "cost_usd": 0.1012,
  "latency_ms": 688,
  "trace_id": "dc_01J..."
}
FieldTypeMeaning
decisionenumallow, allow_with_flow, review, challenge or deny. Always set — even when every upstream failed.
flowstring | nullThe flow name when decision is allow_with_flow, e.g. "agent". Otherwise null.
rule_idstring | nullThe rule that matched, or null when the policy's default action answered.
tagsstring[]The matching rule's tags, or [].
policy{ slug, version } | nullThe policy version evaluated. null only when the account has no policy at all and the never-fail-closed default (allow) answered.
experimentobject?Present when an experiment assigned this request: slug, variant, mode (enforce or shadow) and, in shadow mode, shadow_decision — what the assigned variant would have done.
reasonsstring[]One human-readable sentence per predicate that fired, plus any degraded: note. Describes the data, never echoes a local part or a person's title.
verificationVerifyResponseThe /v1/verify response, verbatim, email_type included. Its field names are frozen and this endpoint never reshapes them.
enrichmentEnrichmentProfile | nullThe profile, when the enrich lane ran. null when no rule needed it. See the Enrich reference.
icpIcpScore | nullThe ICP score when your account has an active rubric, otherwise null.
lists{ matched: string[] }Names of the lists whose membership predicate matched.
cost_usdnumberBoth lanes, summed.
latency_msnumberWall-clock for the whole decision.
trace_idstringdc_-prefixed. Quote it in /v1/events, in Stripe customer metadata, and when asking about a decision.

It never fails closed

An enrichment outage, a missing policy or an unreadable list does not break your signup. The response is still 200, the decision is still set, and a degraded: line in reasons says what was missing. A predicate over missing data is unknown, an unknown rule is skipped unless it says on_unknown: "match", and no match means the default action — which is allow unless you chose otherwise.

Reason lineWhen
degraded: enrichment unavailableEvery enrichment upstream failed or none is configured. Rules over enrichment fields evaluate as unknown.
degraded: enrichment skipped, cost ceiling reachedWhat was left of max_cost_usd after verification could not buy a lookup.
degraded: enrichment allowance spentReserved for the monthly enrichment allowance. That allowance is not enforced yet, so this reason does not currently appear.
degraded: policy unavailable, default action appliedThe policy could not be read. The answer is allow.
degraded: lists unavailableA referenced list could not be loaded. Membership is unknown, never "not a member".
degraded: icp unavailableThe ICP rubric could not be read or scored.
degraded: verification answered from priorThe verify lane itself was degraded; see verification.degraded.

Who can say deny

Only you. Sortance classifies — actor_class, email_type, mailbox_type, signals, enrichment, ICP fit — and a deny comes only from a rule in a policy you confirmed. That includes the Email types switches on the Policy screen: “free email: Block” is the rule r_type_free in your default policy, and the response names it (email types). The response names the rule and the version. The compiler prefers review over deny for rules that depend on enrichment, recommendations propose review, and the default action is allow.

Errors

The same envelope as every other endpoint: { "error": { "code", "message", "trace_id" } }. Decide never returns enrichment_quota_exceeded; enrichment trouble degrades the answer instead.

StatusCodeWhen
400invalid_requestMalformed body or an address that fails syntax.
401unauthorizedMissing, malformed or revoked key.
402quota_exceededThe monthly allowance is spent. A decide call is metered like a verification.
429rate_limitedMore than 600 requests a minute, counted across /v1/verify, /v1/decide, /v1/enrich and the configuration endpoints together.
500internal_errorA bug. Carries the trace_id.