{"openapi":"3.1.0","info":{"title":"TruthFactor Signal API","version":"0.1.0","description":"Identity corroboration. Tell us what you know about a person — a phone number, a name, an\nemail, a professional profile — and we tell you which of it holds up.\n\n## Authentication\n\nEvery request carries a bearer key. An admin of an active account mints keys from the\ndashboard; the secret is shown once at creation and never again. Approval gates the account,\nnot the key.\n\n```http\nAuthorization: Bearer tf_live_…\n```\n\nA missing or unknown key returns `401` with `{\"error\":\"invalid api key\"}`.\n\n## Quickstart\n\nSubmitting is asynchronous: you get a `request_id` straight away and poll it. A batch of one\nis fine.\n\n**1. Submit**\n\n```bash\ncurl -sS -X POST https://api.truthfactor.ai/api/v1/signals \\\n  -H \"Authorization: Bearer $TF_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"inputs\":[{\"phone\":\"+447911123456\",\"correlation_id\":\"lead-4417\"}],\"signals\":[\"phone_connectivity\"]}'\n```\n\nReturns `202`:\n\n```json\n{\n  \"request_id\": \"rq_8efcd577456f4bd1\",\n  \"requested_datetime\": \"2026-09-26T14:03:11Z\",\n  \"status\": \"pending\",\n  \"accepted\": 1\n}\n```\n\n**2. Poll**\n\n```bash\ncurl -sS https://api.truthfactor.ai/api/v1/signals/rq_8efcd577456f4bd1 \\\n  -H \"Authorization: Bearer $TF_API_KEY\"\n```\n\n`status` stays `pending` until every item is terminal, then `complete`. While it is still\nrunning the batch has no completion time yet, and `completed_datetime` carries `null` — the\nkey is always present:\n\n```json\n{\n  \"request_id\": \"rq_8efcd577456f4bd1\",\n  \"requested_datetime\": \"2026-09-26T14:03:11Z\",\n  \"completed_datetime\": null,\n  \"status\": \"pending\",\n  \"data\": []\n}\n```\n\nOnce it is done:\n\n```json\n{\n  \"request_id\": \"rq_8efcd577456f4bd1\",\n  \"requested_datetime\": \"2026-09-26T14:03:11Z\",\n  \"completed_datetime\": \"2026-09-26T14:03:41Z\",\n  \"status\": \"complete\",\n  \"data\": [\n    {\n      \"correlation_id\": \"lead-4417\",\n      \"input\": { \"phone\": \"+447911123456\", \"correlation_id\": \"lead-4417\" },\n      \"status\": \"scored\",\n      \"score\": 92,\n      \"band\": \"clear\",\n      \"reason\": \"phone_connectivity\",\n      \"signals\": { \"phone_connectivity\": { \"trusted\": true, \"confidence\": 92 } }\n    }\n  ]\n}\n```\n\n`correlation_id` is yours. Pass whatever identifies the row on your side and it comes back\nuntouched, so you never have to match on array position.\n\n## Reading a verdict\n\nThis is the part worth getting right.\n\n| Value | Means |\n| --- | --- |\n| `trusted: true` | Confirmed. The number connects, or the name or profile matches. |\n| `trusted: false` | Confirmed otherwise. A positive finding, not an absence of one. |\n| `trusted: null` | Unconfirmable — no source could answer. Never read this as `false`. |\n| `confidence` | How strongly the sources agreed, 0–100. Always present: it carries `null` exactly when `trusted` is `null`. |\n\n`false` means we checked and the answer was no. `null` means nobody could tell you. Treating\nthe second as the first is how a good lead gets discarded.\n\n## Signals\n\nName the signals you want, or omit the field and every signal whose required inputs you\nsupplied will run, subject to your account's plan.\n\n### Available signals\n\n| Signal | Needs | Tells you |\n| --- | --- | --- |\n| `phone_name_match` | phone, first_name, last_name | Tells you if the owner of the phone belongs to the given name. |\n| `phone_connectivity` | phone | Whether the phone number reaches a live subscriber. Resolves outside the US and Canada. In the US and Canada it returns `trusted: null` by design — `premium_phone_connectivity` is the signal that answers there. A `trusted: null` is never a `false`. |\n| `phone_profile_match` | phone, profile_url | Tells you if a phone belongs to the professional network profile. |\n| `premium_phone_name_match` | phone, first_name, last_name | `phone_name_match` on the premium tier: the same question, resolved against more than the standard signal reaches, so fewer rows come back unresolved. Needs a premium grant on your account. |\n| `premium_phone_connectivity` | phone | `phone_connectivity` on the premium tier: the same question, with coverage in the US and Canada where the standard signal returns no result. Needs a premium grant on your account. |\n| `premium_phone_profile_match` | phone, profile_url | `phone_profile_match` on the premium tier: the same question, with a live confirmation when the primary record is inconclusive, so fewer rows come back unresolved. Needs a premium grant on your account. |\n\n### Coming soon\n\nNot callable yet. They are listed so you can see where the API is going; a request naming\none is rejected rather than silently ignored.\n\n| Signal | Needs | Tells you |\n| --- | --- | --- |\n| `email_name_match` | email, first_name, last_name | Tells you if the owner of the email belongs to the given name. |\n| `email_profile_match` | email, profile_url | Tells you if an email belongs to the professional network profile. |\n| `phone_type` | phone | Whether the number is a mobile, a landline or VoIP. |\n| `phone_ported` | phone | Tells you the last time the phone was ported (a recent port is a fraud co-signal). |\n| `phone_activity` | phone | How established the number is over time. A long, consistent history corroborates a real person; a number that appears from nowhere does not. |\n| `profile_account_age_risk` | profile_url | Risk that the professional profile was recently created (a very new account is a strong fraud signal). |\n\n## When an item is rejected\n\nA rejected item is a normal outcome for that row, not a failed request. The batch still\nreturns `200` and the other items still carry verdicts. The reason appears on the item:\n\n- `no_runnable_signal`\n- `invalid_phone`\n\n## When an item is unresolved\n\nAn item comes back `unresolved` when it ran but every signal you asked for returned\n`trusted: null` — nobody could answer, so there is nothing to score. It carries no `score`,\n`band` or item-level `reason`. Unlike a rejected item, it RETURNS its full `signals` map, and\neach null verdict carries an `unresolved_reason` telling you why:\n\n| `unresolved_reason` | Means |\n| --- | --- |\n| `inconclusive` | A source ran or answered but could not conclude on this input. |\n| `source_unavailable` | A source could not be reached — a transient outage. Worth a retry. |\n| `no_coverage` | No source covers this input, so none was consulted. |\n\n## Results by webhook\n\nInstead of polling, pass `webhook_url` on submit and we POST the completed payload to it,\nsigned. Results stay fetchable with `GET`, so a failed delivery never loses them.\n\n## Webhook signature\n\nEvery delivery carries `X-TruthFactor-Signature`: HMAC-SHA256 over the exact\nbytes of the request body, lowercase hex, prefixed `sha256=`.\nVerify before you parse — re-serialising the JSON changes the bytes, and the signature will no\nlonger match. Compare the two values in constant time.\n\nRun this and you get the vector below, so it doubles as a test of your implementation:\n\n```bash\nSIGNING_SECRET='whsec_example_do_not_use_in_production'\nRAW_BODY='{\"request_id\":\"rq_b42e\",\"requested_datetime\":\"2026-09-26T18:20:05Z\",\"completed_datetime\":\"2026-09-26T18:20:38Z\",\"status\":\"complete\"}'\n\necho \"sha256=$(printf '%s' \"$RAW_BODY\" \\\n  | openssl dgst -sha256 -hmac \"$SIGNING_SECRET\" -r | cut -d' ' -f1)\"\n# sha256=12362f6de2f6f2b7577e1a509d98e4cb9fd70762c4f8c769bcb07d0efd7bfbad\n```"},"servers":[{"url":"/api","description":"Same-origin API namespace (routes live under /api/v1)."}],"security":[{"bearerAuth":[]}],"paths":{"/v1/signals":{"post":{"summary":"Submit a batch for background scoring","description":"Validates, persists and enqueues per-input work, returning 202 with a request_id. Results arrive by webhook (if webhook_url is given) and/or by polling.","operationId":"submitSignals","security":[{"bearerAuth":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string"},"description":"Optional. A repeated Idempotency-Key (or an identical batch within a window) returns the same request_id without re-charging."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignalsRequest"},"examples":{"poll_clear":{"summary":"Poll — a consistent identity","value":{"inputs":[{"phone":"+447911123456","first_name":"Dana","last_name":"Okafor","profile_url":"linkedin.com/in/dana-okafor","email":"dana.okafor@example.com","correlation_id":"signup-3391"}],"signals":["phone_name_match","phone_connectivity","phone_profile_match","email_name_match"]}},"webhook_name_mismatch":{"summary":"Webhook — a name mismatch","value":{"inputs":[{"phone":"+447911123457","first_name":"Jordan","last_name":"Reyes","profile_url":"linkedin.com/in/jordan-reyes","correlation_id":"order-5567"}],"signals":["phone_name_match","phone_connectivity","phone_profile_match"],"webhook_url":"https://acme.example.com/hooks/truthfactor"}}}}}},"responses":{"202":{"description":"Accepted — background scoring enqueued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptedResponse"}}}},"401":{"description":"invalid api key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"out_of_credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"invalid_request — malformed body, unknown/ungoverned signal, empty inputs, or over the size limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"rate_limited — the account's request rate, or its ceiling of items queued in non-terminal batches. Carries Retry-After.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"Retry-After":{"description":"Whole seconds to wait before retrying. Always present on a 429 and always at least 1. The request-rate refusal computes it from the account's bucket; the in-flight ceiling's is deliberately coarse, since when queued work drains depends on the sources.","schema":{"type":"integer"}}}}}}},"/v1/signals/{request_id}":{"get":{"summary":"Poll batch status and results","description":"Returns the same payload the webhook delivers, with whatever items have scored. A caller may rely on polling alone.","operationId":"getSignals","security":[{"bearerAuth":[]}],"parameters":[{"name":"request_id","in":"path","required":true,"schema":{"type":"string"},"description":"The request_id returned by POST /v1/signals."}],"responses":{"200":{"description":"The batch status and results.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignalsResponse"},"examples":{"poll_clear":{"summary":"Poll — a consistent identity","value":{"request_id":"rq_7a1d","requested_datetime":"2026-09-26T14:03:11Z","completed_datetime":"2026-09-26T14:03:41Z","status":"complete","data":[{"correlation_id":"signup-3391","status":"scored","score":94,"band":"clear","reason":"phone_profile_match","input":{"phone":"+447911123456","first_name":"Dana","last_name":"Okafor","profile_url":"linkedin.com/in/dana-okafor","email":"dana.okafor@example.com","correlation_id":"signup-3391"},"signals":{"phone_name_match":{"trusted":true,"confidence":90},"phone_connectivity":{"trusted":true,"confidence":98},"phone_profile_match":{"trusted":true,"confidence":76},"email_name_match":{"trusted":null,"confidence":null}}}]}},"webhook_name_mismatch":{"summary":"Webhook — a name mismatch","value":{"request_id":"rq_b42e","requested_datetime":"2026-09-26T18:20:05Z","completed_datetime":"2026-09-26T18:20:38Z","status":"complete","data":[{"correlation_id":"order-5567","status":"scored","score":36,"band":"high_risk","reason":"name_mismatch","input":{"phone":"+447911123457","first_name":"Jordan","last_name":"Reyes","profile_url":"linkedin.com/in/jordan-reyes","correlation_id":"order-5567"},"signals":{"phone_name_match":{"trusted":false,"confidence":100},"phone_connectivity":{"trusted":true,"confidence":94},"phone_profile_match":{"trusted":false,"confidence":78}}}]}}}}}},"401":{"description":"invalid api key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"no batch with that request_id for this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"webhooks":{"signalsComplete":{"post":{"summary":"Signed batch-complete delivery","description":"When webhook_url is set, the completed SignalsResponse is POSTed to it, signed with X-TruthFactor-Signature (HMAC), retried with backoff and dead-lettered on exhaustion. Results always remain fetchable via GET, so a webhook failure never loses them.","operationId":"signalsWebhook","security":[],"parameters":[{"name":"X-TruthFactor-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"HMAC-SHA256 of the raw request body, formatted `sha256=<hex>`. See the \"Webhook signature\" section of this guide for the verification recipe and a test vector."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignalsResponse"},"examples":{"webhook_name_mismatch":{"summary":"Webhook — a name mismatch","value":{"request_id":"rq_b42e","requested_datetime":"2026-09-26T18:20:05Z","completed_datetime":"2026-09-26T18:20:38Z","status":"complete","data":[{"correlation_id":"order-5567","status":"scored","score":36,"band":"high_risk","reason":"name_mismatch","input":{"phone":"+447911123457","first_name":"Jordan","last_name":"Reyes","profile_url":"linkedin.com/in/jordan-reyes","correlation_id":"order-5567"},"signals":{"phone_name_match":{"trusted":false,"confidence":100},"phone_connectivity":{"trusted":true,"confidence":94},"phone_profile_match":{"trusted":false,"confidence":78}}}]}}}}}},"responses":{"2XX":{"description":"The receiver acknowledged delivery."}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"An account-scoped API key, `tf_live_…`."}},"schemas":{"Signal":{"type":"string","enum":["phone_name_match","phone_connectivity","phone_profile_match","email_name_match","email_profile_match","phone_type","phone_ported","phone_activity","profile_account_age_risk","premium_phone_name_match","premium_phone_connectivity","premium_phone_profile_match"],"description":"The name of a signal."},"SignalInput":{"type":"object","properties":{"phone":{"type":"string"},"first_name":{"type":"string"},"last_name":{"type":"string"},"email":{"type":"string"},"profile_url":{"type":"string"},"correlation_id":{"type":"string","description":"The caller's join key, echoed back verbatim on the matching result item."}},"additionalProperties":false,"description":"One submitted person. Every field is optional — a single field is valid."},"SignalsRequest":{"type":"object","properties":{"inputs":{"type":"array","items":{"$ref":"#/components/schemas/SignalInput"},"minItems":1},"signals":{"type":"array","items":{"$ref":"#/components/schemas/Signal"},"minItems":1,"description":"Which signals to run for every input. Omitted → all signals whose required inputs are present, subject to phase and per-account governance."},"webhook_url":{"type":"string","format":"uri","description":"Where the completed payload is POSTed, signed. Optional."},"skip_cache":{"type":"boolean","description":"Bypass the result cache and resolve every signal fresh — a live probe/dial is placed rather than a cached verdict returned — and never idempotency-replay this submission (a new request_id is always minted). Default false. Optional."}},"required":["inputs"],"additionalProperties":false,"description":"The POST /v1/signals body."},"AcceptedResponse":{"type":"object","properties":{"request_id":{"type":"string","minLength":1},"requested_datetime":{"type":"string","format":"date-time"},"status":{"type":"string","const":"pending"},"accepted":{"type":"integer","minimum":0}},"required":["request_id","requested_datetime","status","accepted"],"additionalProperties":false,"description":"The 202 acknowledgement of POST /v1/signals."},"SignalVerdict":{"oneOf":[{"type":"object","properties":{"trusted":{"type":"boolean"},"confidence":{"type":"integer","minimum":0,"maximum":100}},"required":["trusted","confidence"],"additionalProperties":false,"description":"A resolved verdict."},{"type":"object","properties":{"trusted":{"type":"null"},"confidence":{"type":"null"},"unresolved_reason":{"type":"string","enum":["inconclusive","source_unavailable","no_coverage"]}},"required":["trusted","confidence"],"additionalProperties":false,"description":"Unresolved — no source produced a verdict. On an unresolved item it carries an unresolved_reason (inconclusive, source_unavailable or no_coverage) explaining the null (TFA-130)."}],"description":"One signal's verdict. Either a resolved verdict (a boolean trusted with an integer confidence) or the unresolved {trusted: null, confidence: null}. confidence is null exactly when trusted is null — a mixed {trusted: null, confidence: 100} or {trusted: true, confidence: null} is not a valid verdict, which is why this is a oneOf rather than two independently-nullable fields."},"SignalVerdictMap":{"type":"object","properties":{"phone_name_match":{"$ref":"#/components/schemas/SignalVerdict"},"phone_connectivity":{"$ref":"#/components/schemas/SignalVerdict"},"phone_profile_match":{"$ref":"#/components/schemas/SignalVerdict"},"email_name_match":{"$ref":"#/components/schemas/SignalVerdict"},"email_profile_match":{"$ref":"#/components/schemas/SignalVerdict"},"phone_type":{"$ref":"#/components/schemas/SignalVerdict"},"phone_ported":{"$ref":"#/components/schemas/SignalVerdict"},"phone_activity":{"$ref":"#/components/schemas/SignalVerdict"},"profile_account_age_risk":{"$ref":"#/components/schemas/SignalVerdict"},"premium_phone_connectivity":{"$ref":"#/components/schemas/SignalVerdict"},"premium_phone_name_match":{"$ref":"#/components/schemas/SignalVerdict"},"premium_phone_profile_match":{"$ref":"#/components/schemas/SignalVerdict"}},"additionalProperties":false,"description":"Only the selected signals (or all applicable when omitted). A signal that could not run is {trusted: null, confidence: null}, never {trusted: false}."},"ResultItem":{"oneOf":[{"type":"object","properties":{"correlation_id":{"type":"string"},"input":{"$ref":"#/components/schemas/SignalInput"},"status":{"type":"string","const":"scored"},"score":{"type":"integer","minimum":0,"maximum":100},"band":{"type":"string","enum":["clear","caution","high_risk"]},"reason":{"type":"string","minLength":1,"description":"The dominant factor: an override code, else the lowest-scoring signal."},"signals":{"$ref":"#/components/schemas/SignalVerdictMap"}},"required":["input","status","score","band","reason","signals"],"additionalProperties":false,"description":"A scored item."},{"type":"object","properties":{"correlation_id":{"type":"string"},"input":{"$ref":"#/components/schemas/SignalInput"},"status":{"type":"string","const":"rejected"},"reason":{"type":"string","minLength":1,"description":"Why the item was rejected: no_runnable_signal when the input supports none of the selected signals, invalid_phone when the phone will not parse, and so on. Not a score factor — nothing was scored."},"signals":{"$ref":"#/components/schemas/SignalVerdictMap"}},"required":["input","status","reason"],"additionalProperties":false,"description":"A rejected item — no_runnable_signal, invalid_phone, …."},{"type":"object","properties":{"correlation_id":{"type":"string"},"input":{"$ref":"#/components/schemas/SignalInput"},"status":{"type":"string","const":"error"},"reason":{"type":"string","minLength":1,"description":"Why the item failed, when that is known. Optional — an errored item is promised no field beyond its status."},"signals":{"$ref":"#/components/schemas/SignalVerdictMap"}},"required":["input","status"],"additionalProperties":false,"description":"An errored item."},{"type":"object","properties":{"correlation_id":{"type":"string"},"input":{"$ref":"#/components/schemas/SignalInput"},"status":{"type":"string","const":"unresolved"},"signals":{"$ref":"#/components/schemas/SignalVerdictMap"}},"required":["input","status","signals"],"additionalProperties":false,"description":"An unresolved item (TFA-130): it ran but every signal returned trusted: null, so there is no score. It returns its full signals map, each verdict null with an unresolved_reason, and no score/band/reason."}],"description":"One result item, discriminated on status: scored carries score/band/reason/signals; rejected carries reason; unresolved carries its signals map (every verdict null, each with an unresolved_reason) and no score/band/reason; error may carry neither."},"SignalsResponse":{"type":"object","properties":{"request_id":{"type":"string","minLength":1},"requested_datetime":{"type":"string","format":"date-time"},"completed_datetime":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"status":{"type":"string","enum":["pending","complete","partial","failed"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/ResultItem"}}},"required":["request_id","requested_datetime","completed_datetime","status","data"],"additionalProperties":false,"description":"The GET /v1/signals/{request_id} and webhook body."},"Error":{"type":"object","required":["error"],"description":"An error body. error is a machine-readable code.","properties":{"error":{"type":"string"}}}}},"x-signals":{"phone_name_match":{"phase":1,"requiredInputs":["phone","first_name","last_name"]},"phone_connectivity":{"phase":1,"requiredInputs":["phone"]},"phone_profile_match":{"phase":1,"requiredInputs":["phone","profile_url"]},"email_name_match":{"phase":2,"requiredInputs":["email","first_name","last_name"]},"email_profile_match":{"phase":2,"requiredInputs":["email","profile_url"]},"phone_type":{"phase":3,"requiredInputs":["phone"]},"phone_ported":{"phase":3,"requiredInputs":["phone"]},"phone_activity":{"phase":3,"requiredInputs":["phone"]},"profile_account_age_risk":{"phase":3,"requiredInputs":["profile_url"]},"premium_phone_name_match":{"phase":1,"requiredInputs":["phone","first_name","last_name"]},"premium_phone_connectivity":{"phase":1,"requiredInputs":["phone"]},"premium_phone_profile_match":{"phase":1,"requiredInputs":["phone","profile_url"]}},"x-limits":{"max_inputs_per_batch":"to be finalized","rate_limit_per_account":"to be finalized","note":"Per-account rate limits and the maximum batch size are to be finalized. A 429 rate_limited response is returned when a limit is exceeded, always carrying a Retry-After header of whole seconds. A batch too large for the account is 422 invalid_request on `inputs`, not a 429 — there is no wait that would make it fit."}}