CI/CD integration
Run your voice tests on every deploy and fail the pipeline when quality drops below your threshold. There are two ways to wait for the result:
Server waits (?wait=true) |
Client polls (CLI, GitHub Action) | |
|---|---|---|
| Tools | one curl |
examples/cli/callqa (Python 3.9+, no dependency) |
| Longest wait | 900 s | as long as you want (--timeout) |
| Summary | JSON of the run | Markdown table per test case, job outputs |
| Best for | short suites, minimal setup | real suites, GitHub summaries, flaky networks |
Both use the same endpoint and the same pass rule.
The API
POST /api/v1/scenarios/{scenario_id}/runs?wait=true&threshold=80&timeout=600
Scope: run. Body (all optional):
{
"version": "v2.3.0",
"git_sha": "9f2c1ab",
"git_ref": "main",
"environment": "staging",
"tags": ["ci"],
"variants": ["2b9d...", "8f41..."],
"ci": true
}
| Parameter | Where | Meaning |
|---|---|---|
wait |
query | true: hold the request until the run finishes. Default false. |
timeout |
query | Seconds to wait, default 600, capped at 900. |
threshold |
query | Minimum run score, 0 to 100. Stored on the run. |
version |
body | Version of your code under test (v2.3.0, a short sha), max 60 characters. Looked up in the run's environment, created there if new. Without it the run belongs to the version live in the environment. |
git_sha, git_ref, source_url |
body | Commit, branch or tag, and pipeline page of that version, stored when the version is created. |
environment |
body | Environment by slug, name or id (staging). Default: your default environment. It decides the number dialed. |
release |
body | Deprecated: the former name of version, read when version is absent. See Versioning. |
tags |
body | Free keywords added to the run. Created if new. |
variants |
body | Only these enabled variants. Default: all enabled variants. |
ci |
body | Mark the run as triggered by CI (trigger: "ci"). Implied by wait=true. |
Answers:
| Status | When | Body |
|---|---|---|
201 |
Without wait: the run is queued |
the run |
200 |
With wait: finished and passed |
the run |
412 |
With wait: finished and failed |
the run |
202 |
With wait: still running at timeout |
the run: poll GET /runs/{id} |
400 |
threshold out of range (invalid_threshold), unknown environment, scenario archived or no matching enabled variant (run_not_possible) |
error |
404 |
Unknown scenario | error |
The run object has a ci block: {"threshold": 80, "passed": true}. passed is null while
the run is not finished.
The pass rule
When the run is completed:
- with a threshold: passed if
score >= thresholdand no call has the verdictfail, ended inerroror was blocked (for example by insufficient credit). One failed test case breaks the gate whatever the average score: a broken case cannot be averaged away by the passing ones.warncalls only lower the score. - without a threshold: passed only if the run verdict is
pass. Awarnrun fails the gate.
A canceled run never passes: it answers 412, not 202. Choose the threshold from your recent
scores on the dashboard, a few points under your usual level, then raise it as the agent
improves.
The API key needs the run scope to trigger the run and the read scope to read it back (the
CLI and the GitHub Action list the calls of the run for their summary). write is not needed
in CI.
Curl only
code=$(curl -sS -o run.json -w '%{http_code}' -X POST --max-time 960 \
-H "Authorization: Bearer $CALLQA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"version": "'"$GIT_SHA"'", "git_sha": "'"$GIT_SHA"'", "tags": ["ci"]}' \
"https://app.callqa.dev/api/v1/scenarios/$SCENARIO/runs?wait=true&timeout=900&threshold=80")
jq '{status, verdict, score, ci, url}' run.json
[ "$code" = "200" ] || exit 1
Do not rely on curl --fail alone: it treats 202 (still running) as a success, so a slow
suite would pass the gate. Check the status code as above: only 200 passes.
The server waits asynchronously: a waiting request holds no worker and no database
connection, so a timeout of 900 seconds costs nothing on our side.
Proxies and load balancers in front of your runner may cut a request that stays open for minutes. If that happens, use the CLI instead.
The CLI
examples/cli/callqa is one Python file with no dependency. Copy it
into your repository (for example ci/callqa):
mkdir -p ci && curl -sSfo ci/callqa https://app.callqa.dev/docs/examples/cli/callqa
export CALLQA_API_KEY=...
python3 ci/callqa run --scenario "$SCENARIO" --threshold 80 \
--code-version v2.3.0 --git-sha "$GIT_SHA" --environment staging --tag ci \
--wait --summary callqa-summary.md
--code-version, --git-sha, --git-ref and --environment map to the body fields above
(--release is still accepted as the former name of --code-version). It triggers the run, polls GET /runs/{id} every 10 s (--poll), retries on 429 and on
network errors while polling, then writes a Markdown summary and exits with:
| Exit code | Meaning |
|---|---|
0 |
passed |
1 |
quality gate failed (score under threshold, verdict fail, error or blocked call) |
2 |
usage error (missing key, bad argument) |
3 |
API or network error (bad key, missing scope, unknown scenario...) |
4 |
timed out while waiting (--timeout, default 1800 s); the run continues |
5 |
the run was canceled |
Other commands: callqa me, callqa status RUN_ID, callqa bundle export CALL_ID,
callqa replay --call CALL_ID --wait, callqa scenario import file.yaml.
GitHub Actions
examples/ci/github-action/ is a composite action. Copy it
to .github/actions/callqa/ and copy the CLI next to its action.yml
(.github/actions/callqa/callqa). Then:
jobs:
voice-tests:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- id: callqa
uses: ./.github/actions/callqa
with:
api-key: ${{ secrets.CALLQA_API_KEY }}
scenario-id: ${{ vars.CALLQA_SCENARIO_ID }}
threshold: "80"
version: ${{ github.sha }}
git-sha: ${{ github.sha }}
environment: staging
tags: "ci,${{ github.ref_name }}"
- if: always()
run: echo "Report ${{ steps.callqa.outputs.run-url }}"
| Input | Default | |
|---|---|---|
api-key |
required | Key with read and run scopes, from a secret |
scenario-id |
required | Scenario to run (all its enabled variants) |
threshold |
80 |
Empty: pass only on verdict pass |
version |
Version of your code under test (created in the environment if new) | |
git-sha |
Commit of that version | |
environment |
Environment slug. Empty: the default environment | |
release |
Deprecated: use version |
|
tags |
Comma-separated | |
variants |
Comma-separated variant ids | |
wait |
true |
false: trigger and return |
timeout |
1800 |
Seconds |
poll-interval |
10 |
Seconds |
api-url |
https://app.callqa.dev/api/v1 |
Outputs: run-id, run-url, verdict, score, passed. The action writes the summary table
to the job summary page.
Full workflow: examples/ci/github-workflow.yml.
Curl-only workflow: examples/ci/github-workflow-curl.yml.
GitLab CI
voice-test:
stage: voice-test
image: python:3.12-slim
needs: ["deploy-staging"]
timeout: 40m
script:
- python3 ci/callqa run --scenario "$CALLQA_SCENARIO_ID" --threshold 80
--code-version "$CI_COMMIT_SHORT_SHA" --git-sha "$CI_COMMIT_SHA" --tag gitlab
--wait --summary callqa-summary.md
artifacts:
when: always
paths: [callqa-summary.md]
Store CALLQA_API_KEY as a masked, protected CI/CD variable. Full file:
examples/ci/gitlab-ci.yml.
Jenkins
stage('Voice tests') {
environment { CALLQA_API_KEY = credentials('callqa-api-key') }
steps {
sh 'python3 ci/callqa run --scenario "$CALLQA_SCENARIO_ID" --threshold 80 --code-version "$GIT_COMMIT" --wait'
}
}
Full file: examples/ci/Jenkinsfile.
Tips
- Run after the deploy, against the environment you just deployed: pass
environment(for examplestaging) so the call dials that environment's number. See Environments. - One scenario per pipeline job, or run several jobs in parallel. Calls of one run are already placed in parallel, up to your plan's concurrent call limit.
- Budget time: a call usually lasts 1 to 5 minutes, bounded by
limits.max_duration_s. Set the CI job timeout above your longest call plus queueing. - Name the version (
version: v2.3.0or the commit SHA): the Dashboard puts a marker on the score curve for each version, and Versions and environments compares them. - Every CI run uses quota: calls beyond your monthly quota are paid from the prepaid wallet. A call the wallet cannot cover is not placed and fails the gate. See Billing.
- Subset for fast feedback: pass
variantsto run a smoke subset on every commit, and the full suite nightly with a schedule (one per environment, see Writing scenarios). - Prefer being notified over polling for long suites: Webhooks.