> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chargerdojo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Run a conformance suite

> Drive a full conformance sweep, a single module or scenario, or one manual OCPI command against a registered partner endpoint, from the command line.

Once a connection is `registered`, you can run against it.

## See what can run

```bash theme={null}
curl -fsS "https://chargerdojo.com/api/v1/testing/suites" \
  -H "Authorization: Bearer $DOJO_KEY"
```

Returns the available suites. A run only executes a **protocol module** the selected OCPI
version **advertises**. If a version does not advertise one, the run refuses it rather than
executing a suite that was never made to pass against those routes, so a module absent from a
version has no suite there, not a failing one. Scenarios are the exception: you name one
explicitly and it runs regardless, because it walks a flow rather than testing a module's
endpoints. The advertised set
differs by version; 2.3.0 advertises the widest set today, including the Bookings and
Payments (Direct Payment) suites.

## Run everything

```bash theme={null}
curl -fsS -X POST "https://chargerdojo.com/api/v1/testing/runs?wait=55" \
  -H "Authorization: Bearer $DOJO_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"connectionId\": \"$CONNECTION_ID\"}"
```

Omit `module` and you get every advertised protocol-conformance suite your plan covers for the
connection's OCPI version. **Scenarios are never part of a sweep**, for any account: name one to
run it.

Direct Payment and Bookings are both on the Automate plan, so against a real endpoint their
suites run only for accounts that have it. Against the sandbox they are open to everyone, because it costs us nothing
to let you see what they do before you buy. When a run leaves them out, the report says which
modules were withheld, so a smaller total is never a mystery.

`?wait=<seconds>` blocks for **up to** that long, to a maximum of 55; larger values are
clamped to it, so a script asking for 120 gets 55 and should loop rather than wait longer. Four outcomes, and a CI script has to
tell them apart:

| Status | `data.status`         | Meaning                                                                                                                                    | Body                                                                                                                              |
| ------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `200`  | `completed`           | The run finished and was graded.                                                                                                           | A summary in `data.summary` (passed, failed, warned and unobservable counts). The per-check report is at `GET /testing/runs/:id`. |
| `200`  | `failed`              | The run stopped early. Whatever it graded before it stopped is real.                                                                       | A summary covering the checks that ran, plus `data.error`. Fewer checks than a full sweep.                                        |
| `200`  | anything else         | The run is over and was never graded. Today that means `interrupted`: we restarted the server and the run it was carrying did not survive. | **No `data.summary` at all.** `data.error` says what happened.                                                                    |
| `202`  | `queued` or `running` | The run is **still going**. Your wait expired, not the run.                                                                                | `data.reportId` and `data.status`. No `summary`.                                                                                  |

**Read `data.status` before you read the counters.** A 200 alone does not mean there is a
verdict to read, and `jq` answers `null` for a summary that is not there, which shell
arithmetic turns into an error rather than a number. The script below checks the status first
for exactly this reason.

Poll `GET /api/v1/testing/runs/$REPORT_ID?wait=<seconds>` until the run reaches a terminal
state. Without `?wait`, the start call returns immediately and you poll the same way.

## Repeat a run

A run creates objects on your endpoint, and the ids it uses are derived from a **seed**. Send
the same seed again and the run sends the same requests, down to the object ids, which is what
turns "it failed yesterday" into something you can put in front of a partner.

```bash theme={null}
curl -fsS -X POST "https://chargerdojo.com/api/v1/testing/runs" \
  -H "Authorization: Bearer $DOJO_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"connectionId\": \"$CONNECTION_ID\", \"seed\": \"nightly-2026-08-29\"}"
```

`seed` is optional and is 1 to 128 characters. Omit it and one is generated for you; either way
the seed comes back in `data.seed` on every answer the start call gives, so keep it with the
report. What it reproduces is **the requests we send**, not your endpoint: replaying a seed
against a target that already holds those objects re-sends ids it created the first time, and an
immutable object such as a settled CDR will rightly refuse the second POST.

The seed on a Charging Journey is a different field with the same name. It belongs to the
journey manifest, it shapes the scenario itself rather than object ids, and it is set when you
compile the plan. See [Run a charging journey](/guide/run-a-charging-journey).

## Run one module or scenario

```bash theme={null}
curl -fsS -X POST "https://chargerdojo.com/api/v1/testing/runs" \
  -H "Authorization: Bearer $DOJO_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"connectionId\": \"$CONNECTION_ID\", \"module\": \"scenario_ptp_e2e\"}"
```

Useful when you are iterating on one thing and do not want to wait for the rest. A
scenario walks an end-to-end story (a session that produces a CDR, a command with its
asynchronous callback) rather than checking endpoints in isolation, and it chains its own
output: each step feeds the next. That is why a scenario can fail on a late step with a
perfectly healthy endpoint; something upstream handed it nothing to work with. Read the
exchange on the first red step, not the last.

## Send one manual OCPI command

Before scripting a whole flow, it is often worth firing a single request and reading the
exact exchange:

```bash theme={null}
curl -fsS -X POST "https://chargerdojo.com/api/v1/testing/command" \
  -H "Authorization: Bearer $DOJO_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"connectionId\": \"$CONNECTION_ID\", \"method\": \"GET\", \"endpoint\": \"/locations\"}"
```

| Field            | Notes                                                                                                                                                                        |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connectionId`   | A registered connection.                                                                                                                                                     |
| `method`         | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. A `DELETE` or `PUT` at `/credentials` ends the registration on their system, so it asks for a signed-in person rather than a key. |
| `endpoint`       | Module-relative OCPI path, for example `/commands/START_SESSION`.                                                                                                            |
| `body`           | Request body for write methods. Optional.                                                                                                                                    |
| `expectedStatus` | Expected HTTP status, or every status the spec permits. Optional.                                                                                                            |

The response includes the request that was sent, the response status and body, and the
latency.

## Manage your runs

```bash theme={null}
# List your runs, optionally filtered
curl -fsS "https://chargerdojo.com/api/v1/testing/runs?connectionId=$CONNECTION_ID" \
  -H "Authorization: Bearer $DOJO_KEY"

# Delete one run
curl -fsS -X DELETE "https://chargerdojo.com/api/v1/testing/runs/$REPORT_ID" \
  -H "Authorization: Bearer $DOJO_KEY"
```

Runs are owner scoped: another owner's run id answers `404`.

## Gate your build on it

```bash theme={null}
#!/usr/bin/env bash
set -euo pipefail

BASE="https://chargerdojo.com"
AUTH="Authorization: Bearer $DOJO_KEY"

# Capture the HTTP status as well as the body. Without it, an expired wait (202), a rate
# limit (429) or an auth error (401) all look identical to a failing run, and you would
# tell your partner they are broken when the truth is that your script never got an answer.
RESPONSE=$(curl -sS -w '\n%{http_code}' -X POST "$BASE/api/v1/testing/runs?wait=55" \
  -H "$AUTH" \
  -H "Content-Type: application/json" \
  -d "{\"connectionId\": \"$CONNECTION_ID\"}")

STATUS=$(printf '%s' "$RESPONSE" | tail -n1)
BODY=$(printf '%s' "$RESPONSE" | sed '$d')

case "$STATUS" in
  200) ;;
  202) echo "Run did not finish within the wait. Poll it; do not treat this as a failure."
       exit 2 ;;
  *)   echo "Could not run conformance (HTTP $STATUS): $BODY"
       exit 2 ;;
esac

# Read the status before the counters. A run can end without ever being graded: restart the
# server and the run it was carrying is stamped `interrupted`, and such a run carries no summary
# at all, so `.data.summary.failed` is null rather than a number. Treating that as zero is how a
# killed run reads as a pass.
RUN_STATUS=$(printf '%s' "$BODY" | jq -r '.data.status')
case "$RUN_STATUS" in
  completed) ;;
  failed)
    # The run stopped early, but what it graded before it stopped is real. Treat those failures
    # as failures: they are your partner's, not ours.
    echo "Run stopped early: $(printf '%s' "$BODY" | jq -r '.data.error // "no reason given"')" ;;
  *)
    # Terminal without a verdict. `interrupted` means we restarted the server under it, which says
    # nothing about your partner, so it belongs with the network errors rather than with a
    # broken endpoint.
    echo "Run ended as '$RUN_STATUS' and was never graded. Run it again."
    exit 2 ;;
esac

FAILED=$(printf '%s' "$BODY" | jq '.data.summary.failed')
PASSED=$(printf '%s' "$BODY" | jq '.data.summary.passed // 0')
UNGRADED=$(printf '%s' "$BODY" | jq '.data.summary.unobservable // 0')
WITHHELD=$(printf '%s' "$BODY" | jq -r '.data.withheldModules // [] | join(", ")')

# A completed run always carries counters. If one ever does not, jq hands you `null`, and
# `[ null -gt 0 ]` is a bash error rather than a comparison, which falls through to the
# success path below and prints "compliant". Refuse instead of guessing.
if [ "$FAILED" = "null" ]; then
  echo "The run reported no results to read. Treat this as no answer, not as a pass."
  exit 2
fi

# How many checks were actually graded: passed plus failed, and nothing else. Zero of them is
# not a pass. A version that advertises no runnable suite, or a mistyped module name, ends as a
# run with every counter at zero, and "nothing failed" is vacuously true of a run that asked
# nothing.
if [ "$(( PASSED + FAILED ))" -eq 0 ]; then
  echo "The run graded no checks, so nothing is proved. Treat this as no answer, not a pass."
  exit 2
fi

if [ "$FAILED" -gt 0 ]; then
  echo "OCPI conformance: $FAILED required checks failed"
  exit 1
fi

# A run that stopped early proves its failures and nothing else. With none of them, it never
# finished asking, so the checks it did not reach are untested rather than clean. That belongs
# with "we did not get an answer", beside an interrupted run.
if [ "$RUN_STATUS" = "failed" ]; then
  echo "The run stopped before it finished, with nothing failing yet. That is not a pass. Run it again."
  exit 2
fi

# Zero failures is not a pass on its own: a module your plan does not cover contributes no
# failures because it never ran, and an unobservable check decided nothing because the response
# carried nothing to grade. Say what was not tested rather than calling it compliant.
if [ -n "$WITHHELD" ] || [ "$UNGRADED" -gt 0 ]; then
  echo "OCPI conformance: compliant on what ran ($PASSED checks); not tested: ${WITHHELD:-none} (+$UNGRADED ungraded)"
  exit 0
fi

echo "OCPI conformance: compliant"
```

Exit `1` means "your partner broke a rule". Exit `2` means "we did not get an answer".
Those are different problems and a build log should not conflate them, and an ungraded run
belongs in the second group: it says nothing about your partner.

**Gate on `failed`, not on warnings.** A warning means a recommendation was declined, and
a declined recommendation breaks no rule. If you fail your build on warnings you will be
fixing code that already works, which is the exact mistake this tool refuses to make on
your behalf. See [Read your report](/guide/read-your-report).

A packaged GitHub Action that runs this script with the same exit codes is built but not
published yet. Until it is, use the script above.

Store the key as a repository or organisation secret so the runner masks it. See
[Create an API key](/guide/create-an-api-key).

## Next

[Read your report](/guide/read-your-report).
