Authenticator
Apps under test ask for MFA. Enrolment shows a QR code and a setup key, and every later sign-in asks for a code from an authenticator app. A test needs what a person keeps on their phone: something that holds the secret and gives the code of the moment. Waridex keeps the secret the test hands it at enrolment and serves the codes.
An authenticator is one secret — what an authenticator app calls an account, and what the dashboard calls an account. It belongs to a workspace, carries tags and may be given an expiry, as an inbox is.
Enrolling
Section titled “Enrolling”Pass the otpauth:// URI behind the enrolment QR code, which is what the app under test shows beside it:
curl -sS --fail-with-body "$API/v1/authenticators" \ -H "Authorization: Bearer $WARIDEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"otpauthUri": "otpauth://totp/Acme:sara@acme.io?secret=JBSWY3DPEHPK3PXP&issuer=Acme", "tags": ["run-4711"]}'Or pass the setup key the enrolment screen prints, with the parameters it names:
{ "secret": "JBSW Y3DP EHPK 3PXP", "issuer": "Acme", "account": "sara@acme.io", "digits": 6, "period": 30 }POST /v1/authenticators needs the authenticator:use scope. Exactly one of otpauthUri and secret is required.
With a URI the secret and its parameters come from the URI, so passing secret, issuer, account, algorithm,
digits or period beside it is 400 request.invalid; tags and expiresInSeconds
are yours either way.
| Field | Meaning |
|---|---|
otpauthUri | the Key URI behind the QR code. Its label is split at the first : into issuer and account, the issuer parameter winning over the label’s prefix, as authenticator apps do; unknown parameters are ignored. One that does not parse, or has no secret, is 400 authenticator.invalid_uri |
secret | the setup key: Base32, ignoring case, spaces and hyphens, padding optional, decoding to 10 to 64 bytes, else 400 authenticator.invalid_secret |
issuer, account | optional labels of up to 100 characters each. With both empty, the list shows the id |
algorithm | SHA1 (the default), SHA256 or SHA512 |
digits | 6 (the default) or 8 |
period | 30 seconds (the default) or 60 |
tags | up to ten, as on an inbox: typically the CI run. See tags and the per-run purge |
expiresInSeconds | 180 seconds to one year. When it passes, the authenticator stops serving codes at once and the hourly job deletes it. Without it, it lives until you delete it: a long-lived staging user keeps its secret |
Waridex implements TOTP (RFC 6238) and nothing else. Another algorithm, digit
count or period, and an otpauth://hotp/ URI, are
400 authenticator.unsupported naming the parameter: nothing is guessed or
rounded.
The response is the authenticator with code, the enrolment code, so the test can finish the enrolment screen in the
same step. A create begun in the last seconds of a window holds for the next one before it answers — at most five
seconds, since a new authenticator has no window of its own to skip — so the code it returns has time left on it; like
a code request, it can answer 409 authenticator.window_lapsed instead.
{ "id": "0199a3c4-7e11-7c42-8b05-9a1f2d3c4b5e", "issuer": "Acme", "account": "sara@acme.io", "algorithm": "SHA1", "digits": 6, "period": 30, "tags": ["run-4711"], "createdAt": "2026-09-28T09:00:00.000+00:00", "code": { "code": "844716", "validFrom": "2026-09-28T09:00:00+00:00", "validTo": "2026-09-28T09:00:30+00:00", "serverTime": "2026-09-28T09:00:04.118+00:00", "waitedMs": 0 }}The secret never leaves the API. No response carries it, nor the URI nor a QR code, and there is no export and no “show secret”: a test that needs the secret again enrols a new authenticator.
Waridex protects it with a key of its own, so a create and a code request both answer
503 authenticator.secret_unavailable while that key cannot be read —
the one failure here that is neither the caller’s doing nor worth retrying at once.
Asking for a code
Section titled “Asking for a code”curl -sS --fail-with-body "$API/v1/authenticators/$AUTHENTICATOR_ID/codes" \ -H "Authorization: Bearer $WARIDEX_API_KEY" \ -H "Content-Type: application/json" \ -d '{}'POST /v1/authenticators/{id}/codes serves a code; it is a POST because serving one changes state, so no client,
proxy or prefetch may repeat it on its own. The body may be left out, and both fields default:
| Field | Default | Meaning |
|---|---|---|
unused | true | serve a window no earlier request served. Two parallel requests for one authenticator therefore never get the same window |
minValiditySeconds | 5 | the window must have at least this long left when it is served, from 0 to 15 |
The answer is the code with its window:
{ "code": "651264", "validFrom": "2026-09-28T09:00:30+00:00", "validTo": "2026-09-28T09:01:00+00:00", "serverTime": "2026-09-28T09:00:31.204+00:00", "waitedMs": 26118}A window that has not begun is held for — the call waits until it starts, one period at most, and waitedMs says how
long it was held. So a code request straight after an enrolment returns the next window’s code after a hold: an app
that refuses a code already used in its window, as RFC 6238 recommends, accepts it. With unused=false the current
window is served whether or not it was served before, which skips that wait. It does not skip minValiditySeconds,
though: asked in the last seconds of a window, the call still moves to the next one and holds for it, so a test that
must never wait sends minValiditySeconds: 0 as well.
serverTime is the API’s clock when the code was served. The API’s clock is the authenticator’s clock, so a test
whose code was refused can compare the two.
- A window a concurrent
unusedrequest claimed first is409 authenticator.window_claimed. The same answer comes at once, without a hold, when the windows already served are ahead of the API’s clock — it was set back, or a database was restored beside a host behind it — and thenRetry-Aftercan span several periods, not just the next window. - A window that ran out while the request waited for its turn is
409 authenticator.window_lapsed.
Both carry Retry-After with the seconds until the next window, and asking again is the whole answer: a caller
retries after it, and the SDKs will do so within their timeout once they ship.
The code log
Section titled “The code log”Every code served is one row: the enrolment code of a create, and every code request, the dashboard’s included.
GET /v1/authenticators/codes lists them newest first, each with its authenticator’s issuer and account, the code,
the window, the kind and who asked, filtered by authenticator, since, apiKey or member.
Nothing else is recorded: adding, listing, reading and deleting authenticators write no row. Codes are stored as served — a served code is past or current, and whoever can read the log can ask for a code anyway — so the log shows which code a refused sign-in was given, and in which window. The rows follow the workspace’s retention and go with their authenticator.
Listing, deleting and the per-run purge
Section titled “Listing, deleting and the per-run purge”| Call | Returns |
|---|---|
GET /v1/authenticators | the workspace’s authenticators, newest first; tag, q (issuer or account contains) and paging with limit and offset, and the API’s serverTime |
GET /v1/authenticators/{id} | one authenticator |
DELETE /v1/authenticators/{id} | deletes it with its tags and log rows |
DELETE /v1/authenticators?tag=run-4711 | deletes every authenticator carrying the tag and returns the count; a tag nobody carries is 200 with zero, and no tag is 400 tag.required |
Every route needs authenticator:use. Tag the authenticators a CI run enrols and purge them in the same teardown as
the run’s inboxes; an expired one stops serving codes at once, so an expiry is a second net under a teardown that
never ran.
In the dashboard
Section titled “In the dashboard”The Authenticator page lists a workspace’s accounts, adds one from a QR code image, a URI or a setup key, and shows a row’s current code on request. The code counts down and hides when its window ends, and nothing polls: every code served is a log row, and a page left open would fill the log.

Plans and scopes
Section titled “Plans and scopes”Free holds three authenticators; Team and Business hold any number. An authenticator counts while it exists, so
deleting one or letting it expire frees its place, and past the number a create is
403 authenticator.limit_reached with limit and tier. A downgrade keeps
every existing authenticator serving codes and refuses new ones past the number.
authenticator:use is one scope for the whole channel, since a test that enrols a user needs every verb. It can go on
an API key, and the Member and Admin workspace roles hold it; a Viewer
does not, because reading a code is signing in as the enrolled user.