Skip to main content

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 URLhttps://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)
AuthenticationSelf-signed RSA JWT for an m2m user (public key uploaded via /users/{id}/jwks)OAuth 2.0 client credentials, 1-hour bearer token
ScopingTenant and team inferred from the tokenOrganization and team are in every URL: organizations/{org}/teams/{team}/...
Unit of workA transaction with a list of tasksA check created from a template with typed params
ResultsGET .../_summary and per-report GET .../reports/{id}GET .../checkSummary and GET .../findings
LifecyclePATCH / DELETE on the transaction:setOngoingMonitoring and :cancel custom methods
NotificationsPer-transaction metadata.notify (http, inbox, email)Organization-level notifications: poll GET /organizations/{org}/notifications or a Thirdfort-configured webhook
Namingsnake_case, string datescamelCase, {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.

LegacyClient APIWhat changes
TenantOrganization (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.
TeamTeam (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 userIntegration (your OAuth client)Not a user. An integration acts as the organization or partner it belongs to.
Stub user + user-id impersonationOn-behalf-ofCertain actions e.g. creating a check allow you to submit a user name - the user the integration is acting on behalf of.
Tenant-Id headerResource name in the URLNothing is inferred from the token.
POST /v2/users (stub user)POST /v1/organizations/{org}/usersCreates 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​

TaskLegacyClient API
Create your business entityThirdfort creates the tenantThirdfort creates the organization or partner
Issue API credentialsYou upload a public key for the m2m userThirdfort issues an OAuth client ID and secret
Create a customer (partners only)POST /v2/tenantsRequest that Thirdfort create a new organization
Create teams and brandsPOST /v2/teamsCreateTeam and CreateBrand APIs
Represent the requesting userPOST /v2/users stub user, then user-id headerUse on_behalf_of
Choose the name consumers seeTenant nameBrand 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 shapeTemplate
actor present, report:identity in taskscheckTemplates/identity-verification-check-v1-0-0
No actor, report:screening:lite with expectationscheckTemplates/clientonly-verification-check-v1-0-0
actor present, report:sof-v1 without report:identitycheckTemplates/source-of-funds-check-v1-0-0

Map the tasks to params (identity verification)​

Legacy taskIdentity verification param
report:identityImplied by the template. Set passportNfcPreference to OPTIONAL (NFC first, fallback to document capture) or SKIP (document capture only). Required.
report:footprintAlways runs; no flag. opts.consent → internationalAddressVerificationConsent
report:pepsAlways runs. opts.monitored: true → enableOngoingMonitoring: true
report:sof-v1requireSourceOfFundsQuestionnaire: true
report:bank-statement, report:bank-summaryrequireBankingData: true
documents:poarequireProofOfAddressDocument: true
documents:poorequireProofOfOwnershipDocument: 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.

requireSourceOfFundsQuestionnaire and requireBankingData are mutually exclusive on the identity verification template. A legacy transaction that requested both report:sof-v1 and 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​

LegacyClient API
namedisplayName (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.phoneparams.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_nameparams.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​

LegacyClient API
GET /v2/transactions/{id} → statusGET /v1/{check} → state
GET /v2/transactions/{id}/_summary → reports.{identity,footprint,peps}.resultGET /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/pdfcheckSummary.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 statusstateLegacy resultrecommendation
openACTIVEclearCLEAR
closedINACTIVEconsiderCONSIDER
abortedCANCELLEDfailFAIL
deletedDELETED—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​

LegacyClient 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:disableMonitoringCall :setOngoingMonitoring per check
DELETE /v2/transactions/{id} (abort)POST /v1/{check}:cancel (irreversible; also stops monitoring)
PATCH /v2/transactions/{id} to renameNot 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.

LegacyClient 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:

  1. Stop sending metadata.notify.
  2. Verify x-thirdfort-signature (base64) with the Thirdfort-issued secret instead of X-Signature (hex) with your own key.
  3. 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 featureStatus
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-infoNo separate report; subject data appears on the check summary
documents:identity, documents:other, documents:divorce/inheritance/mortgage/sale-assets/savings as standalone uploadsCollected 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 filesNot 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 keysOrganization-level only

Troubleshooting​

SymptomLikely cause
400 naming a fieldMissing displayName, clientReference, params.@type, or passportNfcPreference; or both requireSourceOfFundsQuestionnaire and requireBankingData set
Error naming a featureYour organization is not entitled to a param you set
401Token missing or expired; refresh before the hour is up
403 reading a checkThe check does not exist or is under a different organization or team; a missing check returns 403, not 404
404Path is missing the organizations/{org}/teams/{team} parent, or uses a legacy ID instead of the full resource name
PDF download failsNo Authorization header, or the URL was rebuilt rather than used verbatim from reportUrl

Cut-over checklist​

  1. Obtain OAuth credentials plus organization and team IDs for nonproduction.
  2. Replace JWT signing with the client-credentials token call and a refresh timer.
  3. For each transaction shape you create, pick a template and build the params from the tables above.
  4. Store the returned check name against your record, keyed by clientReference.
  5. Switch result reads to checkSummary and findings; map state and recommendation values.
  6. Replace inbox polling or per-transaction webhooks with organization notifications; deduplicate by notification name.
  7. Run both integrations in parallel, then stop creating legacy transactions. Existing ones finish on the legacy API.