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

# Troubleshooting: Webinar registration and registrant lifecycle errors

> Every 4xx the webinar register endpoints and the join gate return — closed gate, duplicate email, full roster, missing required answer, approve over the seat cap, check-in of a non-approved registrant, and the 403 walk-in gate — with the state that fired it and the call that resolves it.

# Troubleshooting: Webinar registration and registrant lifecycle errors

A scheduled room's register lives at
`/api/v1/video/rooms-scheduled/:id/registration` (the form config) and
`/api/v1/video/rooms-scheduled/:id/registrants` (the roster). Every
rejection on those endpoints, and every refusal the join gate raises at
an unregistered arrival, is a deterministic 4xx: the same room state
rejects the same way every time, so the response tells you exactly which
setting or roster state to change. This page indexes the whole ladder.

All register responses share one envelope:

```json theme={null}
{
  "error": {
    "code": "VIDEO_REGISTRATION_REJECTED",
    "message": "This email is already registered for this room",
    "status": 409
  },
  "meta": {
    "request_id": "req_5c91e2aa",
    "timestamp": "2026-10-27T09:14:02Z"
  }
}
```

`error.code` names the class, `error.message` names the cause, and
`meta.request_id` is what support needs if you escalate.

## Which side is failing?

| You see it when | Surface | Codes to expect |
| - | - | - |
| You configure the form or manage the roster (PUT config, PATCH decision, check-in, DELETE) | Organizer writes | `VIDEO_REGISTRATION_REJECTED`, `VIDEO_REGISTRATION_INVALID`, `NOT_FOUND`, `VALIDATION_ERROR` |
| An attendee registers (POST registrants) | Attendee register | `VIDEO_REGISTRATION_REJECTED`, `VALIDATION_ERROR`, `NOT_FOUND` |
| An attendee joins (invite redeem or join-token mint) | Join gate | `VIDEO_REGISTRATION_REQUIRED`, `VIDEO_REGISTRATION_PENDING`, `VIDEO_REGISTRATION_DECLINED`, `VIDEO_REGISTRATION_BLOCKED`, `VIDEO_REGISTRATION_CAPACITY` |

Work the section that matches your call. If a 5xx such as
`VIDEO_REGISTRANT_CREATE_FAILED` shows up instead, the write never ran;
retry once, then escalate with the `request_id`.

## Decision checklist

1. **Read the gate as it stands.** `GET /:id/registration` returns
   `config` (`enabled`, `require_approval`, `require_registration`,
   `capacity`, `custom_fields`) beside the live roster and the
   attendance report. Trust that read over what the dashboard last
   showed you.
2. **Match the message.** Each row below names the state that fired it.
3. **Apply the fix, then resend the same request once.** These rejects
   are deterministic; if the same 4xx returns, the fix did not land.

## Register-time rejects (POST /:id/registrants)

| Response | Cause | Fix |
| - | - | - |
| 409 `Registration is not open for this room` | The register's `enabled` flag is false. | Re-open with `PUT /:id/registration` and body `{"enabled": true}`. Registrants already on the list keep their join tokens either way. |
| 400 `name is required` or `email is required` | The value is present but trims to empty. | Send a non-blank name (max 200 characters) and a non-blank email (max 254). |
| 409 `This email is already registered for this room` | The email is already on the roster; the match ignores case and surrounding spaces. | Do not re-POST. Work with the existing `vreg_` row: read it in the `GET` roster, decide it with `PATCH /:id/registrants/:registrantId`, or remove it with `DELETE` first if it is stale. |
| 409 `registration is full (max 2000 registrants)` | The roster already holds 2,000 rows, the hard per-room ceiling. | Remove stale rows with `DELETE /:id/registrants/:registrantId`, or run a second session. This is distinct from the approved-seat cap below, which waitlists instead of rejecting. |
| 400 `"Company" is required` | A required custom question has no answer; the message quotes the question's label. | Send the answer in `custom_answers`, keyed by the field key. If the question was added after the attendee registered, complete the answer on the registrant card in the webinar's registrant list instead of re-registering them. |
| 422 `VALIDATION_ERROR` | The body failed schema validation: a missing field, a string that is not an email, a value over its cap, or non-string answer values. | Read `error.details.issues` for the offending path and correct the body. |
| 404 `Video room '<id>' not found` | The id matches no scheduled room in this organization. | Check the room id, and that the room is a scheduled (webinar) room rather than an instant or persistent one. |

A register that succeeds past the approved-seat cap is not an error:
the attendee lands with `"status": "waitlisted"` and joins only after a
`PATCH` approve frees a seat.

## Decision-time rejects (PATCH /:id/registrants/:registrantId)

| Response | Cause | Fix |
| - | - | - |
| 409 `capacity is full (max N approved). Waitlist or raise the capacity first.` | The approve would push the approved count past `capacity`. | Free a seat first (`decline`, `block`, or `waitlist` an approved registrant) or raise the cap with `PUT /:id/registration` and `{"capacity": N}`; then approve. |
| 404 `Registrant 'vreg_…' not found` | The id is not on this room's roster: it was removed, or copied from another room. | Re-read the roster with `GET /:id/registration` and use a current id. |
| 422 `VALIDATION_ERROR` | `decision` is not one of `approve`, `decline`, `block`, `waitlist`. | Send one of the four values. |

`decline`, `block`, and `waitlist` never hit the cap. Only `approve`
does, and only when it moves a non-approved registrant to approved.

## Check-in rejects (POST /:id/registrants/:registrantId/check-in)

| Response | Cause | Fix |
| - | - | - |
| 409 `Only approved registrants can be checked in` | The registrant is `pending`, `waitlisted`, `declined`, or `blocked`. | Approve first (`PATCH` with `{"decision": "approve"}`), then check in. If the approve itself 409s, work the capacity row above. |
| 404 `Registrant 'vreg_…' not found` | As in the decision table. | Re-read the roster. |

A repeat check-in on an already-attended registrant succeeds and keeps
the original `attended_at`; the report counts one attendance.

## The walk-in gate: 403 at join

When `require_registration` (or `require_approval`) is on, the join gate
checks every arrival against the roster and refuses an unmatched joiner
with:

```json theme={null}
{
  "error": {
    "code": "VIDEO_REGISTRATION_REQUIRED",
    "message": "The host requires every participant to register for this webinar. Register first, then join with the email you registered with or your join code.",
    "status": 403
  }
}
```

Roster matches get their own refusal until they are approved and a seat
is free: `VIDEO_REGISTRATION_PENDING`, `VIDEO_REGISTRATION_DECLINED`,
`VIDEO_REGISTRATION_BLOCKED`, and `VIDEO_REGISTRATION_CAPACITY`.

A plain room with no register never returns these codes; a failed join
there is a join-token or invite refusal, covered in [Video room
lifecycle failures](https://docs.orbit.devotel.io/troubleshooting/video-room-lifecycle.md). The code
alone therefore tells you which door the joiner hit.

To see why the gate fired, read it back: `GET /:id/registration`
returns `config.require_registration`, `config.require_approval`, and
`config.enabled` exactly as the gate evaluates them. The gate arms only
while the register is enabled, and closing the register
(`{"enabled": false}`) returns unmatched joiners to open-guest
behaviour.

## Required questions that name the question

The register 400 (`"Company" is required`) quotes the question's label,
and the registrant card in the dashboard marks which questions are
required. Answers you save on the card sync to the roster on save, so a
registrant who signed up before you added a required question can be
completed there. Deleting and re-registering them instead mints a new
`join_token` and breaks the link they already hold.

## What NOT to do

* **Do not re-POST on a duplicate-email 409.** The roster keeps one row
  per email; the retry cannot win and never merges. PATCH the existing
  row.
* **Do not loop approve against a capacity 409.** Every approve returns
  the same 409 until a seat frees. Change the state once (free a seat
  or raise the cap), then approve.
* **Do not retry check-in on a pending or waitlisted registrant.** The
  409 holds until a successful approve; retrying changes nothing.
* **Do not delete-and-re-register to edit a registrant.** The new row
  mints a new `join_token`, invalidating the attendee's existing link.

## When to escalate

Escalate to support when a write still fails after the state named in
the fix column is confirmed by a fresh `GET /:id/registration`. Include
the room id, the registrant id (`vreg_…`) or attendee email, the exact
`error.code` and `error.message`, and `meta.request_id` from the failing
response.

## See also

* [Registration-gated video webinars](https://docs.orbit.devotel.io/guides/video-webinars.md) — opening
  the register and running the session.
* [The webinar registration gate](https://docs.orbit.devotel.io/guides/video-webinar-registration-gate.md)
  — what each joiner sees at the door.
* [Error Code Reference](https://docs.orbit.devotel.io/reference/error-codes.md) — the `VIDEO_*` family
  with HTTP statuses.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.