rCTF Docs
Overview

Challenges

Challenge listing, solve listing, flag submission, and dynamic-scoring webhook routes.

Challenge routes provide public challenge metadata, solve history, the legacy flag submission endpoint, and the dynamic-scoring webhook. Admin challenge mutation routes are documented in Admin.

For new clients, prefer V2 routes. V1 routes remain available for older clients. Flag submission still uses V1 because there is no V2 route for that action.

Scoring behavior#

Challenge point values come from the configured scoring provider for decay challenges. The public challenge list returns the current point value. It does not expose the configured points.min or points.max range. Challenge updates and solve changes trigger leaderboard recalculation.

dynamic challenges receive per-team scores over a webhook instead - see Submit dynamic scores for the wire format and Scoring for the operator-facing model.

Visibility behavior#

Regular users see challenges once those challenges are visible and released. Admin users with challsRead can read through the CTF start gate with the same public routes. Hidden and scheduled release rules still apply inside the challenge service.

GET Challenge list

GET /api/[v2,v1]/challs

Auth
Optional
Gate
Started (bypass challsRead)
Permissions
No extra permissions
Captcha
No captcha
Rate limit
No rate limit

Returns the challenge list visible to the current request. Regular users do not receive hidden challenges or challenges with a future releaseTime.

For new clients, prefer V2. It adds instancer metadata, admin bot input schemas, and the hasFlag field. V1 remains available for older clients and returns the original challenge fields.

Response fields

idstring
Challenge ID.
namestring
Display name.
descriptionstring
Markdown challenge description.
categorystring
Category name.
authorstring
Author display name.
files[].namestring
File name.
files[].urlstring
Download URL.
files[].sizenumber | null
File size in bytes.
pointsnumber
Current score after dynamic scoring.
solvesnumber
Current solve count.
sortWeightnumber | null
Optional ordering weight.
instancerLifetimenumber | null
Instance timeout in milliseconds when the challenge has instancer config and an instancer is enabled.
instancerExtendableboolean
false only when challenge config explicitly disables extension.
adminBotInputsRecord<string, { pattern: string, flags?: string }> | null | undefined
Participant input schema for admin-bot challenges.
hasFlagboolean
Whether the challenge has a flag configured.
scoringKind"decay" | "dynamic" | undefined
Scoring kind: decay, static, or dynamic.
yourScorenumber | undefined
Caller's current points for this challenge. Present when the user has a solve (or feed entry) for it.
yourPointDeltanumber | undefined
Caller's latest dynamic point delta for this challenge. Present for dynamic challenges when the caller had an entry in the latest feed tick.

Response fields

idstring
Challenge ID.
namestring
Display name.
descriptionstring
Markdown challenge description.
categorystring
Category name.
authorstring
Author display name.
files[].namestring
File name.
files[].urlstring
Download URL.
pointsnumber
Current score after dynamic scoring.
solvesnumber
Current solve count.
sortWeightnumber | null | undefined
Optional ordering weight.

GET Challenge solves

GET /api/[v2,v1]/challs/:id/solves

Auth
Optional
Gate
Started (bypass challsRead)
Permissions
No extra permissions
Captcha
No captcha
Rate limit
No rate limit

Returns a page of solve history for one challenge.

V2 includes solve avatars, country codes, division placements, blood index, and the authenticated user’s mySolvePosition when optional auth is present. V1 remains available for older clients and returns a smaller solve row.

Path parameters

id requiredstring
Challenge ID.

Query parameters

limit requirednumber
Integer >= 1. Maximum enforced by config.
offset requirednumber
Integer >= 0.

Response fields

solves[].idstring
Solve ID.
solves[].createdAtnumber
Unix timestamp in milliseconds.
solves[].userIdstring
Team ID.
solves[].userNamestring
Team display name.
solves[].userAvatarUrlstring | null
Avatar URL when set.
solves[].userCountryCodestring | null
ISO country code when set.
solves[].userStatusTextstring | null
Status text when set.
solves[].globalPlacenumber
Team's current global standing at solve time.
solves[].divisionstring
Team division at solve time.
solves[].divisionPlacenumber
Team's current divisional standing at solve time.
solves[].bloodIndexnumber | null
0, 1, or 2 for the first three solves. Otherwise null.
mySolvePositionnumber | null
Authenticated user's solve position. Present when optional auth is sent.

Response fields

solves[].idstring
Solve ID.
solves[].createdAtnumber
Unix timestamp in milliseconds.
solves[].userIdstring
Team ID.
solves[].userNamestring
Team display name.

POST Submit a flag

POST /api/v1/challs/:id/submit

Auth
Required
Gate
Started + Before end (bypass challsWrite)
Permissions
No extra permissions
Captcha
No captcha
Rate limit
Burst 5, refill window 25000 ms, scoped to user and challenge.

Submits a flag for the authenticated team. This route uses V1 because there is no V2 equivalent.

Rate limit conventions are documented under /api#rate-limits.

Path parameters

id requiredstring
Challenge ID.

Request body

flag requiredstring
Maximum length 1024.

A correct flag returns 200 goodFlag (no data) and records a solve for the team. The route records a submission audit row for both correct and incorrect attempts. Admins can read those rows through /api/v2/admin/submissions.

POST Submit dynamic scores

POST /api/v2/challs/:id/scores

Auth
Service
Gate
None
Permissions
No extra permissions
Captcha
No captcha
Rate limit
No rate limit

Publishes per-team scores for a dynamic challenge. The endpoint is authenticated through an HMAC signature derived from the challenge’s per-instance webhook secret (the scoring.source.secret value set on the challenge); no user auth token is involved. The operator guide for this endpoint, including the team-identifier patterns and the recommended publisher snippet, lives at Scoring.

Warning (No event-timing gate)

This route deliberately has neither onlyWhenStarted nor onlyWhenNotFinished. Backends can seed scores before startTime and any deliveries that land after endTime are still applied to the final tally. Cutting the feed off cleanly at end is the operator’s job.

Authentication#

HeaderValue
X-RCTF-TimestampUnix milliseconds at signing time. Must be within a five-minute skew window of the server clock.
X-RCTF-Signaturesha256= followed by the lowercase hex HMAC-SHA256 of ${timestamp}.${raw_body} using the challenge’s webhook secret.

Sign the raw request bytes, not the re-serialized JSON. Any difference between the signed bytes and the bytes on the wire (whitespace, key ordering, middleware re-serialization) will fail verification.

Unknown challenge IDs, non-dynamic challenges, mismatched signatures, and timestamps outside the skew window all return the same 401 badSignature so the endpoint can’t be probed for which challenges accept a feed.

Path parameters

id requiredstring
Challenge ID.

Request body

scores requiredobject[]
Request body field for scores.

Each entry is an absolute setter for that team and challenge. A points value of 0 clears the team’s score for the challenge, negative values are accepted, and teams omitted from the payload keep whatever score they already had. Entries for team IDs that don’t exist are silently dropped - the response counts only reflect rows that actually landed.

Response fields

insertednumber
Returned inserted value.
updatednumber
Returned updated value.
deletednumber
Returned deleted value.

Successful pushes also fire the leaderboard:force-update Redis pub/sub message, so a split frontend + leaderboard deployment still picks up the new scores on the worker’s next tick.

Esc

Start typing to search the docs.