curl --request POST \
--url https://app.testorim.com/api/projects \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"baseUrl": "https://example.com"
}
'{
"project": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"name": "<string>",
"baseUrl": "<string>",
"createdAt": "2023-11-07T05:31:56Z",
"lastUsedAt": "2023-11-07T05:31:56Z",
"hasLoginState": true,
"archivedAt": "2023-11-07T05:31:56Z",
"storageStateCapturedAt": "2023-11-07T05:31:56Z",
"timezone": "<string>",
"locale": "<string>",
"geolocationJson": {
"latitude": 0,
"longitude": 0,
"accuracy": 5000
},
"appContextJson": {
"appType": "<string>",
"summary": "<string>",
"authModel": "<string>",
"keyFlows": [
"<string>"
],
"domainTerms": [
"<string>"
],
"testNotes": "<string>"
},
"appMapJson": {}
},
"created": false
}{
"project": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"name": "<string>",
"baseUrl": "<string>",
"createdAt": "2023-11-07T05:31:56Z",
"lastUsedAt": "2023-11-07T05:31:56Z",
"hasLoginState": true,
"archivedAt": "2023-11-07T05:31:56Z",
"storageStateCapturedAt": "2023-11-07T05:31:56Z",
"timezone": "<string>",
"locale": "<string>",
"geolocationJson": {
"latitude": 0,
"longitude": 0,
"accuracy": 5000
},
"appContextJson": {
"appType": "<string>",
"summary": "<string>",
"authModel": "<string>",
"keyFlows": [
"<string>"
],
"domainTerms": [
"<string>"
],
"testNotes": "<string>"
},
"appMapJson": {}
},
"created": true
}{
"error": "Invalid request body",
"issues": [
{
"code": "<string>",
"path": [
"<string>"
],
"message": "<string>"
}
]
}{
"error": "Invalid or revoked API key"
}{
"error": "Viewers have read-only access to this workspace",
"code": "read_only_role"
}{
"error": "<string>",
"retryAfter": 123
}Create (or re-use) a project
Creates a project for the key’s organization.
This endpoint is idempotent-by-URL, and the status code tells you
which happened. The base URL is normalised
(https:// added when the scheme is missing, trailing slash on the
path removed) and then matched against existing non-archived
projects in the org:
- no match → the project is inserted and the response is
201withcreated: true; - match → the existing project’s
lastUsedAtis bumped and the response is200withcreated: false.
The normalised URL keeps only the scheme, host and path (a query or
fragment is dropped). It is checked by the SSRF guard, including a
DNS lookup of the name, before anything is persisted, so a private or
internal host is refused with 400 and never stored.
When name is omitted it is derived from the hostname with a leading
www. stripped.
curl --request POST \
--url https://app.testorim.com/api/projects \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"baseUrl": "https://example.com"
}
'{
"project": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"name": "<string>",
"baseUrl": "<string>",
"createdAt": "2023-11-07T05:31:56Z",
"lastUsedAt": "2023-11-07T05:31:56Z",
"hasLoginState": true,
"archivedAt": "2023-11-07T05:31:56Z",
"storageStateCapturedAt": "2023-11-07T05:31:56Z",
"timezone": "<string>",
"locale": "<string>",
"geolocationJson": {
"latitude": 0,
"longitude": 0,
"accuracy": 5000
},
"appContextJson": {
"appType": "<string>",
"summary": "<string>",
"authModel": "<string>",
"keyFlows": [
"<string>"
],
"domainTerms": [
"<string>"
],
"testNotes": "<string>"
},
"appMapJson": {}
},
"created": false
}{
"project": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"name": "<string>",
"baseUrl": "<string>",
"createdAt": "2023-11-07T05:31:56Z",
"lastUsedAt": "2023-11-07T05:31:56Z",
"hasLoginState": true,
"archivedAt": "2023-11-07T05:31:56Z",
"storageStateCapturedAt": "2023-11-07T05:31:56Z",
"timezone": "<string>",
"locale": "<string>",
"geolocationJson": {
"latitude": 0,
"longitude": 0,
"accuracy": 5000
},
"appContextJson": {
"appType": "<string>",
"summary": "<string>",
"authModel": "<string>",
"keyFlows": [
"<string>"
],
"domainTerms": [
"<string>"
],
"testNotes": "<string>"
},
"appMapJson": {}
},
"created": true
}{
"error": "Invalid request body",
"issues": [
{
"code": "<string>",
"path": [
"<string>"
],
"message": "<string>"
}
]
}{
"error": "Invalid or revoked API key"
}{
"error": "Viewers have read-only access to this workspace",
"code": "read_only_role"
}{
"error": "<string>",
"retryAfter": 123
}Authorizations
Send Authorization: Bearer tst_live_….
Format (services/api-keys.ts): the literal prefix tst_live_
followed by 24 random bytes rendered as 32 base64url characters.
Only the SHA-256 hash is stored server-side. The shape check that
routes a token down the API-key path rather than the Clerk JWT path
requires the tst_live_ prefix and a total length of at least 25
characters.
Keys are minted in the dashboard at /settings/api-keys, in any
workspace, personal included. The same header also accepts a Clerk
session JWT, which is how the web app authenticates, but the JWT path
is out of scope for this document.
Body
The site under test. The scheme may be omitted, in which case https:// is prepended, and a trailing slash on the path is removed before storage and before the duplicate check. Private, loopback, link-local and cloud-metadata hosts are rejected.
3 - 500Display name. Defaults to the hostname with a leading www. stripped.
1 - 100Response
A project with this normalised base URL already existed in the organization; it was returned with lastUsedAt refreshed.

