# Submit one step of a group for one of your students

- Endpoint: `POST /v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/step/{stepId}/submit`
- Production: `https://api.main-team.org/v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/step/{stepId}/submit`
- Sandbox: `https://apisnd.main-team.org/v1/{organizationId}/group-challenge/{challengeId}/group/{groupId}/step/{stepId}/submit`
- Operation: `submitGroupChallengeStep` (Group challenges)
- Authentication: `Authorization: Bearer <token>`, a short-lived token you sign with your API key and secret
- Permission: `group-challenge/submit:$org:$ID`

## Description

Submits the open step of a group, for one of your students who is a member of it, exactly as the student would in the panel: the files uploaded for the step are submitted with it, and the next step opens. The members upload the work in the panel or the app; this API does not upload. Read the group first: a step’s `canSubmit` says whether this would be accepted now.

The body names the student you act for: `{ "studentId": "<studentId>" }`. The group’s history records the submit as your account (`partner`) acting for them.

**Checks, in order.** The first that fails decides the answer, and nothing is written for any of them.

1. The student is yours (`404`), and has signed in to this organization once (`409`).
2. The challenge exists and is published or closed (`404`).
3. The group belongs to the challenge and the student is an active member of it (`404`).
4. The step has not been submitted already. If it has, the answer is `200` with `changed: false` and "Step already submitted.", and nothing changes, so a retry after a timeout is safe.
5. The challenge is published (`409`, `challenge_closed`) and now is between `windowStart` and `windowEnd` (`409`, `window_closed`).
6. The teacher has confirmed the group (`409`, `payment_pending` or `group_not_confirmed`).
7. The group has this step (`404`).
8. The step is open, not `locked` (`409`, `step_locked`).
9. At least one file has been uploaded for it (`409`, `step_empty`). Do not retry this one in a loop: a member uploads the work first.

Every `409` carries `error.details.reason`, one of the codes above; branch on it, not on the message.

**Side effects.** The step becomes `submitted`, and its uploaded files with it. An upload still running for the step is stopped. The next step, if there is one, opens. An entry is added to the group’s history, and a second one when the next step opens.

**Busy.** A group is changed by one request at a time. If someone is changing it at that moment the answer is `503` with `Retry-After: 1` and `details.reason: busy`, and nothing was written: send the same request again.

## Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `organizationId` | path | yes | string | The organization’s `_id`: 24 hexadecimal digits, as `listOrganizations` (`GET /v1/organization`) lists it. |
| `challengeId` | path | yes | string | The group challenge’s `_id`, from `listGroupChallenges`. |
| `groupId` | path | yes | string | The group’s `_id`, from `listGroupChallengeGroups` or a student’s `group`. |
| `stepId` | path | yes | string | The step’s `_id`, from the group’s `steps` (`getGroupChallengeGroup`) or the challenge’s. |

## Request body

Required fields: `studentId`.

```json
{
  "studentId": "6650a1b2c3d4e5f6a7b8c9d0"
}
```

## Response

`200` Success: `message` is "Step submitted.". Or `message` is "Step already submitted.": The step had already been submitted, by anyone. `changed` is `false` and nothing changed.

```json
{
  "data": {
    "changed": true,
    "groupId": "6650a1b2c3d4e5f6a7b8c9ed",
    "groupStatus": "finalized",
    "nextStep": {
      "_id": "6650a1b2c3d4e5f6a7b8c9f1",
      "order": 2,
      "state": "open"
    },
    "step": {
      "_id": "6650a1b2c3d4e5f6a7b8c9ee",
      "order": 1,
      "state": "submitted",
      "submittedAt": "2026-10-20T16:02:11.000Z"
    }
  },
  "message": "Step submitted.",
  "success": true
}
```

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | The body is not valid JSON, breaks a field’s rules, or has a field this operation does not accept ("property <name> should not exist"). |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `challengeId` is not 24 hexadecimal digits. |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `groupId` is not 24 hexadecimal digits. |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `stepId` is not 24 hexadecimal digits. |
| 401 | [`unauthorized`](https://hub.main-team.org/api/errors#unauthorized) | The token is missing or malformed, is not signed with your account’s `apiSecret`, breaks the `iat` and `exp` rules, has expired or been revoked, or its account is not active. All of these answer the same. |
| 403 | [`forbidden`](https://hub.main-team.org/api/errors#forbidden) | The token is valid, but no role on your account allows `group-challenge/submit` on the organization in the path, or a role denies it. |
| 404 | [`not_found`](https://hub.main-team.org/api/errors#not_found) | `organizationId` is not the `_id` of an organization. |
| 404 | [`not_found`](https://hub.main-team.org/api/errors#not_found) | Group challenges are not switched on for this organization. The answer is the one an unknown path gets; nothing was read. |
| 404 | [`not_found`](https://hub.main-team.org/api/errors#not_found) | No student of yours has this id: it matches nobody, or another account registered the student. |
| 404 | [`not_found`](https://hub.main-team.org/api/errors#not_found) | No group challenge of this organization has this id, or it is not published: a draft, archived or deleted challenge answers the same. |
| 404 | [`not_found`](https://hub.main-team.org/api/errors#not_found) | No group of this challenge has this id with the student as an active member: a missing group, a deleted one and one the student is not in get the same answer. |
| 404 | [`not_found`](https://hub.main-team.org/api/errors#not_found) | The group has no step with this id. A group has its steps once the teacher confirms it. |
| 409 | [`conflict`](https://hub.main-team.org/api/errors#conflict) | The student has never signed in to this organization, so it holds no record of them yet. Send them a sign-in link (`createSigninLink`), and try again once they have used it. |
| 409 | [`conflict`](https://hub.main-team.org/api/errors#conflict) | The organizers have closed the challenge (`status: closed`). |
| 409 | [`conflict`](https://hub.main-team.org/api/errors#conflict) | Now is before `windowStart` or after `windowEnd`. `details` carries both. |
| 409 | [`conflict`](https://hub.main-team.org/api/errors#conflict) | The teacher is still preparing the group (`status: awaiting_payment`). |
| 409 | [`conflict`](https://hub.main-team.org/api/errors#conflict) | The teacher has not confirmed the group yet (`status: draft`), so it has no steps. |
| 409 | [`conflict`](https://hub.main-team.org/api/errors#conflict) | The step is still locked: the steps before it are not all submitted. |
| 409 | [`conflict`](https://hub.main-team.org/api/errors#conflict) | Nothing has been uploaded for the step yet. A member uploads the work in the panel or the app first; retrying does not help. |
| 413 | [`payload_too_large`](https://hub.main-team.org/api/errors#payload_too_large) | The body is larger than 100 kB. |
| 415 | [`unsupported_media_type`](https://hub.main-team.org/api/errors#unsupported_media_type) | The body declares a charset that is not a UTF one (send UTF-8), or a `Content-Encoding` other than gzip, deflate or br. |
| 429 | [`too_many_requests`](https://hub.main-team.org/api/errors#too_many_requests) | Your account has made more than 100 requests to this operation in the current 60-second window. Wait the seconds in `Retry-After` before sending again. |
| 500 | [`internal_error`](https://hub.main-team.org/api/errors#internal_error) | Something failed on our side. Retry later, and quote `request_id` if it goes on. |
| 503 | [`service_unavailable`](https://hub.main-team.org/api/errors#service_unavailable) | Someone else — a member in the panel or the app, or another request of yours — is changing the group at this moment. Nothing was written. Wait the second in `Retry-After` and send the same request again. |

## Code samples

### curl

```bash
# $TOKEN: a short-lived token you minted with your API key and secret
curl -sS -X POST 'https://api.main-team.org/v1/<organizationId>/group-challenge/<challengeId>/group/<groupId>/step/<stepId>/submit' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  --data-binary @- <<'JSON'
{
  "studentId": "6650a1b2c3d4e5f6a7b8c9d0"
}
JSON
```

### Node.js

```js
const token = process.env.TOKEN; // a short-lived token you minted with your API key and secret

const res = await fetch('https://api.main-team.org/v1/<organizationId>/group-challenge/<challengeId>/group/<groupId>/step/<stepId>/submit', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "studentId": "6650a1b2c3d4e5f6a7b8c9d0"
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
console.log(body.data);
```

### PHP

```php
<?php
$token = getenv('TOKEN'); // a short-lived token you minted with your API key and secret

$ch = curl_init('https://api.main-team.org/v1/<organizationId>/group-challenge/<challengeId>/group/<groupId>/step/<stepId>/submit');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . $token,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'studentId' => '6650a1b2c3d4e5f6a7b8c9d0',
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
if ($response === false) {
    throw new RuntimeException(curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$body = json_decode($response, true);
if ($status >= 400) {
    $error = $body['error'];
    throw new RuntimeException("$status {$error['code']}: {$error['message']}");
}
print_r($body['data']);
```
