REST API

Read and write BesTest requirements, test cases, cycles, executions and traceability links from scripts and CI. Setup, authentication, filtering, paging, safe updates with ETags, and limits.

The BesTest REST API lets your CI pipelines, scripts and internal tools read and write the same testing data your team works with by hand: requirements, test cases and steps, cycles, executions, collections, campaigns, and the traceability links between them and your Jira work items. Automated results and manual runs end up in one place.

This page is the guide: how to get set up, the handful of ideas that carry the whole API, and what is worth building. For endpoint-by-endpoint detail, use the reference.

Full REST API reference

Every resource, every parameter, every response shape, with copy-paste examples.

Open the REST API reference

If you would rather start from something that runs, the recipes are complete flows for the jobs people automate first, and the reference has worked workflows for every multi-step pattern, each as a script you can copy.

Open beta

The REST API is in open beta. You create your own token inside the app and start immediately. The surface can still change while we are in beta; we will tell you before anything breaks.

What you can do with it

A customer once asked us: "and what would I actually do with the REST API?" The short answer: connect your automation to the same picture your testers use. What teams build first:

  • Push automated results into a cycle. After a nightly Playwright, Cypress, JUnit or Postman run, record each result against its test case in the right cycle, so automated runs sit beside the manual ones.
  • Gate a release from CI. Count what failed, what is blocked and what nobody ran in the release cycle, in one request, and fail the pipeline if it is not clean.
  • Feed your own dashboards. Pull requirements, test cases and execution history into Grafana, Power BI or a data warehouse.
  • Keep test cases in step with code. Create or update test cases and their steps from your repo, keyed by an automationKey your test runner already knows.
  • Build traceability from your tools. Link test cases to requirements, and failed runs to the Jira bugs they raised.
  • Automate cycle setup. Create a cycle per release or sprint, plan test cases into it, and group cycles into a campaign.

If you would rather ask for these things in plain English than write code, the MCP server reaches the same data with the same token.

Set it up

1. Create a token

Open the app menu in BesTest, choose API & MCP tokens, then Create token. Name it, pick an expiry, choose read only or read and write, and pick the Space it may reach. Copy the value immediately: it is shown once. The full walkthrough, with rotation and revocation, is in API tokens.

Pick the narrowest access that does the job. A release gate only reads, so give it a read-only token; only a pipeline that writes results back needs read and write.

2. Find your base URL

Your data lives in one region, and a token only works against that region's server:

Your data regionAPI base URL
Europehttps://prod-eu.getbestest.com/api/v1
North Americahttps://prod-us.getbestest.com/api/v1
Indiahttps://prod-in.getbestest.com/api/v1

3. Make your first call

export BESTEST_BASE="https://prod-eu.getbestest.com"   # your region
export BESTEST_TOKEN="eyJ..."                           # your token

curl "$BESTEST_BASE/api/v1/projects" \
  -H "Authorization: Bearer $BESTEST_TOKEN"

This returns the one Space your token reaches. Keep its id: it is the projectId that every create request needs. If you get 403 instead, you are most likely talking to the wrong region.

Never ask for application/json

This API follows the JSON:API standard strictly. You can leave the Accept header out, or send application/vnd.api+json, but a request that asks for application/json is refused with 406 Not Acceptable, even when everything else is right. Any request with a body must send Content-Type: application/vnd.api+json, or it gets 415. These two trip up nearly everyone once.

4. Get the OpenAPI file, if your tools want one

GET /api/v1/openapi.json with your token returns the machine-readable OpenAPI 3.1 description, trimmed to exactly what that token is allowed to do. Import it into Postman, Insomnia, Bruno or a client generator. It describes every entity, field, enum value, filterable field and error code, and includes the worked workflows.

curl "$BESTEST_BASE/api/v1/openapi.json" \
  -H "Authorization: Bearer $BESTEST_TOKEN" \
  -H "Accept: */*" -o bestest-openapi.json

How access works

There is nothing to grant centrally. Anyone who can use BesTest can create their own token, and it carries their own permissions, in one Space, at the level they chose. Nobody gets more through the API than they already have in the app.

A Space admin controls the other direction: Space settings → API / MCP lists every token pointed at that Space, whoever created it, with its owner, access level, expiry, last use and status, and the admin can revoke any of them. That is the lever for someone leaving the team, a leaked token, or an audit.

For a shared pipeline, create the token under an account the team controls together rather than a personal one. A personal token stops working with its owner's access, and your nightly build goes red at 2am.

The ideas that carry the API

ConceptWhat it means
JSON:APIEvery request and response is a JSON:API document: a data envelope, type and id on every resource, the fields under attributes. Errors come back as an errors array.
One Space per tokenEvery request is scoped to the Space the token was created for. There is no cross-Space query.
FiltrQLFiltering is one readable expression: filter=result = "FAILED" AND executedAt > now - 7d.
Paging25 records per page by default, 100 at most, by page number or by cursor.
AggregatesEvery collection has an /aggregate twin for counts and rollups, optionally grouped, so you never page through 10,000 rows to count them.
ETagsUpdates and deletes must send back the ETag from your last read as If-Match, so two writers cannot silently overwrite each other.

Everything has a key, and the API wants an id

In the app you see keys: KAN-CY-38, KAN-TC-42, KAN-REQ-7. Every API path takes the record's id, a UUID. The bridge is a filter, because the key is an ordinary field:

curl -G "$BESTEST_BASE/api/v1/test_cycles" \
  -H "Authorization: Bearer $BESTEST_TOKEN" \
  --data-urlencode 'filter=testCycleKey = "KAN-CY-38"'

The field is testCaseKey on test cases, testCycleKey on cycles, requirementKey on requirements and testCaseExecutionKey on executions. You can also filter through a relation with dot notation, which often removes the lookup entirely:

--data-urlencode 'filter=testCycle.testCycleKey = "KAN-CY-38" AND testCase.testCaseKey = "KAN-TC-42"'

This is the step that catches almost everyone on their first useful call. There is a worked version in Find things by their key.

The values that matter

Automation mostly reads and writes these:

FieldOnValues
resultexecutionsNOT_EXECUTED, IN_PROGRESS, PASSED, FAILED, BLOCKED, SKIPPED
resultstep executionsthe same, without IN_PROGRESS
statustest cyclesDRAFT, ACTIVE, IN_PROGRESS, DONE
statustest casesDRAFT, IN_REVIEW, CHANGE_NEEDED, ACTIVE, ARCHIVED
statusrequirementsDRAFT, READY, COVERED, ARCHIVED, IN_REVIEW, CHANGES_REQUESTED, APPROVED
complexity, impactrequirementsLOW, MEDIUM, HIGH (together they produce significance)
significancerequirementsLOW, MEDIUM, HIGH, CRITICAL (read only, calculated)
prioritytest casesCRITICAL, HIGH, MEDIUM, LOW
automationStatustest casesMANUAL, AUTOMATED, TO_BE_AUTOMATED, NOT_AUTOMATABLE

A value outside its list is rejected with 422. The reference lists the allowed values on every field, generated from the live schema.

Filtering

Strings go in double quotes. Combine conditions with AND, OR and parentheses, and let curl -G --data-urlencode do the encoding:

# Failures in the last week
filter=result = "FAILED" AND executedAt > now - 7d

# Case-insensitive "contains", and a list
filter=name ~ "checkout" AND priority in ("HIGH", "CRITICAL")

# Relation quantifiers: test cases with at least one failed run
filter=testCaseExecutions ANY (result = "FAILED")

# ...or where no run ever passed
filter=testCaseExecutions NONE (result = "PASSED")

# Functions: you, and calendar boundaries
filter=ownerId = currentUser() AND updatedAt >= startOfWeek()

# Empty and null checks
filter=automationKey is null AND automationStatus = "AUTOMATED"

Functions: currentUser(), startOfDay(), endOfDay(), startOfWeek(), endOfWeek(), startOfMonth(), endOfMonth(), startOfYear(), endOfYear(). Every list endpoint in the reference shows its own filterable fields and the operators each accepts. A field or relation that does not exist is rejected with 400, and the error lists the valid ones.

Sorting, sparse fields and includes

# Newest first
sort=-updatedAt

# Only the attributes you need: a 30-field record becomes 3
fields[test_cases]=testCaseKey,name,status

# Embed a related to-one record in the same response
include=testCaseFolder,ownerUser

include follows to-one relations, up to three levels deep. To-many relations have their own URLs instead, such as /test_cases/{id}/testSteps or /test_cycles/{id}/testCaseExecutions, which page and filter like any collection.

Paging

Collections return 25 records by default and at most 100. There are two ways to walk them:

  • By page number: page[size]=100&page[number]=2. The response carries meta.totalCount and first, prev, next and last links. Sort by a stable field so records do not shuffle between pages.
  • By cursor: send an empty page[after]= on the first request, then follow links.next until it is absent. Cursors do not skip or repeat records when data changes while you read, which makes them the right choice for exports. Take each cursor verbatim from links.next; never build or decode one.

Sending page[number] and page[after] together is rejected with 400.

Counting with aggregates

# How many active test cases?
curl -G "$BESTEST_BASE/api/v1/test_cases/aggregate" \
  -H "Authorization: Bearer $BESTEST_TOKEN" \
  --data-urlencode 'aggregations[count]=true' \
  --data-urlencode 'filter=status = "ACTIVE"'
{ "data": { "type": "testCases_aggregate", "id": "(aggregate)", "attributes": { "count": 354 } } }

Add aggregations[groupBy][]=result on executions and you get one count per result in a single request, which is a release gate in one call. Sums, averages, minimums and maximums work the same way, on the columns each endpoint lists.

Writing data

Create

A create is a POST of one JSON:API resource. Most need your Space's projectId (from GET /projects) and a rank, the record's position in ordered lists; 1 is fine:

curl -X POST "$BESTEST_BASE/api/v1/test_cases" \
  -H "Authorization: Bearer $BESTEST_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "test_cases",
      "attributes": {
        "projectId": "YOUR_PROJECT_ID",
        "rank": 1,
        "name": "Checkout rejects an expired card",
        "priority": "HIGH",
        "automationStatus": "AUTOMATED",
        "automationKey": "checkout.spec.ts:expired-card"
      }
    }
  }'

Child records are created after their parent and carry its id: a test step carries testCaseId and a sequenceNumber, an execution carries testCycleId and testCaseId. The worked workflows show a case and its steps end to end.

The server stamps what it owns: keys, ids, created and updated times, the test case version on an execution, and run times. Sending one of those returns 422 naming the attribute.

Update, safely

Updates and deletes are version-locked. Read the record, take the ETag response header, and send it back as If-Match:

# 1. Read it, keeping the headers
curl -sS -D headers.txt "$BESTEST_BASE/api/v1/test_case_executions/$EXEC_ID" \
  -H "Authorization: Bearer $BESTEST_TOKEN" -o /dev/null
ETAG=$(grep -i '^etag:' headers.txt | cut -d' ' -f2- | tr -d '\r')

# 2. Change only what changes, and prove which version you read
curl -X PATCH "$BESTEST_BASE/api/v1/test_case_executions/$EXEC_ID" \
  -H "Authorization: Bearer $BESTEST_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -H "If-Match: $ETAG" \
  -d "{\"data\":{\"type\":\"test_case_executions\",\"id\":\"$EXEC_ID\",\"attributes\":{\"result\":\"PASSED\"}}}"

No If-Match gets 428 Precondition Required. A stale one, because someone changed the record after you read it, gets 412 Precondition Failed: read it again, check the change still makes sense, and retry. That is the whole point: a pipeline cannot silently overwrite what a tester just recorded by hand.

Delete and restore

Requirements, test cases, test steps, test collections and test campaigns are soft-deleted. A deleted record disappears from lists but stays reachable with withDeleted=true, and POST /{type}/{id}/actions/restore (with the deleted record's ETag as If-Match) brings it back. Test cycles cannot be deleted through the API at all; set their status instead.

Custom fields

Your Space's custom fields are read and written through each record's customFields attribute, as plain key and value pairs, on requirements, test cases, test cycles, test collections and test campaigns. GET /gqlith_custom_field lists the fields your Space defines. An unknown key is rejected with 422. Custom fields can be filtered on like any other field.

A request body can be at most 1 MB.

Rate limits, and how not to hit them

Requests are rate-limited per person, and the REST API and MCP share the same limits. They are set well above what a CI pipeline, a dashboard export or an agent normally needs, and we tune them as the beta grows, so build for the response rather than for a number.

Over a limit you get 429 with the code RATE_LIMITED. There is no Retry-After header, so back off for a few seconds and retry, doubling the wait if it happens again.

You rarely get near them if you use the tools the API gives you:

Instead ofUse
Paging a collection to count it/aggregate with aggregations[count]=true, one request
A count per statusaggregations[groupBy][]=result, still one request
A follow-up request per related recordinclude= on the first request, or the related route
Default pages of 25page[size]=100
Whole records when you need three fieldsfields[type]=a,b,c

What is not here yet

Worth knowing before you design around it:

  • No bulk import over the API. There is no batch create endpoint; records are created one request at a time. For a large migration, use Import, Export in the app, or an agent over MCP, which can create records in batches.
  • No coverage or report data. Requirement coverage, the release-readiness report and the other reports are computed in the app and not exposed to tokens. Build release gates from executions, as in the recipe.
  • No review and approval actions. Reviews happen in the app.
  • No webhooks. Nothing calls you when something changes, so poll on a schedule. updatedAt on every record makes "what changed since my last run" a filter: filter=updatedAt > now - 1h.
  • No OAuth. Authentication is a token you create yourself.
  • No public GraphQL documentation. MCP uses a GraphQL surface under the hood, but it is not documented for direct use during the beta.

What "beta" means for stability

The API is versioned in its path (/api/v1) and that version is a promise: we will not change the meaning of a field or remove an endpoint from v1 without telling you first. Additive changes, meaning new endpoints, new optional fields and new enum values, can land at any time, so write clients that ignore what they do not recognise.

The surface is still growing and we would rather hear from you early. If you build something on this and we are about to make it awkward, we want to warn you, so tell support what you have automated.

Programmatic access is included on every plan, the free tier included, with no per-call charge.

When something goes wrong

Every error is an errors array. code is stable and meant for your code; detail is written for a person and usually names the fix:

{
  "errors": [
    {
      "status": "428",
      "title": "Precondition Required",
      "code": "MISSING_IF_MATCH",
      "detail": "This resource uses optimistic locking. You must include an \"If-Match\" header with the current ETag value (from the \"ETag\" response header) to prevent concurrent modification conflicts."
    }
  ]
}
StatusWhat it usually means
400The query string is wrong: an unknown field or relation, a filter that does not parse, or two paging styles at once.
401The token has been revoked.
403No token, an expired or incomplete one, a token from another region, or a read-only token attempting a write.
404No such record, or it is outside the Space your token reaches.
406Accept asked for application/json.
409The write conflicts with existing data, such as a reference to a record that does not exist.
412Your If-Match ETag is stale. Read again and retry.
415Content-Type was not application/vnd.api+json.
422A value is invalid, a required attribute is missing, or you sent an attribute the server sets itself.
428The record is version-locked and you sent no If-Match.
429Rate limited.

The full reference

Every resource, parameter, filterable field and response shape, generated from the API's own schema so it stays current:

REST API reference

Requirements, test cases, cycles, executions, collections, campaigns and links, with copy-paste curl for each, plus worked multi-step workflows.

Browse all endpoints
  • API tokens - create, rotate and revoke
  • Recipes - CI results, release gates, exports
  • Overview - regions, permissions and rate limits
  • MCP - the same data in plain English
  • Test automation - connecting your CI and coverage
Getting started

Live in about a minute.

  1. ~30 seconds
    1.Install from the Marketplace

    One click on "Get it now" - no sales call, no signup form, no separate login.

  2. ~1 minute
    2.Enable it on a Space

    Flip it on in Space settings. BesTest shows up in the Space menu, where your team already works.

  3. right away
    3.Run your first test

    Create a requirement, link a test case, hit run. No training course required.

Host your data in the EU, US, or IndiaNo Jira issue bloat - your library stays out of Jira’s wayBuilt on Atlassian Forge