An official website of the United States GovernmentUnclassified
Seal of the Department of the NavyDepartment of the NavyProject ROUNDTABLE Docs
API reference

Portal API

Public feeds, industry registration, profiles, submissions, and private file access

Portal routes serve industry users and public discovery. A signed-in industry session carries organizationId; every private read and write is scoped to that value. Public announcement and call feeds intentionally have no session requirement.

GET /api/portal/announcements

Auth: Public. Success: 200.

curl http://localhost:3000/api/portal/announcements

Example shape:

{"data":[{"id":"clx-announcement","title":"ROUNDTABLE pilot update","body":"Industry engagement dates are available.","published":true,"createdAt":"2026-01-15T12:00:00.000Z","author":{"name":"CDR Dana Whitfield"}}]}

The route returns published announcements ordered newest first. There are no request fields. Unexpected database failures return { "error": "An error occurred." } with 500.

GET /api/portal/calls

Auth: Public. Success: 200.

curl http://localhost:3000/api/portal/calls

Example shape:

{"data":[{"id":"clx-call","title":"Maritime AI for Predictive Maintenance","description":"Share approaches for naval maintenance analytics.","techTags":"ai/ml,predictive maintenance","status":"OPEN","closesAt":"2026-04-30T00:00:00.000Z"}]}

The route selects open calls and sorts them by deadline using sortCallsByDeadline. A database failure returns 500. The feed does not let a caller create, close, or edit a call.

POST /api/portal/register

Auth: Public. Success: 201. Rate limit: five attempts per IP per hour.

FieldTypeRequired?Constraints/default
orgNamestringYesTrimmed, 2–255.
orgTypeenumYesCOMPANY, ACADEMIA, FFRDC, UARC, FOREIGN_PARTNER, OTHER.
cageCodestringNoExactly 5 alphanumeric characters, or empty string.
ueistringNoTrimmed, max 20, or empty string.
dunsNumberstringNoExactly 9 digits, or empty string.
naicsCodesstringNoComma/space-separated six-digit codes, at most 10, or empty string.
addressLine1 / addressLine2stringNoMax 255, or empty string.
citystringNoMax 100, or empty string.
statestringNoMax 50, or empty string.
postalCodestringNo3–20 characters (letters, digits, space, hyphen), or empty string.
setAsidesenum arrayNoUp to 6 of SMALL_BUSINESS, EIGHT_A, WOSB, EDWOSB, SDVOSB, HUBZONE; defaults [].
pocName / pocEmail / pocPhonestringNoName max 255; valid email; phone 7–25 permitted characters.
contractsPocName / contractsPocEmail / contractsPocPhonestringNoSame constraints as the primary POC fields.
employeeCountdigit stringNoWhole number up to 2,000,000.
annualRevenueUsddigit stringNoWhole dollars, up to 12 digits.
businessStartYeardigit stringNo1800 through the current year.
samRegistrationStatusenumNoACTIVE, PENDING, EXPIRED, NOT_REGISTERED.
samExpirationDatedate stringNoYYYY-MM-DD.
websiteURL stringNoMax 255, or empty string.
descriptionstringNoMax 2,000, or empty string.
techTagsstringNoMax 500, or empty string.
contactNamestringYesTrimmed, 2–255.
emailemail stringYesLowercased, max 255.
passwordstringYes14–128; uppercase, lowercase, digit, special character.

There is no smallBusiness field: the stored smallBusiness flag is derived — it is true when setAsides contains any SBA set-aside category.

curl -X POST http://localhost:3000/api/portal/register \
  -H 'content-type: application/json' \
  -d '{"orgName":"Pelagic Dynamics","orgType":"COMPANY","setAsides":["SMALL_BUSINESS"],"naicsCodes":"334511","city":"San Diego","state":"CA","description":"Autonomous undersea vehicles","techTags":"uuv,autonomy,acoustics","contactName":"Pelagic Demo Contact","email":"demo-pelagic@example.com","password":"Roundtable2026!Demo"}'

Success:

{"data":{"organizationId":"clx-organization"}}

The transaction creates an organization and first INDUSTRY user (with industryRole: INDUSTRY_ADMIN), hashes the password, audits the registration, and returns the organization ID. Errors: 400 invalid data, 409 duplicate organization/email, 429 rate limit, 500 unexpected failure.

GET /api/portal/profile

Auth: INDUSTRY. Success: 200.

curl http://localhost:3000/api/portal/profile -H 'cookie: next-auth.session-token=SESSION'

Example shape:

{"data":{"id":"clx-org","name":"Pelagic Dynamics","orgType":"COMPANY","status":"ACTIVE","cageCode":null,"uei":null,"dunsNumber":null,"naicsCodes":["334511"],"setAsides":["SMALL_BUSINESS"],"smallBusiness":true,"nontraditional":true,"website":null,"addressLine1":null,"addressLine2":null,"city":"San Diego","state":"CA","postalCode":null,"country":"USA","pocName":null,"pocEmail":null,"pocPhone":null,"contractsPocName":null,"contractsPocEmail":null,"contractsPocPhone":null,"employeeCount":null,"annualRevenueUsd":null,"businessStartYear":null,"samRegistrationStatus":null,"samExpirationDate":null,"description":"Autonomous undersea vehicles","techTags":"uuv,autonomy,acoustics"}}

No request fields. 401 means no industry session; 404 means the session has no organization or the organization cannot be found; 500 is unexpected failure.

PUT /api/portal/profile

Auth: INDUSTRY with industryRole: INDUSTRY_ADMIN; updates only the session user's organization. An ordinary INDUSTRY_USER receives 403.

FieldTypeRequired?Constraints
orgTypeenumYesSix OrgType values.
cageCodestringNoFive alphanumeric characters or empty.
ueistringNoMax 20 or empty.
dunsNumberstringNoNine digits or empty.
naicsCodesstringNoUp to 10 six-digit codes or empty.
addressLine1 / addressLine2 / postalCodestringNoSame constraints as registration.
setAsidesenum arrayNoUp to 6 set-aside values; the stored smallBusiness flag is re-derived from this list on every save.
POC and contracts-POC fieldsstringNoSame constraints as registration.
employeeCount / annualRevenueUsd / businessStartYeardigit stringNoSame constraints as registration.
samRegistrationStatus / samExpirationDateenum / dateNoSame constraints as registration.
websiteURL stringNoMax 255 or empty.
citystringNoMax 100 or empty.
statestringNoMax 50 or empty.
descriptionstringNoMax 2,000 or empty.
techTagsstringNoMax 500 or empty.
curl -X PUT http://localhost:3000/api/portal/profile \
  -H 'content-type: application/json' -H 'cookie: next-auth.session-token=SESSION' \
  -d '{"orgType":"COMPANY","cageCode":"","uei":"","setAsides":["SMALL_BUSINESS"],"naicsCodes":"334511","website":"","city":"San Diego","state":"CA","description":"Updated UUV capability","techTags":"uuv,autonomy"}'

Success returns { "data": <updated organization> }. Errors: 401, 400 invalid profile data, 404 missing organization, 500. The route normalizes tags and audits the update.

GET /api/portal/submissions

Auth: INDUSTRY; own organization only.

curl http://localhost:3000/api/portal/submissions -H 'cookie: next-auth.session-token=SESSION'

Example shape:

{"data":[{"id":"clx-submission","type":"WHITE_PAPER","status":"ROUTED","title":"Autonomous UUV navigation","summary":"...","fileName":"uuv-paper.pdf","techTags":"uuv,autonomy","callId":null,"createdAt":"2026-02-01T12:00:00.000Z","updatedAt":"2026-02-01T12:00:01.000Z"}]}

No request fields. Errors: 401 or 500.

POST /api/portal/submissions

Auth: INDUSTRY. Content type: multipart/form-data. The text fields are validated with the submission schema.

FieldTypeRequired?Constraints
typeenumYesCAPABILITY_STATEMENT, WHITE_PAPER, PITCH_MATERIAL, EVENT_SUMMARY, PROFILE_UPDATE.
titlestringYesTrimmed, 3–255.
summarystringYesTrimmed, 10–5,000.
techTagsstringNoMax 500 or empty.
callIdstringNoMax 64 or empty; referenced call must be eligible.
filefileNoPDF/DOCX/PPTX/TXT, max 15 MB, valid magic bytes.
curl -X POST http://localhost:3000/api/portal/submissions \
  -H 'cookie: next-auth.session-token=SESSION' \
  -F 'type=WHITE_PAPER' -F 'title=Autonomous UUV navigation' \
  -F 'summary=Pelagic Dynamics describes an acoustic navigation approach for UUV operations.' \
  -F 'techTags=uuv,autonomy,acoustics' -F 'callId=clx-call' \
  -F 'file=@uuv-paper.pdf'

Success is 201 with { "data": { "id": "clx-submission" } }. Errors: 401, 400 invalid fields, invalid file type, oversize, bad magic bytes, invalid call, 413 when the request body exceeds 15 MB plus a 64 KB field allowance (checked against Content-Length and again by a streaming byte counter, before the multipart body is parsed), 429 above twenty submissions per user per hour, 500 storage/database failure. The route creates the row, calls routeSubmission, writes matches/notifications/audit, and never returns a public object URL.

GET /api/portal/submissions/[id]/file

Auth: Any government role, or an INDUSTRY user of the owning organization. Success: 200 file stream.

curl -OJ http://localhost:3000/api/portal/submissions/clx-submission/file \
  -H 'cookie: next-auth.session-token=SESSION'

The route checks session, submission ownership, file metadata, and S3 retrieval. It returns 401 without a session, 404 for no record/no file/storage not-found, 403 for another organization's record, and 500 for an unexpected storage error.

PATCH /api/portal/users/[id]

Auth: INDUSTRY with industryRole: INDUSTRY_ADMIN; the target must be an INDUSTRY user of the same organization.

FieldTypeRequired?Constraints
industryRoleenumYesINDUSTRY_ADMIN or INDUSTRY_USER.
curl -X PATCH http://localhost:3000/api/portal/users/clx-user \
  -H 'content-type: application/json' -H 'cookie: next-auth.session-token=SESSION' \
  -d '{"industryRole":"INDUSTRY_ADMIN"}'

Success returns { "data": { "id": "clx-user", "email": "...", "industryRole": "INDUSTRY_ADMIN" } }. Two guarded cases return 400 with a generic message and write an authorization_failure audit event: self-demotion, and demoting the organization's last administrator (checked inside a serializable transaction). Other errors: 400 invalid body, 401, 403 non-admin or no organization, 404 target outside the organization, 500. No session bump is needed — the JWT callback refreshes industryRole from the database.

Portal endpoint decision table

EndpointPublic/sessionMain write or readAudit or side effect
GET /api/portal/announcementsPublicPublished feed readNone.
GET /api/portal/callsPublicOpen call feed readNone.
POST /api/portal/registerPublicOrganization and user createRegistration audit and rate-limit events.
GET /api/portal/profileIndustryOwn organization readNone.
PUT /api/portal/profileIndustry (organization admin)Own organization updateProfile-update audit.
PATCH /api/portal/users/[id]Industry (organization admin)Same-organization role changeRole-change audit; last-admin and self-demotion guards.
GET /api/portal/submissionsIndustryOwn submissions readNone.
POST /api/portal/submissionsIndustrySubmission and optional object createSubmission audit, matches, notifications.
GET /api/portal/submissions/[id]/fileAny government role or industry ownerPrivate object readNo public URL creation.

Why the API repeats ownership checks

The browser can send any submission ID or organization ID. The server does not assume a link came from a trusted page. It obtains the organization ID from the session, applies it to Prisma's where clause, and only then serializes data. This is why a UI-only “My submissions” filter is not sufficient security.

Multipart debugging

Inspect the request as multipart rather than JSON. The route reads type, title, summary, techTags, and callId from FormData, then reads a file part. A request with Content-Type: application/json cannot supply a binary file to this handler. A request with a zero-byte file is treated as no file; a nonempty file must pass all validators.

Portal request checklist

Before sending a submission request, confirm all of the following:

  • The session is an industry session and belongs to the intended organization.
  • The request is multipart/form-data; do not manually set a boundary header.
  • type is an exact SubmissionType enum value.
  • title and summary satisfy their trim and length constraints.
  • techTags is a comma-separated string rather than a JSON array.
  • callId, when present, identifies an open call.
  • The file extension matches the file signature and the file is no larger than 15 MB.

Example response interpretation

{
  "data": {
    "id": "cmexample-submission"
  }
}

The short response is intentional. The client can fetch the newly created row through the authenticated submissions list, where organization scoping is re-applied. It should not assume the returned ID grants access to a government detail route or to another organization's records.

Failure triage

SymptomLikely statusInterpretation
No session401Sign in or inspect cookie/origin configuration.
Government account posts403Use the industry portal account.
Missing title/file field400Inspect multipart names and Zod constraints.
Closed call ID400Choose an open call or omit callId.
Invalid magic bytes400The content is not the claimed supported file type.
Storage failure500Check S3/MinIO configuration and server logs; do not make the bucket public.

Status transitions

Creation starts at SUBMITTED. A routing match moves it to ROUTED; a government reviewer may later move it to REVIEWED; archival is a separate lifecycle decision. A failure in routing is logged but does not roll back the already-created submission, so operators can use reroute later.