Add Bio Link
Append a link to the bio page. Validates URL + scheduling, runs title through moderation, blocks manual social links when DM is active, auto-detects embed type, and respects an admin-managed max-links cap.
POST /api/v1/creators/:creatorId/links — 🔑 Bearer · Rate limit: 30 req / hour
Appends a BioLink to the creator's BioPage. Validates the URL (http(s):// only, javascript: blocked), strips HTML from the title, runs the title through trust-safety moderation, auto-detects embed type (YouTube / Spotify / TikTok / SoundCloud / Twitch / Apple Music) when not explicitly supplied, and respects the admin-managed bio.max_links cap with a row-locked count check.
Manual social links require DM-off. When isSocial: true AND the creator has dmActive: true, the request is rejected with 400 social_dm_active. The platform's design choice: while DM is active, social presence must come from OAuth-verified SocialAccount rows (via POST /creators/social/connect), not manual links — so fan trust signals stay tied to platform-verified ownership.
Embed auto-detection runs only when embedType is not supplied. Detected types: YOUTUBE (with videoId in meta), SPOTIFY (with contentType + contentId), TIKTOK, SOUNDCLOUD, TWITCH, APPLE_MUSIC. Custom embeds via explicit embedType: 'CUSTOM' + embedMeta.
Request
Path parameters
| Param | Type | Validation | Notes |
|---|---|---|---|
creatorId | string (UUID) | ParseUUIDPipe | Must match the bearer's CreatorProfile.id (otherwise 403) |
Body — CreateBioLinkDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
title | string | ✓ | MaxLength(100) | HTML-stripped server-side, then moderation-checked |
url | string | ✓ | IsUrl() | Server validates scheme — javascript: rejected as creator.links.invalid_url |
icon | string | optional | MaxLength(50) | Free-form icon identifier (your design system) |
sortOrder | number | optional | IsInt, Min(0), Max(1000) | Defaults to current link count (appended at end) |
active | boolean | optional | — | Default true |
embedType | enum | optional | IsIn(['YOUTUBE','SPOTIFY','TIKTOK','SOUNDCLOUD','TWITCH','APPLE_MUSIC','CUSTOM']) | Skipped → server auto-detects from URL |
embedMeta | object | optional | IsObject | Free-form, e.g. { videoId: '...' } |
scheduledStart | string (ISO 8601) | optional | IsDateString | Schedule visibility window start |
scheduledEnd | string (ISO 8601) | optional | IsDateString | Must be after scheduledStart |
isSocial | boolean | optional | — | When true, DM must be off and platform is required |
platform | string | conditional | MaxLength(30) | Required when isSocial: true. Whitelist: instagram, x, youtube, tiktok, github, linkedin, facebook, kick, twitch, snapchat, threads, pinterest, discord |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | JWT from POST /auth/login |
Response
201 Created — ApiResponseOf<AddLinkResponseDto>
{
"success": true,
"data": {
"id": "l1a2b3c4-d5e6-7890-abcd-ef1234567890"
}
}| Field | Type | Notes |
|---|---|---|
id | string (UUID) | The new BioLink.id — pass to PATCH /creators/links/:linkId and DELETE /creators/links/:linkId |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | creator.links.schedule_invalid | scheduledEnd <= scheduledStart |
400 | creator.links.social_dm_active | isSocial: true AND creator has dmActive: true |
400 | creator.links.invalid_platform | isSocial: true AND platform missing or not in whitelist |
400 | creator.links.content_flagged | Moderation flagged the title |
400 | creator.links.invalid_url | URL not http(s):// OR contains javascript: |
400 | creator.links.max_links | Existing link count >= bio.max_links (admin-managed, default 20). Carries { maxLinks } payload. |
400 | (DTO validation) | Field length / type / enum failures |
401 | (guard) | Missing / invalid bearer token |
403 | (verifyCreatorOwnership) | creatorId does not belong to the bearer's user |
404 | creator.bio.not_found | BioPage row missing for this creator |
429 | (throttle) | Rate limit exceeded (30 req/hour) |
Side effects
- Ownership check —
verifyCreatorOwnership(creatorId, userId)→ 403 on mismatch. - Lookup
BioPagebycreatorId; thrownot_foundif missing. - Schedule validation — if both bounds present, reject when
end <= start. - Social-link gate — if
isSocial: true: lookupcreator.dmActive, reject whentrue; then platform whitelist check. - Moderation gate (when
titlesupplied) —trustSafetyService.checkContent(input.title); flag → reject. - Read
bio.max_links(admin-managed, default 20). Generateid = randomUUID(). Computeembed = detectEmbedType(input.url). - Inside one transaction with row-lock:
SELECT ... FOR UPDATEon theBioPagerow to prevent concurrent appends racing past the cap.count(BioLink where bioPageId)≥maxLinks→ throwmax_links(with{ maxLinks }payload).validateUrl(input.url)(re-assertshttp(s)://+javascript:block).stripHtmlTags(input.title).bioLink.createwith assembled fields.sortOrderdefaults to currentlinkCount(appended). Embed type/meta default to auto-detected if neither supplied.
- Cache invalidation —
invalidateBioCache(creatorId).
Code samples
curl -X POST https://api.bio.re/api/v1/creators/c1a2b3c4-d5e6-7890-abcd-ef1234567890/links \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"title": "Watch on YouTube",
"url": "https://youtube.com/watch?v=dQw4w9WgXcQ"
}'type EmbedType = 'YOUTUBE' | 'SPOTIFY' | 'TIKTOK' | 'SOUNDCLOUD' | 'TWITCH' | 'APPLE_MUSIC' | 'CUSTOM';
type CreateBioLinkInput = {
title: string;
url: string;
icon?: string;
sortOrder?: number;
active?: boolean;
embedType?: EmbedType;
embedMeta?: Record<string, unknown>;
scheduledStart?: string;
scheduledEnd?: string;
isSocial?: boolean;
platform?: string; // required when isSocial: true
};
async function addBioLink(accessToken: string, creatorId: string, input: CreateBioLinkInput): Promise<string> {
const res = await fetch(`https://api.bio.re/api/v1/creators/${creatorId}/links`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'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 ?? 'Add link failed'), {
code: json?.error?.code,
maxLinks: json?.error?.maxLinks, // present on 'creator.links.max_links'
});
}
return json.data.id as string;
}import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useAddBioLink(creatorId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: CreateBioLinkInput) => {
const res = await fetch(`/api/v1/creators/${creatorId}/links`, {
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 ?? 'Add link failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
maxLinks: json?.error?.maxLinks,
});
}
return json.data.id as string;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['creators', creatorId, 'bio'] });
},
});
}Try it
Authorization
bearer In: header
Path Parameters
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
curl -X POST "https://loading/api/v1/creators/string/links" \ -H "Content-Type: application/json" \ -d '{ "title": "string", "url": "http://example.com" }'{
"success": true,
"data": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/creator/creator.controller.ts | 151–161 (addLink) |
| DTO (request) | apps/api-core/src/modules/creator/dto/creator.dto.ts | 47–80 (CreateBioLinkDto) |
| DTO (response) | apps/api-core/src/modules/creator/dto/creator-client-response.dto.ts | 451–454 (AddLinkResponseDto) |
| Service | apps/api-core/src/modules/creator/creator.service.ts | 333–391 (addLink), 42–50 (validateUrl), 1065–1081 (detectEmbedType) |
| Moderation | apps/api-core/src/modules/trust-safety/trust-safety.service.ts | checkContent() |
| Config keys | apps/api-core/src/modules/config/config.service.ts | bio.max_links (admin-managed) |
| Prisma models | packages/prisma/prisma/schema.prisma | BioPage, BioLink, CreatorProfile.dmActive |
Get Public Bio Page (Fan-side)
SSR-friendly public bio page render by username. CDN + in-memory cache, status filter (suspended/banned/deactivated → 404), only published creators with active scheduled links.
Update Bio Link
Sparse update of a bio link. URL revalidation re-runs embed auto-detection. Title HTML-stripped + moderation-checked. Schedule end must follow start. Cache invalidation is best-effort.