Submit Contact Form
Public contact form submission. Captcha-gated (active provider, admin-managed). Captures user-agent + IP server-side. Stores in ContactMessage and fires fire-and-forget admin notification. Hard 3/hour throttle.
POST /api/v1/public/contact โ ๐ Public ยท Rate limit: 3 req / hour ยท CaptchaGuard
Public contact form submission. Hard rate limit (3/hour) โ designed for legitimate use, not bulk submissions. Captcha-gated via the active captcha provider (admin-managed via external.captcha.active_provider). The server captures User-Agent + IP from the request headers, persists a ContactMessage row, and fires a fire-and-forget admin notification.
Captcha required: include captchaToken in the body (the token from the active captcha provider's widget on the client). The CaptchaGuard validates it server-side via the active provider's verify endpoint. Vendor identity stays server-side โ clients use whatever captcha widget the platform serves; no need to know which provider is active.
turnstileToken is deprecated: kept for 1-sprint backwards compatibility โ accepts the same value as captchaToken. New clients should use captchaToken only. The deprecated alias may be removed in a future iteration.
IP capture is privacy-aware: the server resolves the client IP with priority cf-connecting-ip style CDN header โ first entry of x-forwarded-for โ req.ip. The IP is stored on the ContactMessage row for support / abuse tracking but never exposed in any read endpoint. Same pattern as the bio analytics tracking endpoint.
Request
Body โ SubmitContactDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
name | string | optional | MaxLength(100) | Sender's name. Stored as null when omitted. |
email | string | โ | IsEmail(), lowercased + trimmed server-side | Sender's email |
subject | string | โ | MinLength(3), MaxLength(200) | Subject line |
message | string | โ | MinLength(10), MaxLength(5000) | Free-form message body |
captchaToken | string | conditional | IsString() | Captcha token from the active provider. Required by CaptchaGuard unless skip-captcha is enabled in admin config. |
turnstileToken | string | optional (deprecated) | IsString() | Deprecated alias for captchaToken โ accepted for 1 sprint backwards compat. |
No headers required (other than the implicit User-Agent which the server reads).
Response
200 OK
{
"success": true,
"data": {
"message": "Thank you for your message. We will respond shortly."
}
}The controller uses @HttpCode(HttpStatus.OK) (not the default 201 for POST). The service returns { message }; the platform's response interceptor wraps it in { success: true, data: ... }.
| Field | Type | Notes |
|---|---|---|
message | string | Localizable confirmation message โ render under the form |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | (DTO validation) | Missing required fields; email not a valid format; subject/message length out of range |
400 | (CaptchaGuard) | Missing or invalid captchaToken (specific code from the active provider) |
429 | (throttle) | Rate limit exceeded (3 req/hour) |
Side effects
- CaptchaGuard runs first โ verifies
captchaToken(orturnstileTokenlegacy alias) against the active provider's verify API. Failure โ 400. - Resolve client IP with priority:
cf-connecting-ipstyle CDN header โ first entry ofx-forwarded-forโreq.ip. prisma.contactMessage.create({ id: randomUUID(), name: input.name ?? null, email: input.email, subject, message, userAgent: req.headers['user-agent'] ?? null, ipAddress: <resolved IP> ?? null }).- Fire-and-forget admin notification โ
notificationService.send({ eventKey: 'contact_form_submission', userId: 'system', variables: { name (or 'Anonymous'), email, subject, message: <first 500 chars> } }). Failure is logged at error level but does NOT fail the request. - Audit log:
[contact] Contact form submitted: <email> โ <subject>. - Return
{ message: 'Thank you for your message. We will respond shortly.' }.
Code samples
curl -X POST https://api.bio.re/api/v1/public/contact \
-H 'Content-Type: application/json' \
-d '{
"name": "John Doe",
"email": "[email protected]",
"subject": "Feature Request",
"message": "I would like to suggest a new feature for the platform.",
"captchaToken": "<token-from-active-captcha-widget>"
}'type SubmitContactInput = {
name?: string;
email: string;
subject: string;
message: string;
captchaToken?: string;
};
async function submitContact(input: SubmitContactInput): Promise<string> {
const res = await fetch('https://api.bio.re/api/v1/public/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Contact submit failed'), {
code: json?.error?.code,
});
}
return json.data.message as string;
}import { useMutation } from '@tanstack/react-query';
export function useSubmitContact() {
return useMutation({
mutationFn: async (input: SubmitContactInput) => {
const res = await fetch('/api/v1/public/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Contact submit failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data.message as string;
},
});
}Try it
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
curl -X POST "https://loading/api/v1/public/contact" \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "subject": "Feature Request", "message": "I would like to suggest a new feature for the platform." }'Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/content/public-content.controller.ts | 400โ413 (submitContact) |
| DTO (request) | apps/api-core/src/modules/content/dto/submit-contact.dto.ts | 5โ36 (SubmitContactDto) |
| Service | apps/api-core/src/modules/content/content.service.ts | 901โ... (submitContact) |
| Captcha guard | apps/api-core/src/common/guards/captcha.guard.ts | CaptchaGuard (active provider, admin-managed via external.captcha.active_provider) |
| Notification pipeline | apps/api-core/src/modules/notification/notification.service.ts | send({ eventKey: 'contact_form_submission' }) (fire-and-forget) |
| Prisma model | packages/prisma/prisma/schema.prisma | ContactMessage (stores userAgent, ipAddress server-side; never exposed in reads) |
Get FAQ
Public list of FAQ groups with their published items. Optional locale filter. Group order is admin-set; items inside each group are admin-ordered too. Answers server-side sanitized.
Get Cookie Policy
Public cookie policy. Returns the 4 cookie categories (essential / functional / analytics / marketing) with localized name and description.