Migrating from the Legacy API
A task-oriented guide for integrators moving an existing api.thirdfort.io (v2) integration to the
Thirdfort Client API (api.thirdfort.com). It covers the things almost every legacy integration does,
what each becomes, and what has no equivalent yet.
Both APIs can run side by side, and the legacy API has no planned deprecation date. Existing transactions stay on the legacy API; create new work on the new API. Contact api@thirdfort.com for credentials and migration help.
What changes
| Legacy API (v2) | Client API | |
|---|---|---|
| Base URL | https://api-v2.thirdfort.io/v2 (sandbox https://sandbox.api.thirdfort.io/v2) | https://api.thirdfort.com/client/api/v1 (nonprod https://api.thirdfort.dev/client/api/v1) |
| Authentication | Self-signed RSA JWT for an m2m user (public key uploaded via /users/{id}/jwks) | OAuth 2.0 client credentials, 1-hour bearer token |
| Scoping | Tenant and team inferred from the token | Organization and team are in every URL: organizations/{org}/teams/{team}/... |
| Unit of work | A transaction with a list of tasks | A check created from a template with typed params |
| Results | GET .../_summary and per-report GET .../reports/{id} | GET .../checkSummary and GET .../findings |
| Lifecycle | PATCH / DELETE on the transaction | :setOngoingMonitoring and :cancel custom methods |
| Notifications | Per-transaction metadata.notify (http, inbox, email) | Organization-level notifications: poll GET /organizations/{org}/notifications or a Thirdfort-configured webhook |
| Naming | snake_case, string dates | camelCase, {year, month, day} for dates of birth, RFC 3339 timestamps |
Your organization and team IDs are issued with your OAuth credentials. Use - as a wildcard for
reads across teams (organizations/{org}/teams/-/checks); creating a check always needs a real team.
How tenants map to organizations and teams
The legacy API scoped everything to a tenant and team read from your token, and partner integrations
created tenants and stub users and impersonated them with the Tenant-Id and user-id headers.
The Client API replaces that with an explicit resource hierarchy that appears in every URL.
| Legacy | Client API | What changes |
|---|---|---|
| Tenant | Organization (organizations/{org}) | The business using Thirdfort. Entitlements, notifications and webhooks are configured here. |
| Tenant name (shown to consumers in the invitation SMS) | Brand (organizations/{org}/brands/{brand}) | The consumer-facing name is a separate resource. Every team belongs to exactly one brand, so one organization can present different names to consumers. |
| Team | Team (organizations/{org}/teams/{team}) | Checks are always created in a team. Access to checks follows the team. Each team is linked to exactly one brand. |
m2m user | Integration (your OAuth client) | Not a user. An integration acts as the organization or partner it belongs to. |
Stub user + user-id impersonation | On-behalf-of | Certain actions e.g. creating a check allow you to submit a user name - the user the integration is acting on behalf of. |
Tenant-Id header | Resource name in the URL | Nothing is inferred from the token. |
POST /v2/users (stub user) | POST /v1/organizations/{org}/users | Creates a Thirdfort user. |
Legacy tenants and teams are not automatically migrated. Organizations and teams are provisioned fresh on the new platform, and legacy IDs are not recognised by the Client API.
Two integration shapes
You integrate on behalf of your own business (direct integration). Thirdfort provisions an organization for you, with at least one brand and at least one team, and creates an integration owned by that organization. Its credentials can do anything within your organization, including managing its teams, brands and users through the API.
You integrate on behalf of your customers (platform partner or reseller). Thirdfort creates a partner resource to represent you and issues an integration owned by that partner. Each of your customers is its own organization, with its own brands, teams, and users. This replaces the legacy tenant-per-customer model.
To ensure valid contracts are in place and to prevent the proliferation of duplicates, organization creation is performed by Thirdfort. After creating it, Thirdfort will link the organization to your partner resource or create a team within the client organization for your integration to interact with, depending on your agreements with Thirdfort.
Your partner integration automatically has access to every organization/team it is linked to, there are no per-customer credentials to manage.
Who does what
| Task | Legacy | Client API |
|---|---|---|
| Create your business entity | Thirdfort creates the tenant | Thirdfort creates the organization or partner |
| Issue API credentials | You upload a public key for the m2m user | Thirdfort issues an OAuth client ID and secret |
| Create a customer (partners only) | POST /v2/tenants | Request that Thirdfort create a new organization |
| Create teams and brands | POST /v2/teams | CreateTeam and CreateBrand APIs |
| Represent the requesting user | POST /v2/users stub user, then user-id header | Use on_behalf_of |
| Choose the name consumers see | Tenant name | Brand display name |
1. Authenticate
Replace JWT generation and signing with a single token request:
curl -X POST https://discerning-holiday-58.authkit.app/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600
}
Nonproduction uses https://creative-jungle-73-staging.authkit.app/oauth2/token. Send the token on
every request as Authorization: Bearer {access_token}, cache it, and refresh it before expires_in
elapses. See Authentication.
Delete the key-upload and iss == sub token code; none of it is needed.
2. Create a check instead of a transaction
A legacy transaction bundled tasks for one consumer. On the new API, pick the template that matches the consumer's journey and turn the remaining legacy tasks into params.
Choose the template
| Legacy shape | Template |
|---|---|
actor present, report:identity in tasks | checkTemplates/identity-verification-check-v1-0-0 |
No actor, report:screening:lite with expectations | checkTemplates/clientonly-verification-check-v1-0-0 |
actor present, report:sof-v1 without report:identity | checkTemplates/source-of-funds-check-v1-0-0 |
Map the tasks to params (identity verification)
| Legacy task | Identity verification param |
|---|---|
report:identity | Implied by the template. Set passportNfcPreference to OPTIONAL (NFC first, fallback to document capture) or SKIP (document capture only). Required. |
report:footprint | Always runs; no flag. opts.consent → internationalAddressVerificationConsent |
report:peps | Always runs. opts.monitored: true → enableOngoingMonitoring: true |
report:sof-v1 | requireSourceOfFundsQuestionnaire: true |
report:bank-statement, report:bank-summary | requireBankingData: true |
documents:poa | requireProofOfAddressDocument: true |
documents:poo | requireProofOfOwnershipDocument: true |
actor.type: "giftor" | persona: "giftor" (also seller, purchaser, propertyOther, landlord, tenantOccupantGuarantor) |
Each param is gated by an entitlement on your organization. A request that sets a param you are not entitled to is rejected and the error names the missing feature.
requireSourceOfFundsQuestionnaireandrequireBankingDataare mutually exclusive on the identity verification template. A legacy transaction that requested bothreport:sof-v1and the bank tasks maps to a source-of-funds journey, which collects bank statements as part of its questionnaire. Ask api@thirdfort.com if you relied on both in one transaction.
Map the fields
| Legacy | Client API |
|---|---|
name | displayName (required) |
ref (optional) | clientReference (required, immutable; use it as your correlation key) |
request.actor.name ("Jane Smith") | params.subject.givenName + params.subject.familyName (split it) |
request.actor.phone | params.subject.phoneNumber (E.164, e.g. +447700900123) |
| — | params.subject.email (optional, in addition to phone) |
expectations["name:lite"].data.{first,last,other} | params.subject.{givenName,familyName,otherNames} |
expectations.dob.data ("1990-06-15T00:00:00Z") | params.subject.dateOfBirth: {year: 1990, month: 6, day: 15} |
expectations.yob.data ("1990") | params.subject.yearOfBirth: 1990 |
expectations.address.data.{building_number, street, town, postcode, country} | params.address.{buildingNumber, street, locality, postalCode, countryCode} |
expectations.address.data.flat_number / building_name | params.address.subBuilding / buildingName |
Country codes stay ISO 3166-1 alpha-3 (GBR) for individuals. Client-only verification requires
params.address; the legacy year-plus-country-only screening has no direct equivalent.
Before and after: identity verification
Legacy:
POST /v2/transactions
{
"type": "v2",
"name": "Purchase of 14A Mansion House",
"ref": "MATTER-0123",
"request": {
"actor": { "name": "Jane Smith", "phone": "+447700112233" },
"tasks": ["report:identity", "report:footprint",
{ "type": "report:peps", "opts": { "monitored": true } },
"documents:poa"]
}
}
Client API:
POST /v1/organizations/{org}/teams/{team}/checks
{
"displayName": "Purchase of 14A Mansion House",
"clientReference": "MATTER-0123",
"template": "checkTemplates/identity-verification-check-v1-0-0",
"params": {
"@type": "type.googleapis.com/thirdfort.client.checks.type.v1.IdentityVerificationCheckParams",
"subject": { "givenName": "Jane", "familyName": "Smith", "phoneNumber": "+447700112233" },
"passportNfcPreference": "OPTIONAL",
"enableOngoingMonitoring": true,
"requireProofOfAddressDocument": true
}
}
Store the returned name (organizations/.../checks/{id}). It is the handle for every later call and
the key that notifications carry.
Before and after: lite screening
Legacy:
POST /v2/transactions
{
"type": "v2",
"name": "Lite screening for John Doe",
"ref": "SCREEN-0123",
"request": {
"tasks": [{ "type": "report:screening:lite", "opts": { "monitored": true } }],
"expectations": {
"name:lite": { "data": { "first": "John", "last": "Doe" } },
"dob": { "data": "1985-06-15T00:00:00Z" },
"address": { "data": { "building_number": "123", "street": "High Street",
"town": "London", "postcode": "SW1A 1AA", "country": "GBR" } }
}
}
}
Client API:
POST /v1/organizations/{org}/teams/{team}/checks
{
"displayName": "Lite screening for John Doe",
"clientReference": "SCREEN-0123",
"template": "checkTemplates/clientonly-verification-check-v1-0-0",
"params": {
"@type": "type.googleapis.com/thirdfort.client.checks.type.v1.ClientOnlyVerificationCheckParams",
"subject": {
"givenName": "John",
"familyName": "Doe",
"dateOfBirth": { "year": 1985, "month": 6, "day": 15 }
},
"address": {
"buildingNumber": "123",
"street": "High Street",
"locality": "London",
"postalCode": "SW1A 1AA",
"countryCode": "GBR"
},
"enableOngoingMonitoring": true
}
}
There is no actor to omit and no expectations object: the subject and address are params.
3. Read results
| Legacy | Client API |
|---|---|
GET /v2/transactions/{id} → status | GET /v1/{check} → state |
GET /v2/transactions/{id}/_summary → reports.{identity,footprint,peps}.result | GET /v1/{check}/checkSummary → payload.overallRecommendation plus per-area results (identityDocumentCheckResult, facialRecognitionCheckResult, addressVerificationCheckResult, screeningCheckResult, …) |
GET /v2/transactions/{id}/reports/{report} with Accept: application/json (screening hits, breakdown) | GET /v1/{check}/findings: one finding per issue, with state (UNREVIEWED, CONFIRMED, DISMISSED, RETRACTED_BY_PROVIDER) |
GET /v2/transactions/{id}/reports/{report} with Accept: application/pdf | checkSummary.reportUrl (full) and checkSummary.summaryReportUrl (summary) |
GET ... reports/{report} with Accept: application/zip (uploaded documents) | Not available |
A check summary looks like this; the payload shape depends on the template:
{
"name": "organizations/{org}/teams/{team}/checks/{check}/checkSummary",
"createTime": "2026-03-06T14:07:00.537001Z",
"payload": {
"@type": "type.googleapis.com/thirdfort.client.checks.type.v1.IndividualVerificationCheckSummary",
"overallRecommendation": {
"recommendation": "CONSIDER",
"description": "One or more items require consideration"
},
"identityDocumentCheckResult": { "...": "..." },
"facialRecognitionCheckResult": { "...": "..." },
"screeningCheckResult": { "...": "..." }
},
"reportUrl": "https://files.thirdfort.dev/client/downloads/objects/...",
"summaryReportUrl": "https://files.thirdfort.dev/client/downloads/objects/..."
}
Download the PDFs by fetching those URLs as-is with the same bearer token:
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" -o report.pdf "$REPORT_URL"
Value mapping
Legacy status | state | Legacy result | recommendation | |
|---|---|---|---|---|
open | ACTIVE | clear | CLEAR | |
closed | INACTIVE | consider | CONSIDER | |
aborted | CANCELLED | fail | FAIL | |
deleted | DELETED | — | NOT_APPLICABLE, NOT_SUCCESSFUL, UNSUPPORTED_BY_PROVIDER |
A check may move ACTIVE → INACTIVE → ACTIVE again (a monitoring hit or a finding review reactivates
it), so treat INACTIVE as "nothing pending", not "final". The check_complete notification is the
signal that results are ready.
Timestamps (createTime, updateTime) are RFC 3339 strings, as before under new names. Only dates of
birth change shape, to {year, month, day}.
4. Manage the check
| Legacy | Client API |
|---|---|
PATCH /v2/transactions/{id} with changes.tasks[{type: "report:peps", opts: {monitored}}] | POST /v1/{check}:setOngoingMonitoring { "enabled": true | false } |
POST /v2/users/{id}/activities:disableMonitoring | Call :setOngoingMonitoring per check |
DELETE /v2/transactions/{id} (abort) | POST /v1/{check}:cancel (irreversible; also stops monitoring) |
PATCH /v2/transactions/{id} to rename | Not supported; displayName and clientReference are fixed at creation |
5. Notifications
Legacy notifications were configured per transaction in metadata.notify. New-API notifications are
per organization and carry the check name, so route on the payload rather than on the URL you
registered.
| Legacy | Client API |
|---|---|
notify.type: "inbox" + POST /v2/inboxes, GET /v2/inboxes/{id}/messages, PATCH ... {acknowledged: true} | GET /v1/organizations/{org}/notifications, paged with page_token / nextPageToken; no inbox, no acknowledgement |
notify.type: "http" with your uri, method, hmac_key; X-Signature (hex) | Webhook configured by Thirdfort for the organization; x-thirdfort-signature (HMAC-SHA256, base64) with a Thirdfort-issued secret |
notify.type: "email" (default) | Not an API concern; portal users are emailed as before |
Notification types: notification.client.check_complete.v1, report_generated.v1,
ongoing_monitoring_hit.v1, ongoing_monitoring_started.v1, ongoing_monitoring_renewed.v1.
Moving off inboxes
Delete the inbox creation and acknowledgement code. Poll the organization's notifications, keep the
last nextPageToken as your cursor, and deduplicate on each notification's name in place of
acknowledging it. Polling suits you if you cannot expose a public HTTPS endpoint and a delay of up to a
minute is acceptable.
Moving off per-transaction webhooks
Ask api@thirdfort.com to configure a webhook for your organization, then:
- Stop sending
metadata.notify. - Verify
x-thirdfort-signature(base64) with the Thirdfort-issued secret instead ofX-Signature(hex) with your own key. - Deduplicate on the notification
name.
If you relied on a unique webhook URL per transaction to route each notification (for example, into a
specific matter in a case management system), that routing key no longer exists: every notification
for the organization arrives at the same endpoint. Every payload includes check (the resource name),
so look up your record by payload.check and dispatch as the old per-URL logic did. clientReference
is not in the payload; keep your own name → clientReference mapping from step 2, or call
GET /v1/{check}.
Full details, including signature-verification code: Polling for Notifications.
Not yet available on the Client API
Plan around these before switching a workflow over.
| Legacy feature | Status |
|---|---|
identity:lite web-SDK flow (POST .../_tokens for an Onfido token) | No equivalent; identity verification runs in the Thirdfort app |
report:screening:lite with only yob + country (no address) | Client-only verification requires a residential address |
report:personal-info | No separate report; subject data appears on the check summary |
documents:identity, documents:other, documents:divorce/inheritance/mortgage/sale-assets/savings as standalone uploads | Collected only inside the source-of-funds journey |
PUT .../expectations/{id}/_data, POST .../expectations/{id}/parts (supplying consumer data on their behalf) | Not exposed; the checkTemplates/identity-document-verification-check-v1-0-0 template covers client-supplied ID images |
Accept: application/zip download of consumer-uploaded files | Not exposed |
Company checks (POST /v2/checks with type: "company", GET /v2/companies) | Not yet available in v1; contact api@thirdfort.com before moving a KYB workflow |
Reviewing screening hits (POST /v2/transactions/{id}/reports/{report}/hits) | Not yet available in v1; review findings in the Thirdfort portal |
| Per-transaction webhook URLs and integrator-chosen HMAC keys | Organization-level only |
Troubleshooting
| Symptom | Likely cause |
|---|---|
400 naming a field | Missing displayName, clientReference, params.@type, or passportNfcPreference; or both requireSourceOfFundsQuestionnaire and requireBankingData set |
| Error naming a feature | Your organization is not entitled to a param you set |
401 | Token missing or expired; refresh before the hour is up |
403 reading a check | The check does not exist or is under a different organization or team; a missing check returns 403, not 404 |
404 | Path is missing the organizations/{org}/teams/{team} parent, or uses a legacy ID instead of the full resource name |
| PDF download fails | No Authorization header, or the URL was rebuilt rather than used verbatim from reportUrl |
Cut-over checklist
- Obtain OAuth credentials plus organization and team IDs for nonproduction.
- Replace JWT signing with the client-credentials token call and a refresh timer.
- For each transaction shape you create, pick a template and build the
paramsfrom the tables above. - Store the returned check
nameagainst your record, keyed byclientReference. - Switch result reads to
checkSummaryandfindings; mapstateandrecommendationvalues. - Replace inbox polling or per-transaction webhooks with organization notifications; deduplicate by notification
name. - Run both integrations in parallel, then stop creating legacy transactions. Existing ones finish on the legacy API.