BIO.RE
Creator

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

ParamTypeValidationNotes
creatorIdstring (UUID)ParseUUIDPipeMust match the bearer's CreatorProfile.id (otherwise 403)

Body — CreateBioLinkDto

FieldTypeRequiredValidationNotes
titlestringMaxLength(100)HTML-stripped server-side, then moderation-checked
urlstringIsUrl()Server validates scheme — javascript: rejected as creator.links.invalid_url
iconstringoptionalMaxLength(50)Free-form icon identifier (your design system)
sortOrdernumberoptionalIsInt, Min(0), Max(1000)Defaults to current link count (appended at end)
activebooleanoptionalDefault true
embedTypeenumoptionalIsIn(['YOUTUBE','SPOTIFY','TIKTOK','SOUNDCLOUD','TWITCH','APPLE_MUSIC','CUSTOM'])Skipped → server auto-detects from URL
embedMetaobjectoptionalIsObjectFree-form, e.g. { videoId: '...' }
scheduledStartstring (ISO 8601)optionalIsDateStringSchedule visibility window start
scheduledEndstring (ISO 8601)optionalIsDateStringMust be after scheduledStart
isSocialbooleanoptionalWhen true, DM must be off and platform is required
platformstringconditionalMaxLength(30)Required when isSocial: true. Whitelist: instagram, x, youtube, tiktok, github, linkedin, facebook, kick, twitch, snapchat, threads, pinterest, discord
HeaderRequiredNotes
Authorization: Bearer <accessToken>JWT from POST /auth/login

Response

201 CreatedApiResponseOf<AddLinkResponseDto>

{
  "success": true,
  "data": {
    "id": "l1a2b3c4-d5e6-7890-abcd-ef1234567890"
  }
}
FieldTypeNotes
idstring (UUID)The new BioLink.id — pass to PATCH /creators/links/:linkId and DELETE /creators/links/:linkId

Errors

HTTPcode / i18nKeyReason
400creator.links.schedule_invalidscheduledEnd <= scheduledStart
400creator.links.social_dm_activeisSocial: true AND creator has dmActive: true
400creator.links.invalid_platformisSocial: true AND platform missing or not in whitelist
400creator.links.content_flaggedModeration flagged the title
400creator.links.invalid_urlURL not http(s):// OR contains javascript:
400creator.links.max_linksExisting 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
404creator.bio.not_foundBioPage row missing for this creator
429(throttle)Rate limit exceeded (30 req/hour)

Side effects

  1. Ownership checkverifyCreatorOwnership(creatorId, userId) → 403 on mismatch.
  2. Lookup BioPage by creatorId; throw not_found if missing.
  3. Schedule validation — if both bounds present, reject when end <= start.
  4. Social-link gate — if isSocial: true: lookup creator.dmActive, reject when true; then platform whitelist check.
  5. Moderation gate (when title supplied) — trustSafetyService.checkContent(input.title); flag → reject.
  6. Read bio.max_links (admin-managed, default 20). Generate id = randomUUID(). Compute embed = detectEmbedType(input.url).
  7. Inside one transaction with row-lock:
    • SELECT ... FOR UPDATE on the BioPage row to prevent concurrent appends racing past the cap.
    • count(BioLink where bioPageId)maxLinks → throw max_links (with { maxLinks } payload).
    • validateUrl(input.url) (re-asserts http(s):// + javascript: block).
    • stripHtmlTags(input.title).
    • bioLink.create with assembled fields. sortOrder defaults to current linkCount (appended). Embed type/meta default to auto-detected if neither supplied.
  8. Cache invalidationinvalidateBioCache(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

POST
/api/v1/creators/{creatorId}/links
AuthorizationBearer <token>

In: header

Path Parameters

creatorId*string

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

SourcePathLines
Controllerapps/api-core/src/modules/creator/creator.controller.ts151–161 (addLink)
DTO (request)apps/api-core/src/modules/creator/dto/creator.dto.ts47–80 (CreateBioLinkDto)
DTO (response)apps/api-core/src/modules/creator/dto/creator-client-response.dto.ts451–454 (AddLinkResponseDto)
Serviceapps/api-core/src/modules/creator/creator.service.ts333–391 (addLink), 42–50 (validateUrl), 1065–1081 (detectEmbedType)
Moderationapps/api-core/src/modules/trust-safety/trust-safety.service.tscheckContent()
Config keysapps/api-core/src/modules/config/config.service.tsbio.max_links (admin-managed)
Prisma modelspackages/prisma/prisma/schema.prismaBioPage, BioLink, CreatorProfile.dmActive

On this page