Skip to main content
Login
Discover Videos Artists Games Book Regions Events

API Documentation

Auto-generated reference for all endpoints

639 endpoints across 49 categories

ssh hiphopworld 'cat ~/audio-diag/results.jsonl' Privacy: the reporter's IP is stored ONLY as a truncated salted hash, used for throttling and for grouping a tester's runs. The user agent IS stored verbatim — the OS/browser version is the diagnosis. Nothing else identifying is accepted; the payload shape is whitelisted below.

Returns merged branding config, assets, and phase completion for a site. Supports ETag caching with 5-minute Cache-Control.

Parameters

site_key

No auth required. CORS enabled. Returns JSON.

Optional query params: site — site key (e.g. 'clothing', 'bingo') title — override the site name tagline — override the tagline

Parameters

site e.g. clothing

Accepts JSON body: { necessary: 1, analytics: 0|1, marketing: 0|1 } Returns JSON: { success: true, id: N }

Receives Content-Security-Policy violation reports from browsers. Rate-limited to 30/min per IP hash. Logs to cli/logs/csp-violations.log. Standard browser payload: {"csp-report": {document-uri, violated-directive, ...}}

Returns: {degrees: [...], stats: {...}, progress: [...]}

Returns a chronological activity feed. If the user is logged in and follows other users, returns a personalized feed from those they follow. Otherwise returns the global feed. ?mode=following — force following-only feed (requires auth) ?mode=global — force global feed ?types=music,events,cards,achievements,members — filter by type ?limit=25 — items per page (max 100) ?offset=0 — pagination offset

Parameters

mode e.g. following

Returns a detailed JSON report on all critical services: database, session, environment, SMTP, OAuth providers, blockchain RPCs, filesystem, PHP runtime, and migrations. Access control (one must match): 1. X-Health-Key header matches HEALTH_CHECK_KEY env var 2. Logged-in admin user (is_admin = 1) 3. IP in .maintenance whitelist If none match, returns 403. Used by uptime monitors, admin dashboards, and deploy scripts.

Maintained for backward compatibility. New code should use /api/subscribe.php directly.

Generates a 1200x630 PNG with credential details using PHP GD. Caches images for 24 hours in /cache/og-images/.

Parameters

hash e.g. VERIFICATION_HASH

Returns cached social stats (Discord, Telegram, registered users). Data is pre-cached by cli/pulse-cache.php (cron job). No authentication required. Response: { "success": true, "data": { "discord_members": N, "discord_online": N, "telegram_members": N, "soundcloud_followers": N, "youtube_subscribers": N, "registered_users": N, "cached_at": "ISO8601", "stale": false } }

region=pdx|dmv|bars (default: all) type=music|etc (event_type filter) limit=N (default 60, max 200) featured=1 (only is_featured=1)

Parameters

q e.g. term
limit e.g. 8

GET /api/site-config.php?site_key=oregontires GET /api/site-config.php?site_key=tre5magic&fields=google_analytics_id,logo_url

Parameters

site_key e.g. oregontires

Cached for 1 hour via file-based cache. Respects the 50,000 URL sitemap limit.

Cache: 45s (client-side poll interval) Rate limit: 0 (public stats)

Body: { "email": "user@example.com", "source": "subscribe" } Creates email_subscribers record + pending user + sends invitation. If user already exists with a password, just adds them to subscribers. Replaces legacy api/newsletter-subscribe.php.

Generates a printer-friendly HTML page with auto print dialog. The user can "Print to PDF" from their browser.

Parameters

user e.g. USERNAME

Receives CLS/LCP/INP metrics from the browser via sendBeacon. Rate-limited to 60 requests/min per IP hash. Returns 204 on success.

Actions: {"action": "generate"} — Generate a new TOTP secret + QR URI {"action": "enable", "code": "123456"} — Verify code against pending secret, enable 2FA {"action": "disable", "code": "123456", "password": "..."} — Disable 2FA (requires TOTP + password)

Called during login when a user has 2FA enabled. Expects a pending 2FA session (set by login.php after password verification). Body: {"code": "123456"} or {"backup_code": "ABCD1234"} On success: completes login, sets session, returns JWT. On backup code use: marks that code as used (NULLs it out).

Called when a logged-in user (via wallet/OAuth) wants to add email login. Sets both email and password_hash on the user, then sends a verification email. The actual email connection in user_connections is created when the user clicks the verification link (handled by verify-email.php). Accepts: { email: string, password: string, confirm_password: string } Returns: { success: true, email_verification: 'pending', message: '...' }

No description available.

We exchange the Google code here (on hiphop.world, whose callback URL IS in Google's allow-list), federate the profile into the central users table, mint a target_origin-bound sso_token, and bounce the browser to the tenant's /sso endpoint for session seating. Assumes $pdo is already available from the parent include. Security hardening (2026-04-18): - HIGH #4: mint inserts both token (plain) AND token_hash = SHA-256(token) - HIGH #5: email-merge step guarded by email_verified check - CRITICAL #2: target_origin normalized (lowercase + strip trailing slash) on mint - MEDIUM #8: failure redirects after state lookup use $tenantDomain (not hiphop.world) - MEDIUM #9: tenant_domain re-resolved from tenant_federation_clients, not metadata - MEDIUM #12: raw OAuth response bodies never logged; only parsed error fields

No description available.

Validates the invitation token, sets the user's password, creates the email connection, and logs them in.

We exchange the LinkedIn code here (on hiphop.world, whose callback URL IS in LinkedIn's allow-list), federate the profile into the central users table, mint a target_origin-bound sso_token, and bounce the browser to the tenant's /sso endpoint for session seating. LinkedIn redirect URI registered in LinkedIn Console: https://hiphop.world/api/auth/linkedin-broker-callback.php Assumes $pdo is already available from the parent include.

No description available.

No description available.

No description available.

This endpoint is for LOGGED-OUT users who have lost access to their account but previously set a recovery wallet in the Security Hub. Flow: 1. User provides wallet_address, wallet_type, signature, nonce 2. Server verifies nonce + signature 3. Server looks up recovery_wallets table for matching wallet 4. If found: re-enable account (if disabled), create session, return redirect 5. Generic error for all failure types (do not leak which wallets are registered)

New flow (email-only): 1. User submits email (no password required) 2. Creates pending user (NULL password_hash) 3. Creates email_subscribers record 4. Sends invitation email with "Set Your Password" link 5. User does NOT get logged in immediately If the user already has an account with a password, they're told to sign in. If the user exists from OAuth/wallet with no password, they get an invitation.

Accepts { "email": "..." }. Always returns success to prevent user enumeration. Rate limits to 3 reset requests per email per hour.

For users who registered but haven't set their password yet. Body: { "email": "user@example.com" }

Requires authentication. Checks if the user has a pending (unverified) email verification. If yes, creates a new token and resends the email. Rate limited: only allows resend every 60 seconds.

Accepts { "token": "...", "password": "...", "confirm_password": "..." }. Verifies token, validates password constraints, updates the user's password.

Called when login.php has ?return= and user may already be logged in. If authenticated: generates SSO token → returns redirect URL (skip login form) If not: returns { authenticated: false }

No Node.js dependency — uses kornrunner/keccak + simplito/elliptic-php

Public endpoint — no authentication required. Response: {success: true, artist: {entity, music, media, groups, shows, related}}

Parameters

slug (required) artist slug

Public endpoint — no authentication required. Response: {success: true, artists: [...], total: N, limit: N, offset: N} Session #497: - Rewritten self-contained (previous version called ArtistHub statics with wrong signatures — every request 500'd). - Each artist row includes fallback_image: first hd_artist_media cover_url (sort_order, then id) for card preview fallback. - Artists with neither avatar_url nor any media cover_url are excluded from listings (not deleted — direct /artist/{slug} URLs still work).

Parameters

region region slug (optional, filters by region)
sub_region sub-region slug (optional)
type artist_type filter (optional)
q search query (optional)
verified 0 or 1 (optional)
limit results per page (default 24, max 100)
offset pagination offset (default 0)

Public endpoint — no authentication required. Response: { success: true, artists: [...], total: N, page: N, per_page: N, filters: {countries: [...], languages: [...], genres: [...]} } Session #497 (via ArtistHub::browse): - Each artist row includes fallback_image — first hd_artist_media cover_url (sort_order, then id) for card preview fallback. - Artists with neither avatar_url nor any media cover_url are excluded from listings (not deleted — direct /artist/{slug} URLs still work).

Parameters

country ISO country code (e.g. US, GB, FR)
language ISO language code (e.g. en, es, fr)
genre genre slug (e.g. trap, boom-bap, reggaeton)
region region slug (e.g. dmv, bars, pdx)
continent continent filter (north_america, europe, latin_america, africa, asia, oceania)
q search query (name, stage name, hometown)
sort name|popular|recent (default: popular)
page page number (default 1)
per_page results per page (default 24, max 100)

Request body (JSON): { stage_name: string (required, 2..120), artist_type?: string (enum, default 'solo'), region_id?: int (must exist in hhw_regions.id, 0/null = clear), sub_region_id?: int (must exist in hhw_sub_regions AND match region_id, 0/null = clear), force_create?: bool (bypass the name-conflict pre-check — "create new anyway") } Guards: requireCsrf → requireApiAuth → checkRateLimit (5/day/user) CSRF: required Response 201: { success: true, state: 'created', entity: { id, slug } } Errors: { success: false, error: string } + 400|409|422|429|500 One-artist-per-account: 409 if user already owns an entity. Name conflict: 409 { conflict: { id, slug, name, claimed } } if a non-deleted artist matches this name by slug OR display name — CLAIMED or seeded/unclaimed (anti name-squat) — steer the user to CLAIM it. Bypass with force_create=true for genuinely distinct same-name artists. Slug: generated from stage_name, suffixed on collision. Audit: writes hd_claim_requests row with status='auto_approved' like ClaimFlow.

Response: { success: true, sub_regions: [{id, name, slug}] } Rate-limited (30/min) to prevent enumeration abuse. No auth required — regions are public data.

Parameters

region_id

Rate limit: 30 / 60s per IP. Response: { success: true, results: [{ id, slug, name, city, state_region, logo_url, cover_image_url, claimed, follower_count }] }

Parameters

q search query (min 2 chars after trim, truncated at 100)

Request body: { entity_id: int, evidence?: string (http(s) link, max 500) } Responses: 200 { success: true, state: 'claimed', entity: {...} } — auto-approved 200 { success: true, state: 'already_owner', entity: {...} } — user already owns it 200 { success: true, state: 'pending', request_id: int } — dispute queued for admin 4xx { success: false, error: "..." }

Public endpoint — no authentication required. Response: {success: true, events: [{..., lineup: [...]}], total: N, limit: N, offset: N}

Parameters

region region slug (optional, filters by region)
limit results per page (default 24, max 100)
offset pagination offset (default 0)

Request body: { entity_id: int } Response (success): { success: true, following: bool, follower_count: int } Response (error): { success: false, error: "..." }

Public endpoint, rate limited. Response: {success: true, artists: [{entity_id, name, slug, artist_type, avatar_url, is_verified, region_short_name}]}

Parameters

q search query (required, min 2 characters)
limit max results (default 10, max 50)

GET ?event_id=123 — Public: get lineup for an event POST {event_id, artist_entity_id, billing_order?, set_time?, set_length_minutes?} — Auth: add artist to lineup (event creator only) DELETE {event_id, artist_entity_id} — Auth: remove artist from lineup (event creator only)

Parameters

event_id e.g. 123

Request body: { url: string, title?: string, media_type?: string, attach_to_media_id?: int } media_type ∈ song|album|video|mixtape|ep|single (Engagement::ATTACHABLE_MEDIA_TYPES). Omit it to keep the parser's guess — which is 'song' for EVERY YouTube URL, so a music video must say so explicitly or it files as a song. attach_to_media_id (migration 341) makes this the MULTI-SOURCE path: the URL becomes another way to hear an EXISTING release instead of a second unrelated catalogue row. The song keeps one set of plays, likes and comments. A YouTube source becomes primary when one is attached, so the parent row every read site uses stays video-first. Rate limit: 20 / 10 min Response (success): { success: true, created: bool, media: {...}, sources?: [...] } Response (error): { success: false, error: "..." }

Request body: { media_id: int } Response (success): { success: true, removed: true } Response (error): { success: false, error: "..." }

Request body: { media_ids: [int, ...] } media_ids — full ordered list of visible track ids (most-first). Unknown or cross-entity ids are silently ignored. Response (success): { success: true, updated: int } Response (error): { success: false, error: "..." }

Response (with entity): { success: true, entity: {...}, music: [...], platforms: [...] } Response (no entity): { success: true, entity: null }

Response (with entity): { success: true, entity_id: int, follower_count: int, tracks: [{ media_id, title, plays }] } Response (no entity): { success: true, entity_id: null, follower_count: 0, tracks: [] }

Gate order mirrors api/hub/profile-update.php:39-44 — CSRF, then auth, then rate limit. ── WHAT THIS IS NOT ── DISPLAY-ONLY. This endpoint stores a handle the member already owns so a fan can pay them DIRECTLY on the member's own account. It moves no money, quotes no amount, holds no balance and talks to no processor. There are no Stripe or Square credentials anywhere in this feature and there must never be — see includes/payment-methods.php for the ruling and the canon citations. 🔴 NO CLAIMED-ENTITY GATE HERE, DELIBERATELY. `payment_handles` is keyed on `users.id`, not on an artist entity, and these rows already power the member's hiphop.id passport. Requiring a claim to SAVE would break that shipped feature for every member who has no artist profile. The claim gate belongs on the DISPLAY side, and it is there: the public artist profile reads handles through `$linkedUser`, which is null for the 2,744 unclaimed entities, so an unclaimed profile renders no payment surface at all. Gating a member out of editing their own payout details would be a different, wrong rule. Ownership is enforced in the SQL of every write (`AND user_id = ?`), inside PaymentHandles — never here, and never by trusting an id in the request body.

Request body: { media_id: int, item_type?: 'media'|'share' } item_type defaults to 'media' (hd_artist_media). 'share' records a play for a member-shared track (hd_music_shares — mig 291 CQ-4 discriminator). Rate limit: 120 / 5 min Response (success): { success: true, counted: bool, plays: int } Response (error): { success: false, error: "..." }

Returns the authenticated user's aggregated hub data: claimed entity, region, media, upcoming shows, connected platforms, stats. Response: { success: true, hub: {...} }

Multipart form fields: image (file) — JPEG, PNG, or WebP, ≤5MB kind — 'avatar' or 'banner' Guards: requireCsrf → requireApiAuth → checkRateLimit (20/hr/user) Owner-gate: user must have a claimed artist entity Resize: avatar ≤512×512 (square-crop-center), banner ≤1600 wide Output dir: public_html/uploads/artist-images/ Response: { success:true, kind, url } Errors: { success:false, error } + 400/403/413/422/500

Request body (all optional, at least one). Source of truth for the field set is Engagement::PROFILE_WHITELIST (includes/engagement.php) — keep in sync: { stage_name, bio, hometown, genres: [string], website_url, avatar_url, banner_url, instagram_handle, twitter_handle, youtube_handle, soundcloud_handle, tiktok_handle, artist_type, region_id, sub_region_id, bandcamp_url, spotify_id, booking_email, manager_name, label_name } Rate limit: 20 / 10 min Response (success): { success: true, updated: [field, ...] } Response (error): { success: false, error: "..." }

Returns public hub data for any user by username. No sensitive data (booking_email, manager, connected_platforms excluded). Includes profile-visible imported music. Response: { success: true, hub: {...} } 404 if user not found or no hub presence

Parameters

username

Public endpoint — no authentication required. Response: {success: true, results: [{type: 'artist', ...}, ...], total: N}

Parameters

q search query (required, min 2 characters)
region region slug (optional, scope to region)
type 'artist', 'venue', or 'event' (optional, filter to one type)
limit max results per type (default 20, max 50)

Public endpoint — no authentication required. Response: {success: true, venues: [...], total: N, limit: N, offset: N}

Parameters

region region slug (optional)
sub_region sub-region slug (optional)
city city name filter (optional)
limit results per page (default 24, max 100)
offset pagination offset (default 0)

Multipart form data: song_id (int) + artwork (file) Auth required. User must own the song or be admin. Validates image via finfo MIME detection (JPEG, PNG, WebP only). Saves to /uploads/artwork/ and updates music_songs.artwork_url.

Receives the authorization code from Audius OAuth, exchanges it for tokens using PKCE, fetches the user profile, and registers the connection. Expected query params: ?code=AUTH_CODE&state=STATE

Parameters

code e.g. AUTH_CODE
state e.g. STATE

GET ?item_type=media|share&item_id=<id>&page=1 -> 200 { success:true, item_type, item_id, page, page_size, aggregates:{avg_rating, review_count, comment_count}, my_rating:<1-5|null>, comments:[{id,user_id,author_name,body,rating,created_at,is_mine}] } PUBLIC (reads work logged-out, like plays). A logged-in viewer also gets is_mine + my_rating so the UI can offer delete / avoid a doomed second review POST. POST { item_type, item_id, body, rating? } -> 201 { success:true, comment:{...}, aggregates:{...} } rating omitted/null = comment (unlimited); rating 1-5 = review (ONE per user per track, CQ-3) -> 409 review_exists on a second. DELETE { id } -> 200 { success:true, deleted:true, id, aggregates:{...} } Own comment, or ANY comment when the caller is an admin (spec F4: "DELETE (own or admin)"). Soft delete only. (requireActiveMember, Phase 1) + CSRF. Rate limit: 20 comments/hour per user (POST only; spec F4). Errors: { success:false, error:"<machine_code>" } — verify_required | account_inactive | invalid_item_type | invalid_body | body_too_long | invalid_rating | review_exists | not_found | method_not_allowed | internal_error (+ requireApiAuth 401 and requireCsrf 403).

Parameters

item_type e.g. media|share
item_id e.g. <id>
page e.g. 1

OAuth-first: generates PKCE pair, stores verifier in session, returns the Audius authorization URL for the client to redirect to. Fallback: if body contains { "handle": "..." }, uses handle-based connection via the public Discovery API (no OAuth needed).

SoundCloud's API is restricted (no new app registrations), so we use a handle/URL-based connection. User provides their SoundCloud profile URL or handle, and we verify it exists via oEmbed. If the user already has a SoundCloud OAuth connection (from the login flow), we can auto-detect their handle from provider_data. Body: { "handle": "artist-name" } or { "url": "https://soundcloud.com/artist-name" }

Uses the existing Google OAuth credentials (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET) but requests the youtube.readonly scope specifically for music import. Returns the Google OAuth authorization URL for the client to redirect to.

Returns full song data including artist, album, lyrics, and annotations.

Parameters

id e.g. 123

Removes the platform connection from music_imports. Previously imported songs remain in the catalog (not deleted). Body: { "platform": "audius" | "youtube" | "soundcloud" }

Public — no auth required for tab=new|trending. tab=following requires an authenticated session. Response: { success:true, tracks:[...], has_more:bool } tab=following logged-out -> 401 { success:false, error:'auth_required' } Track row shape (see includes/track-discovery.php for the full contract): { item_type, item_id, title, cover_url, youtube_id, external_url, platform, duration_seconds, artist:{entity_id, name, slug, avatar_url}, play_count, like_count, liked, followed, created_at } Rate limit: 120 / 5 min (mirrors api/hub/play.php's public-endpoint bucket).

Parameters

tab new|trending|following (default: new)
limit 1-48 (default 24)
offset >= 0 (default 0)

Two ways in, and BOTH end at the same entitlement check: SESSION — logged in. This is the "My Music" library path; it cannot expire. TOKEN — an HMAC-signed, expiring link that survives an email inbox. 🔴 THE TOKEN IS NOT AUTHORISATION ON ITS OWN. It proves the link came from us; the entitlement lookup proves the buyer still owns the album. Both are required on every single request, which is what makes a refund kill links that are already in someone's inbox. A signature-only design would outlive the refund. 🔴 THE FILE IS NEVER UNDER THE WEB ROOT. It is streamed from outside public_html by this script (see includes/album-downloads.php). A paid album inside the docroot is a paid album anyone can fetch by guessing a filename, and no PHP check helps when the web server has already served the bytes. Errors are deliberately terse and identical in shape: this endpoint should not tell an unauthenticated stranger whether a given album exists or who owns it.

Parameters

album
u
t

Rate limited: 100 events per hour per IP. Used by song-detail and album-detail pages for analytics + trending.

Body: { "song_id": 1 } Returns: { "success": true, "favorited": true/false }

Imports all tracks from the specified platform into the user's music catalog. Songs are imported with visible_on_profile = 0 (user must manually activate). Deduplicates by external_url — running import again skips already-imported tracks. Body: { "platform": "audius" | "youtube" | "soundcloud" }

Body: { "url": "...", "title": "...", "artist_name": "...", "album_title": "...", "platform": "bandcamp", "media_type": "audio", "features": "...", "track_number": 1, "duration": "3:45", "embed_code": "..." } Requires authentication. Auto-detects platform from URL if not specified. Creates artist if not found, finds or creates album, then calls addSong().

Returns all songs imported from external platforms for the authenticated user. Each song includes its visible_on_profile flag for toggle controls. platform — Filter by platform (audius, youtube, soundcloud) limit — Max results (default 100, max 500) offset — Pagination offset

Request body: { item_type: 'media'|'share', item_id: int } Rate limit: 60 / 5 min Response (success): { success:true, liked:bool, like_count:int } Response (error): { success:false, error:"<machine_code>" } invalid_input | not_found | internal_error (+ requireApiAuth's 401 Unauthorized envelope and requireCsrf's 403 envelope)

Returns paginated list of songs with artist and album info.

Parameters

artist_id e.g. 1
album_id e.g. 1
platform e.g. bandcamp
media_type e.g. audio
q e.g. search
limit e.g. 20
offset

Returns songs the user has marked as visible on their hiphop.id profile. This is a public endpoint — no auth required. user_id — The user whose profile music to fetch (required) limit — Max results (default 50, max 100)

Parameters

user_id

PayPal redirects here after approval. Captures the order, marks purchase as completed, then 302-redirects to success or cancel page.

Returns: { purchased: bool } or { albums: [{id, purchased}] }

Parameters

album_id e.g. N (optional)

Input: { album_id: int } Returns: { success, approval_url, order_ref }

POST { target_type:'share'|'comment', target_id, reason, detail? } reason ∈ spam | not_hiphop | abuse | copyright | other -> 201 { success:true, report_id } -> 409 { success:false, error:'already_reported' } (one report per member per item — UNIQUE(target_type,target_id,reporter_user_id), mig 294) Reporting does NOT hide anything on its own. It lands in the admin queue (admin/music-shares.php) where a human approves / hides / bans. This is the mig 289/290 lesson: a moderation lane, never an automatic takedown. Rate limit: 10 reports/hour per user (bucket music_report_u{id}) — a report button is the cheapest griefing surface on the site, so it gets the tightest member bucket in the feature. Errors: { success:false, error:"<machine_code>" } — verify_required | account_inactive | invalid_target_type | invalid_reason | invalid_detail | detail_too_long | already_reported | not_found | method_not_allowed | internal_error (+ requireApiAuth 401, requireCsrf 403).

Rate limit: 10 shares/hour per user (POST only). Resolution: includes/youtube-resolver.php (Data API; rejects non-embeddable / private / age-restricted — the resolver's machine code is surfaced verbatim). Artist tag must reference an APPROVED hd_entities row -> else 422 invalid_artist. Dedupe: UNIQUE(user_id, youtube_video_id) -> 409 already_shared (soft-deleted duplicates are restored instead). Moderation trust-ramp per PRD CQ-1. Errors: { success:false, error:"<machine_code>" } — verify_required | account_inactive | invalid_url | note_too_long | invalid_note | invalid_artist | not_found | not_embeddable | not_public | age_restricted | already_shared | api_unavailable | missing_api_key | method_not_allowed | internal_error (+ requireApiAuth's 401 Unauthorized and requireCsrf's 403 envelope). Artist type-ahead (task 2.2) intentionally has NO new endpoint: the share UI reuses GET /api/hub/claim-search.php?q= (public approved-artist search returning {id, slug, name, ...}, min 2 chars, rate-limited 30/60s).

NOT member-gated, on purpose. Sharing is a read-side action: a logged-out visitor on /discover, an artist profile or a public playlist must be able to hit Share. Every write endpoint in F2-F4 uses requireActiveMember(); this one does not, because it creates no user content — it only mints a redirect for a row that is ALREADY public. Still guarded: CSRF (so it cannot be driven cross-origin) + checkRateLimit(), which buckets PER IP inside the limiter — a global bucket would be a trivial DoS. created_by is stamped when a member is logged in, NULL for guests (mig 291 allows NULL). Idempotent: one code per target (see includes/short-links.php) — clicks aggregate into one row, which is what the PRD's "share-link clicks / week" metric counts. No external origin is contacted.

Creates a content_queue entry + audio_submission_sources entries. Requires login. Rate limited to 5 submissions per hour.

Parameters

song_id e.g. X)

Controls which imported songs appear on the user's public hiphop.id profile. Supports single toggle and bulk operations. Single toggle: Body: { "song_id": 123, "visible": true } Bulk toggle: Body: { "song_ids": [1, 2, 3], "visible": true } Show all / Hide all: Body: { "all": true, "visible": true } — makes all imported songs visible Body: { "all": true, "visible": false } — hides all from profile

Handles: PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.REFUNDED, PAYMENT.CAPTURE.REVERSED

Handles the redirect back from Google OAuth (YouTube scope). Exchanges the code for tokens, fetches channel info, and registers the YouTube connection for music import.

Rate limit: 60 writes/hour per user, bucket 'playlists_u{id}' — shared across all playlist write endpoints (create/update/delete/items), mirroring api/music/share.php's per-user bucket pattern. Errors: { success:false, error:"<machine_code>" } — verify_required | account_inactive | invalid_title | title_too_long | invalid_description | description_too_long | invalid_visibility | playlist_limit | method_not_allowed | internal_error (+ requireApiAuth 401, requireCsrf 403).

POST { id } -> 200 { success:true, deleted:true, id } (soft delete, own playlist only) Rate limit: 60 writes/hour per user, bucket 'playlists_u{id}' (shared with create/update/items). Errors: { success:false, error:"<machine_code>" } — verify_required | account_inactive | not_found | method_not_allowed | internal_error (+ requireApiAuth 401, requireCsrf 403).

Public read (no member gate) — but session-aware: private playlists return 404 unless the requester's session user_id matches the owner. Items resolved via hd_artist_media (item_type=media, non-deleted) and hd_music_shares (item_type=share, approved + non-deleted only); stale references are silently dropped from the items list. Errors: { success:false, error:'not_found' } — missing slug, missing playlist, or private playlist viewed by a non-owner (indistinguishable, no existence oracle). { success:false, error:'method_not_allowed' } (405). { success:false, error:'internal_error' } (500).

Parameters

slug e.g. X

POST { action:'add'|'remove'|'reorder', playlist_id, item_type, item_id } reorder additionally takes: ordered_ids:[hd_playlist_items.id, ...] add -> 200 { success:true, item_count:N } 409 { success:false, error:'duplicate' } on a repeat add remove -> 200 { success:true, item_count:N } reorder -> 200 { success:true, item_count:N } item_type must be media|share. On add: media must exist in hd_artist_media (non-deleted); share must exist approved + non-deleted in hd_music_shares -> else 422 invalid_item. item_count is recomputed from the actual row count on every add/remove (no incremental-drift risk). Rate limit: 60 writes/hour per user, bucket 'playlists_u{id}' (shared with create/update/delete). Errors: { success:false, error:"<machine_code>" } — verify_required | account_inactive | not_found (playlist not owned/missing, or remove target missing) | invalid_action | invalid_item_type | invalid_item | item_limit | duplicate | invalid_ordered_ids | method_not_allowed | internal_error (+ requireApiAuth 401, requireCsrf 403).

GET -> 200 { success:true, playlists:[{ id, title, slug, visibility, item_count, updated_at }] } — caller's non-deleted playlists, newest-updated first. no CSRF, no rate limit (per network convention: writes are rate-limited, not reads). Errors: { success:false, error:"<machine_code>" } — verify_required | account_inactive | method_not_allowed | internal_error (+ requireApiAuth 401).

POST { id, title?, description?, visibility? } -> 200 { success:true, playlist:{ id, title, slug, visibility, item_count } } Slug is immutable after creation (renaming the title does not change the slug, so a shared /api/playlists/get.php?slug= URL never breaks). Rate limit: 60 writes/hour per user, bucket 'playlists_u{id}' (shared with create/delete/items). Errors: { success:false, error:"<machine_code>" } — verify_required | account_inactive | not_found | invalid_title | title_too_long | invalid_description | description_too_long | invalid_visibility | method_not_allowed | internal_error (+ requireApiAuth 401, requireCsrf 403).

No description available.

No description available.

Body: { rsvp_id, action: "approve"|"decline" }

Requires: auth + event ownership

Parameters

id (event ID)

Body: { "date_id": 456 } Requires authentication. Must own the event that the date belongs to. Cancels an individual occurrence without affecting other dates. Returns success.

Body: { "event_id": 123, "reason": "optional reason" } Requires authentication. Must own the event. Sets event status to 'cancelled', cancels all scheduled dates, and notifies all RSVPed/waitlisted users. All sales are final — no refunds on cancellation.

Public endpoint. Returns all active categories with the count of active, public events that have at least one upcoming scheduled date.

Body: { qr_token: "xxx" }

POST /api/events/comments.php — Add comment Body: { event_id, body, parent_id? }

Parameters

event_id

Requires authentication. Creates event + slug + dates + tickets + tags. Body: { title, description, short_description, event_type, category_id, venue_name, venue_address, venue_city, venue_state, venue_country, digital_url, digital_platform, cover_image_url, is_free, max_capacity, visibility, timezone, requires_approval, allow_guests, max_guests_per_rsvp, dates: [{start_datetime, end_datetime, is_all_day}], tickets: [{name, price_cash, capacity, description}], tags: ["tag1", "tag2"], recurrence: {...}, status: "draft"|"active" }

Body: { "event_id": 123 } Requires authentication. Must own the event. Cascades to event_dates and event_recurrence via FK. Returns success.

Public endpoint — no authentication required. Returns event with dates, recurrence info, and creator info.

Parameters

event_id e.g. 123

city — filter by venue_city (partial match) state — filter by venue_state country — filter by venue_country date_from — events starting on/after (YYYY-MM-DD) date_to — events starting on/before (YYYY-MM-DD) is_free — 1 = free only, 0 = paid only page — page number (default 1) per_page — results per page (default 24, max 100)

Session required. GET: Returns user's favorited events POST: Toggle favorite on/off (requires CSRF) Body: event_id (int, required) Returns: {success: true, is_favorited: bool}

Parameters

limit (1-100, default 20), offset (default 0)

Public endpoint.

Parameters

limit (1-10, default 5)

Body: { host_id, action: "follow"|"unfollow" }

Parameters

username e.g. xxx
limit e.g. 20
offset
upcoming_only e.g. 1

Requires auth + event ownership. event_id — Event ID (required) Returns: rsvp_timeline — Daily RSVP registrations [{date, count}] revenue_by_tier — Revenue per ticket tier [{name, sold, revenue}] conversion_funnel — {views, registered, checked_in} capacity — {used, total, percentage}

Returns RFC 5545 VCALENDAR

Parameters

event_id

Body: { event_id, email?: string, user_id?: int }

Public endpoint. bbox — Bounding box: south,west,north,east (comma-separated floats) category_id — Filter by category ID (int) neighborhood — Filter by neighborhood slug date_from — Start date (YYYY-MM-DD) date_to — End date (YYYY-MM-DD) limit — Max features returned (1-500, default 200)

Owner directive 2026-08-03: collect fliers, photos and video from the show once it has happened, so the site documents hip hop locally for a global audience — and the recap "could also be youtube videos." Guest-friendly, exactly like api/events/submit.php: the point is that the person who actually filmed the show can hand it over without making an account. Everything lands 'pending' and is reviewed through the same approval queue as the event itself. Body: { event_id | event_slug, url, kind?, title?, submitter_name?, submitter_email? }

Requires authentication. Returns paginated list of user's events with dates attached.

Parameters

status e.g. active
limit e.g. 20
offset

Public endpoint.

Body: { event_id, action: "register"|"cancel", event_date_id?, guest_count?, guest_names?, rsvp_note? } Or for cancel: { rsvp_id, action: "cancel" }

Public endpoint. q — Full-text search term category — Category ID (int) city — City name (exact match) type — Event type: physical|digital|hybrid date_from — Start date (YYYY-MM-DD) date_to — End date (YYYY-MM-DD) is_free — 1 for free, 0 for paid sort — date|popular|recent|trending|nearby limit — Results per page (1-100, default 20) offset — Pagination offset (default 0) lat — Latitude (float, required when sort=nearby) lng — Longitude (float, required when sort=nearby) radius — Search radius in miles (1-200, default 50, used with sort=nearby)

Public endpoint. slug — Series slug (optional — returns single series with events) limit — Results per page for list mode (1-100, default 20)

Returns full event with dates, tickets, host info, rsvp_count

Parameters

slug

Body: { "event_id": 123, "cash_amount": 500 } Requires authentication. Must own the event. Creates a card template with source_type='event', card_type='tour'. Cash is embedded in the template to increase its visibility. Returns template data.

Accepts the simplified submit-content.php form (title, description, dates, venue, cover_image_url, ticket_url, source_url, category_id, neighborhood_id). Creates a 'draft' event for admin review. Works for both logged-in users and guests. Guest submissions store submitter_name/submitter_email in the event description.

city — filter by venue_city (partial match) state — filter by venue_state country — filter by venue_country date_from — events starting on/after (YYYY-MM-DD) date_to — events starting on/before (YYYY-MM-DD) is_free — 1 = free only, 0 = paid only page — page number (default 1) per_page — results per page (default 24, max 100)

POST /api/events/tickets.php — Purchase ticket Body: { event_id, ticket_id }

Parameters

event_id

Semi-public (works for logged-in and anonymous users). Body: { event_id: int }

city — filter by venue_city (partial match) state — filter by venue_state country — filter by venue_country date_from — events starting on/after (YYYY-MM-DD) date_to — events starting on/before (YYYY-MM-DD) is_free — 1 = free only, 0 = paid only source — eventbrite|ticketmaster|all (default: all) page — page number (default 1) per_page — results per page (default 24, max 100)

Public endpoint — no authentication required. Returns upcoming events sorted by next scheduled date. Only includes active events with at least one future scheduled date.

Parameters

limit e.g. 20
offset

Body: { "event_id": 123, "title": "...", "description": "...", ... } Requires authentication. Must own the event. Returns the updated event.

Accepts multipart/form-data with: - cover: image file (JPEG, PNG, WebP, GIF; max 5MB) - event_id: integer - csrf_token: string (or X-CSRF-Token header) Resizes to max 1200px wide, converts to WebP, saves to uploads/events/. Replaces any existing uploaded cover for this event. Returns: { success: true, url: "https://hiphop.world/uploads/events/..." }

Public endpoint. entity_id — Venue entity ID (required, int) limit — Results per page (1-100, default 20) offset — Pagination offset (default 0)

Public endpoint. q — Search query (required, min 2 chars)

Requires: admin auth Body: { user_id: int, verified: bool }

Body: { event_id, action: "promote"|"remove", waitlist_id }

POST /api/user/achievements.php — Trigger achievement check (action=check) GET Response: { "success": true, "achievements": {...grouped by category...}, "stats": {...} } POST Body: { "action": "check" } POST Response: { "success": true, "newly_earned": ["first_card", ...], "stats": {...} }

?limit=20 — events per page (max 100, default 20) ?offset=0 — pagination offset (default 0) Response: { "success": true, "events": [...], "total": 42, "stats": { "total_logins": 100, "last_login_at": "...", ... } } Requires authentication.

Parameters

limit e.g. 20

user_id (optional) — Get badges for a specific user (public profile view). If omitted, returns badges for the authenticated user. Response: { "badges": [ { "type": "...", "label": "...", "description": "...", "awarded_at": "...", "color": "...", "icon_svg": "..." }, ... ] }

Returns: { balance: 100000.00, formatted: "100,000.00" }

Returns the user's profile completeness score with item-level detail.

DELETE /api/user/connections.php — Unlink a connection by provider or connection_id PATCH /api/user/connections.php — Set primary wallet for a provider type

Accepts: { "password": "...", "confirmation": "DELETE", "reason": "optional" } - If user has a password_hash: requires correct password - If user has NO password (OAuth/wallet only): requires confirmation text "DELETE" - Anonymizes the user row (preserves ID for foreign key integrity) - Records deletion in account_deletions audit table - Cleans up related data (connections, verifications, sessions, tokens) - Destroys the PHP session

Returns a JSON file containing all data associated with the authenticated user: profile, connections, links, sessions, friendships, referral info.

Returns: { home_address, home_info, communities: [...], total_voting_power } - home_address: user's 6-digit place code (or null) - home_info: { community_name, land_id } if home_address is set - communities: array of communities owned by this user - total_voting_power: sum of voting_power across owned communities

No description available.

No description available.

Returns only the unread count — optimized for header badge polling. Minimal DB load: single COUNT(*) query. Response: {"success": true, "unread_count": 5} Requires authentication.

No description available.

GET: List notifications (paginated) Response: {"success": true, "notifications": [...], "total": 42, "unread_count": 5} POST: Mark notifications as read Body: {"action": "mark_read", "id": 123} or: {"action": "mark_all_read"} Response: {"success": true, "marked": 1} Requires authentication. POST requires CSRF.

Parameters

limit e.g. 20
offset
unread e.g. 0 (unread=1 for unread only)

No description available.

This endpoint is ONLY for changing an existing password. Setting a new password for the first time is handled by /api/auth/connect-email.php. Requires: current_password, new_password, confirm_password

GET — Returns current preferences (email_notifications global toggle) POST — Updates preferences { email_notifications: bool } The email_notifications toggle sets the email channel across ALL notification types. For granular per-type control, use /api/user/notification-prefs.php instead.

POST /api/user/profile-stage.php — Skip a stage { "stage": "music", "action": "skip" }

PUT /api/user/profile.php — Update user profile

No description available.

POST /api/user/recovery-wallet.php — Set recovery wallet (requires wallet signature) DELETE /api/user/recovery-wallet.php — Remove recovery wallet

Returns the user's referral code, referred count, and friend count.

GET: Returns list of active sessions for the authenticated user. DELETE: Revokes a specific session or all other sessions. Body: { "session_id": 123 } — revoke single session Body: { "all_others": true } — revoke all except current Requires authentication.

Accepts multipart/form-data with field: - banner (file) — image file (JPEG, PNG, WebP) Validates file type, size (<=2MB), dimensions (min 600x200, max 3000x1000). Resizes to max 1500px wide via GD. Deletes old local banner if replacing. Returns: { success: true, banner_url: "/assets/uploads/profiles/..." }

POST /api/user/verification.php — Submit verification request Body (POST): { "tier": "identity|artist|business", "real_name": "Full Legal Name", "proof_urls": ["https://...", "https://..."], "additional_info": "Optional explanation" }

Body: { "listing_id": 42 } Requires authentication + CSRF. Deducts Cash from buyer, credits seller, transfers card ownership.

Body: { "listing_id": 42 } Requires authentication + CSRF. Only the seller can cancel their own active listing.

Returns full card data including creator info, holder list, and stack info.

Parameters

id e.g. 123

Body: { "card_id": 123, "price": 500 } Requires authentication + CSRF. Returns the newly created listing data.

Returns paginated list of cards with creator username. Supports optional type filter and sort order (newest, holders, oldest).

Parameters

type e.g. audio
sort e.g. newest
limit e.g. 20
offset

Public endpoint — no authentication required. Returns paginated list of active card listings with card + seller details. Supports filtering by card type and multiple sort options.

Parameters

type e.g. audio
sort e.g. newest
limit e.g. 20
offset

Body: { "card_type": "audio", "title": "My Track", "description": "...", "media_url": "...", "external_link": "..." } Requires authentication. Costs CARD_MINT_COST cash. Returns the newly minted card data.

filter=created — cards the user minted (default) filter=held — cards the user holds (collected) Requires authentication.

Parameters

filter e.g. created|held
limit e.g. 20
offset

Returns paginated list of cards stacked at the specified place.

Parameters

place_id e.g. 123
limit e.g. 50
offset

Body: { "card_id": 123, "place_id": 456, "fee": 10 } Requires authentication. Stacks the specified card at the given place with an optional fee for other users to collect.

Returns aggregate statistics about the card ecosystem: total cards minted, total holders, active stacks, etc.

Body: { "card_id": 123, "to_user_id": 456 } Requires authentication. Transfers ownership of a card from the current user to the specified recipient.

Parameters

land_id

Parameters

id
days

GET ?action=my-boosts: User's boost history (auth required) Returns: {success, boosts} POST action=create: Create a new boost (auth required) Body: {land_id, boost_type, days} Returns: {success, boost} POST action=cancel: Cancel an active/scheduled boost (auth required) Body: {boost_id} Returns: {success}

Parameters

action e.g. my-boosts:

Body: {"land_id": int} Returns PayPal order ID for JS SDK approval flow.

Body: {"land_id": int, "paypal_order_id": string} Captures the payment, verifies amount, and assigns land to user.

No description available.

Parameters

code

No description available.

Parameters

land_id

Parameters

id

Parameters

land_id
limit
offset

Parameters

q e.g. search
land_id
page e.g. 1
per_page e.g. 20
sort e.g. voting_power

Body: {"land_id": int, "album_ids": [int]} Only the land owner can set featured audio.

Body: {"land_id": int, "profile_ids": [int]} Only the land owner can set featured profiles.

sort — 'views' (default), 'newest', or 'featured' limit — max results (default 25, max 50) Returns ranked territories with owner info and metric values. Cached for 5 minutes via Cache-Control header.

Returns minimal data for all 10,000 territories: id, claimed (0/1), mine (0/1), display_name, owner_username World-map spine (mig 302): when lands.region_id/is_hub exist, ALSO returns per-tile region_id (int|null), is_hub (0/1) and a route-distance ring band (0..LAND_MAX_RING_BAND, null for untagged tiles). Ring = multi-source BFS from each region's hub over same-region grid adjacency (includes/land-graph.php; world-bible §2.2 — rings are a projection, never authored). BACKWARD-SAFE: if mig 302 is not yet applied, the region fields are simply omitted and the legacy response shape is returned unchanged. Optimized for the territory map canvas — only the fields needed for rendering. Response is cache-friendly (Cache-Control: 5 minutes).

Returns all lands owned by the authenticated user.

Parameters

code

days — lookback window in days (default 7, max 30) limit — max results (default 5, max 20) Returns the most-searched territory queries from the last N days.

Returns current pricing tier, price, sold count, and tier thresholds.

No description available.

limit — max results (default 10, max 20) Returns the most recently claimed territories with owner info.

No description available.

page — page number (default 1) per_page — results per page (default 50, max 100) filter — all|claimed|unclaimed (default all) search — search string

Returns full land details and tracks an impression event.

Parameters

id

Body: {"land_id": int, "event_type": "impression"|"click"} Hashes IP with sha256 for privacy. Auth optional for viewer tracking.

POST action=respond: Accept or decline a transfer Body: { action: "respond", token: string, response: "accept"|"decline" } Returns: { success, action, land_code } Requires auth. POST action=cancel: Cancel a pending transfer (sender only) Body: { action: "cancel", transfer_id: int } Returns: { success } Requires auth. GET action=pending: Get user's pending transfers Returns: { success, transfers: [] } Requires auth. GET action=check: Get transfer details by token (public) Returns: { success, transfer: {} } Public (token-based access).

Parameters

action e.g. pending
action e.g. check
token e.g. string

Body: {"land_id": int, "display_name": string, "description": string, "banner_url": string, "discord_link": string} Only the land owner can update.

Accepts multipart/form-data with fields: - land_id (int) — territory ID - banner (file) — image file (JPEG, PNG, WebP, GIF) Validates ownership, file type, size (<=5MB), dimensions (min 200x100, max 4000x2000). Resizes to max 1600px wide via GD. Deletes old local banner if replacing. Returns: { success: true, banner_url: "/assets/uploads/land/..." }

Community owners vote to promote or demote a card's visibility in the land's directory. Each community the user owns casts a vote weighted by that community's voting_power.

Community owners vote for who should control/represent a land. Each community the user owns in the land casts a vote weighted by that community's voting_power.

GET: Check if current user is watching. Returns: {watching, watcher_count} Requires authentication (session). Returns 401 if not logged in.

Parameters

land_id e.g. int

No description available.

Parameters

limit e.g. 10
offset

Public endpoint — no authentication required. Returns the list of valid categories with human-readable labels.

GET /api/directory/claim-request.php?verify=TOKEN — Email verification Rate limited: 3 requests per hour per IP. No auth required (public form).

Parameters

verify e.g. TOKEN

Body: { profile_id: int } Rate limit: 3/hour per user

Body: { "profile_id": 123 } Public endpoint — no authentication required. Increments the click counter and returns the profile's website URL.

Body: { name, email, subject, message } Public endpoint — NO authentication required. Rate limited: 3 submissions per 10 minutes per IP.

Body: { "entity_id": 123, "field_name": "phone", "suggested_value": "503-555-1234", "reason": "Number has changed", "submitter_email": "user@example.com" } CSRF: REQUIRED for logged-in users (X-CSRF-Token header) Rate limit: 10 requests per 300 seconds.

Body: { "title": "...", "url": "https://...", "description": "...", "logo_url": "https://...", "category": "music", "tags": ["hip-hop", "beats"] } Requires authentication. Creates a draft listing. Returns the listing.

Returns the user's directory profile, subscription, and analytics (impressions, clicks, CTR for last 30 days). Requires auth.

Public endpoint — no authentication required. Returns profile details with CRM fields stripped.

Parameters

id e.g. 123 or ?slug=some-slug

Public endpoint — no authentication required. Returns single entity or paginated entity list. Rate limit: 60 requests per 60 seconds.

Parameters

slug e.g. some-slug (single entity), ?id=123 (single), ?featured=1 (featured list), ?limit=24
offset

Parameters

entity_id e.g. X
period e.g. 30d

Body: { "entity_id": 123, "claimer_name": "...", "claimer_email": "...", "claimer_phone": "...", "claimer_role": "owner|manager|representative", "verification_method": "dns_txt|html_file|email|manual_review" } CSRF: REQUIRED (X-CSRF-Token header) Rate limit: 5 requests per 300 seconds. Returns claim data with verification instructions.

Body (multipart for upload): action=upload: entity_id, file (image upload) action=delete: entity_id, media_id action=reorder: entity_id, positions (JSON array of {media_id, position}) CSRF: REQUIRED (X-CSRF-Token header) Rate limit: 10 requests per 300 seconds.

Body: { "entity_id": 123, "name": "...", "tagline": "...", "short_description": "...", "long_description": "...", "website_url": "...", "phone": "...", "email": "...", "address_line_1": "...", "address_line_2": "...", "city": "...", "state_region": "...", "postal_code": "...", "country_code": "US", "hours_json": [...] } CSRF: REQUIRED (X-CSRF-Token header) Rate limit: 20 requests per 300 seconds.

No description available.

Public endpoint — no authentication required. Returns directory profiles with active featured_spot boosts from the ad engine. Used by the directory homepage "Featured" section.

Public endpoint — no authentication required. Returns active profiles with filters, sorted by newest/alphabetical/featured. Increments impression_count for returned profiles.

Parameters

category e.g. artist
city
country
monetization
featured e.g. 1
search
sort e.g. newest
limit e.g. 20
offset
recent e.g. 1

Public endpoint — no authentication required. Returns GeoJSON FeatureCollection for entities within bounding box. Rate limit: 20 requests per 60 seconds.

Parameters

sw_lat
sw_lng
ne_lat
ne_lng
category
type
verified

Requires authentication. Returns paginated list of user's listings (all statuses).

Parameters

limit e.g. 20
offset

Returns profiles where user_id matches current user OR submitter_email matches user's email.

Artists (or their representatives) can request removal of an auto-seeded profile. Rate limited: 3 requests per hour per IP. No auth required (public form).

Body: { "listing_id": 123 } Requires authentication. Must own the listing. Sets status to 'removed'. Cash is NOT refunded. Returns success.

No description available.

Public endpoint — no authentication required. Returns entities with faceted filters for category/city/type. Rate limit: 30 requests per 60 seconds.

Parameters

q
category
city
state
type
verified
lat
lng
radius_km
sort e.g. relevance
limit e.g. 24
offset

Public endpoint — no authentication required. Returns aggregate stats: total active, by category, recently added, countries.

Body: { submitter_name, submitter_email, name, website_url, primary_category, location_city, ... } Public endpoint — NO authentication required. Rate limited: 5 submissions per 5 minutes per IP. Honeypot: if `website` field (hidden) is filled, silently returns success (bot trap).

POST /api/directory/subscribe.php — Create directory subscription (auth required) POST body: { "profile_id": 123, "tier": "featured", "paypal_subscription_id": "I-xxx" }

Public endpoint — no authentication required. Returns lightweight suggestions for typeahead UI. Rate limit: 60 requests per 60 seconds.

Parameters

q e.g. port (min 2 chars)

Body: { "listing_id": 123, "cash_amount": 500 } Requires authentication. Must own the listing. Adds Cash to the listing to boost its ranking. Also extends the listing expiration by 30 days. Returns the updated listing.

Body: { "listing_id": 123, "title": "...", "url": "...", ... } Requires authentication. Must own the listing. Only draft or active listings can be updated. Returns the updated listing.

Body: { "website_id": 123, "method": "dns_txt"|"html_file"|"manual_review" } CSRF: REQUIRED (X-CSRF-Token header) Rate limit: Max 3 pending claims per user per hour Returns claim data with verification instructions.

Body: { "website_id": 123 } Validates the website exists and is published, increments click counter, and returns the official URL for redirect. Public endpoint — no authentication required.

User can only verify their own claims. Calls verifyClaim() to check DNS TXT record or HTML file, and returns the verification result.

Parameters

claim_id e.g. <int>
check e.g. 1

Returns website profile data, media entries, and checklist completion. Strips internal checklist_data state. Records an impression. Public endpoint — no authentication required.

Parameters

host e.g. <slug_or_host>

Cloned from /api/forum/channels.php with tier-gating via community-access.php.

Returns detailed information about a specific community identified by its 4-digit code. Returns 404 if the community does not exist.

Parameters

code

Creates a new community linked to a parent via neighbor code. Requires the user to be a member of the parent community and provide a valid neighbor code for the desired expansion slot.

No description available.

Cloned from /api/forum/history.php with tier-gating via community-access.php.

Parameters

channel_id
before
limit

Sets the user's primary community. If no community_id is provided, defaults to community 0000 (the default onboarding community).

No description available.

Returns a paginated list of active communities. Supports limit (max 100, default 20) and offset parameters.

Cloned from /api/forum/members.php with tier-gating via community-access.php. Params: channel_id (optional) -- If provided, only members who've posted in that channel

Parameters

channel_id

Returns the user's primary community assignment and available neighboring communities they can interact with or expand into.

No description available.

Cloned from /api/forum/messages.php with tier-gating via community-access.php. Params: channel_id (required) -- Discord channel ID after (optional) -- Message ID to fetch messages after (delta fetch)

Parameters

channel_id
after

Cloned from /api/forum/pinned.php with tier-gating via community-access.php.

Parameters

channel_id

Body (JSON): { "message_id": "...", "channel_id": "...", "emoji": "...", "action": "add"|"remove" } Cloned from /api/forum/reactions.php with tier-gating via community-access.php.

Returns: { activities: [...], stats: { messages_today: N, active_channels: N } }

Parameters

q
channel_id
limit

Body (JSON): { "channel_id": "...", "content": "..." } Body (multipart): channel_id, content (optional if attachment present), attachment (file) Cloned from /api/forum/send.php with tier-gating via community-access.php.

Body (JSON): { "channel_id": "..." } Cloned from /api/forum/typing.php with tier-gating via community-access.php.

Parameters

user_id e.g. X or ?community_id=X or ?global=1

DELETE /api/social/follow.php — Unfollow a user Body: { "user_id": 123 } POST: Rate limited (30 per 5 minutes). Creates a follow relationship. DELETE: Removes the follow relationship. Requires authentication + CSRF.

Returns paginated list of followers for the specified user. If the viewer is logged in, includes an isFollowing flag for each follower. Requires authentication.

Parameters

user_id e.g. 123
limit e.g. 20
offset

Returns paginated list of users the specified user follows. Requires authentication.

Parameters

user_id e.g. 123
limit e.g. 20
offset

POST /api/social/friends.php — Send a friend request PUT /api/social/friends.php — Accept or decline a friend request DELETE /api/social/friends.php — Remove a friend GET: Default: returns accepted friends for the current user. ?pending=1: returns pending friend requests for the current user. Optional: ?limit=20&offset=0 POST: Body: { "user_id": 123 } Rate limited: 20 per 5 minutes. PUT: Body: { "friendship_id": 456, "action": "accept" | "decline" } DELETE: Body: { "friendship_id": 456 } Requires authentication + CSRF.

Parameters

pending e.g. 1:

DELETE /api/social/team-members.php — Leave or kick a member POST actions: { "action": "join", "team_id": 123 } — Join a public team { "action": "invite", "team_id": 123, "user_id": 456 } — Invite a user to a team { "action": "respond", "invite_id": 789, "response": "accepted" | "declined" } — Respond to invite DELETE actions: { "action": "leave", "team_id": 123 } — Leave a team { "action": "kick", "team_id": 123, "user_id": 456 } — Kick a member (owner/admin only) Requires authentication + CSRF.

POST /api/social/teams.php — Create a new team PUT /api/social/teams.php — Update an existing team GET: ?id=123 — Get single team detail via getTeam() ?invites=1 — Get pending team invites for the current user Default — Get all teams for the current user Optional: ?limit=20&offset=0 POST: Body: { "name": "...", "team_type": "...", "description": "...", "avatar_url": "..." } Rate limited: 5 per hour. name and team_type are required; description and avatar_url are optional. PUT: Body: { "team_id": 123, "name": "...", "description": "...", "avatar_url": "..." } team_id is required; other fields are optional updates. Requires authentication + CSRF.

Parameters

id e.g. 123

Response: { success: true, balance: int, is_locked: bool, total_earned: int, total_spent: int }

Request body: { order_id: "PAYPAL_ORDER_ID", pack_key: "starter"|"growth"|... } Response: { success: true, credits: int, balance: int }

Request body: { pack_key: "starter"|"growth"|"pro"|"scale"|"mega" } Response: { success: true, order_id: "PAYPAL_ORDER_ID" }

limit (int, 1-100, default 20) offset (int, default 0) Response: { success: true, entries: [...], total: int }

Response: { success: true, packs: { starter: {...}, growth: {...}, ... } }

Request body: { amount: int, type: string, source_ref?: string, metadata?: object } Response (success): { success: true, amount: int, new_balance: int, ledger_id: int } Response (error): { success: false, error: "insufficient_credits"|"spend_locked"|"invalid_amount", ... } HTTP codes: 200 success, 400 invalid, 402 insufficient, 423 locked

Parameters

limit
offset

No description available.

No description available.

Body: {"to_username": "bob", "amount": 1000, "memo": "optional note"} Response: {"success": true, "from_balance": 9000, "to_balance": 1000, "transaction_id": 42}

Params: domain (optional) — Filter by target domain type (optional) — Filter by boost_type (default: network_banner) geo (optional) — Filter by target_geo (e.g., "portland", "us-west") category (optional) — Filter by target_category (e.g., "music", "events") Targeting columns (in ad_boosts table): target_geo — if set, only show when ?geo= or ?domain= matches. NULL = show everywhere. target_category — if set, only show when ?category= matches. NULL = show everywhere. target_hours — if set (format "HH-HH" like "18-24"), only show during those UTC hours. NULL = always. Returns: { success: true, ads: [{ id, boost_type, title, description, creative_url, target_url, alt_text, cta_text, cta_url, advertiser_name, impressions_today }] }

No description available.

boost_id=123 — analytics for a specific boost period=7|30 — time-series data for N days (requires boost_id) (omit) — aggregate analytics for all user's boosts

No description available.

POST /api/network/boost.php — Create a new boost (auth required) PUT /api/network/boost.php — Pause or resume a boost (auth required) POST body: { "type": "directory_boost|featured_spot|network_banner", "target_id": 123, "credits_per_day": 10, "daily_budget": 0, "ends_at": "2026-03-31 23:59:59", "starts_at": "2026-03-01", "target_geo": "portland-or", "target_category": "music-production", "target_hours": "18-24" } PUT body: { "boost_id": 1, "action": "pause|resume" }

POST /api/network/credits.php — Purchase credits (auth required) POST body: { "package": "starter|growth|scale", "paypal_order_id": "xxx" }

Public endpoint (no authentication required). Response is cached for 1 hour via Cache-Control header.

Accepts JSON body: { boost_id: int, type: "impression"|"click", domain: string, variant?: "a"|"b" } Returns: { success: true } Security: - Rate limited: 100 events per 60 seconds per IP. - Impression dedup: Same IP + boost + day = skip (stealth 200, not stored). - Clicks are NOT deduped (user may legitimately click multiple times). - Max request body size: 1 KB. - Domain validated as legitimate hostname. CORS: allows any origin for SDK compatibility (write-only endpoint).

Body: { "domain": "hiphop.audio", "status": "ok", "version": "1.0", "uptime": 12345 } Validates that the domain is in the SSO allowed domains list, then upserts into the network_heartbeats table.

matching HHW .env SYNC_API_KEY. Missing or mismatched key → 401. Expected payload (application/json): { "site_key": "obbium", // REQUIRED, must exist in engine_sites "source_user_id": 123, // OPTIONAL, site-local PK "email": "a@b.com", // REQUIRED "display_name": "Jane Doe", // OPTIONAL "role": "member", // OPTIONAL, default "member" "is_admin": 0, // OPTIONAL, default 0 "visibility": "public", // OPTIONAL, default "unlisted" "action": "create" // "create" | "update" | "delete" } Response: 200 { "success": true, "member_id": 42, "action": "upserted|deactivated" } 4xx { "success": false, "error": "..." } Stub status: file is live but no consumer site calls it yet — this is the Wave 3 delivery handle for the Membrane System. Local member-kit hooks will start POSTing here once the rollout switch is flipped.

Response is shaped EXACTLY like iaas_users rows: { members: [{name, rank, role, bio, tagline, disciplines, visibility}] } so community.php's array_merge() works unchanged. - Anonymous for ?visibility=public (read-only public view, CDN-cacheable) - Bearer SYNC_API_KEY for admin/full queries ?visibility=public Only public members (default) ?site_key=KEY Scope to members who have a facet on this site ?rank=officer Filter by ARC rank ?limit=50 Max results (1-200, default 50) ?search=term Name/email search Cache-Control: public, max-age=300 for anonymous public queries. Part of: SOUL_SYSTEM Body Layer → Membrane System

Parameters

visibility e.g. public

Request body: { "order_id": "PAYPAL_ORDER_ID", "package": "starter|growth|scale" } Response: { "success": true, "credits": 250, "balance": 500 }

Request body: { "package": "starter|growth|scale" } Response: { "success": true, "order_id": "PAYPAL_ORDER_ID" }

Accepts: { hw_user_id, email, display_name, avatar_url, phone, site_domain } Updates only non-null, non-empty fields (satellite sites cannot blank out HHW fields) Logs to network_activity with action = 'profile_synced'

Auth required: session-based (requireApiAuth). Returns Content-Type: text/html for display in new tab or iframe. Includes print-friendly CSS (@media print) for clean printing. Security: - User must be logged in (401 if not) - User must own the purchase (403 if not) - purchase_id must be a valid integer (400 if not) <!-- Dashboard link: <a href="/api/network/receipt.php?purchase_id=ID" target="_blank">Receipt</a> -->

Parameters

purchase_id

GET /api/network/status.php ?client_slug=X — filter to a specific client's sites Returns JSON: {overall, activeIncidents, sites[], resolvedIncidents[]}

Parameters

client_slug e.g. X

PayPal redirects the user here after they approve (or cancel) a subscription on PayPal's hosted approval page. This endpoint verifies the subscription state via the PayPal API and activates it locally, then redirects the user to the billing page with an appropriate success or error parameter. Method: GET (PayPal redirect — no CSRF, no JSON body) Params: subscription_id (GET query string, set by PayPal)

Cancels the subscription both on PayPal and in the local database. Access to premium features continues until the end of the current billing period (expires_at), after which the subscription transitions to 'expired'. Rate limit: 3 per hour (subscription_cancel) Body: {} (no body required) Response: {"success": true, "message": "Subscription cancelled. Access continues until ..."}

One-time setup endpoint that creates a PayPal Product and one Plan per tier/interval combination, then stores all plan IDs in the paypal_plans table. Idempotent when called with {"force": true} to recreate plans (e.g. after pricing changes). Method: POST only Body: {} or {"force": true} to override existing plan check Response: { "success": true, "product_id": "PROD-...", "plans": [ {"tier": "place", "interval": "month", "plan_id": "P-..."}, ... ] }

Returns the current subscription tier, full subscription record, recent payment history, and a feature-gate access map so the client can make gating decisions without additional round-trips. Method: GET only Response: { "success": true, "tier": "community", "tier_info": { ...SUBSCRIPTION_TIERS entry... }, "status": "active", "current_period_end": "2026-03-16T00:00:00", "subscription": { ...full record or null... }, "recent_payments": [ ...last 5... ], "feature_access": { "featureKey": true|false, ... } }

Initiates a PayPal subscription for the authenticated user, redirecting them to PayPal's hosted approval page. On success, returns an approve_url for the client to redirect to. Rate limit: 5 per hour (subscription_create) Body: {"tier": "place|community|land", "interval": "monthly|annual"} Response: {"success": true, "approve_url": "https://..."}

Parameters

date

No description available.

No description available.

Parameters

limit e.g. 20
offset

Requires authentication (logged-in user).

Parameters

id e.g. ...

No description available.

Also serves as /.well-known/jwks.json (via .htaccess rewrite)

Returns: { access_token, token_type, expires_in, refresh_token?, scope }

RFC: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo

Cost: 2 credits Body: {lyrics: string, mode: "draft"|"premium"|"triple_threat"} Returns: rhyme scheme analysis, syllable count, flow rating

GET ?action=list&type=image&favorited=1&search=...&limit=30&offset=0 POST action=favorite {asset_id} POST action=tag {asset_id, tags: [...]} POST action=delete {asset_id}

Parameters

action e.g. list
type e.g. image
favorited e.g. 1
search e.g. ...
limit e.g. 30
offset

Body (JSON): { "prompt": string, "bpm": int|null (60-200), "key": string|null ("C minor", "F# major", etc.), "subgenre": "boom_bap"|"trap"|"drill"|"lo_fi"|"jazz_rap"|"west_coast"|"soul"|"afrobeats", "density": float|null (0.0-1.0), "brightness": float|null (0.0-1.0), "duration": 15|30|60, "mode": "draft"|"premium" }

Body (JSON): { "prompt": string, "campaign_type": "single_release"|"album_rollout"|"tour"|"brand"|"event", "platforms": ["instagram","tiktok","twitter","email","youtube"], "timeline_days": int (7-90), "mode": "premium"|"triple_threat", "conversation_id": int|null } Campaigns always require premium mode minimum (75 base credits).

Body (JSON): { "conversation_id": int|null, "message": string, "mode": "draft"|"premium"|"triple_threat", "stream": bool } Supports SSE streaming, auto-classification, artist DNA personalization, conversation auto-creation, and Triple Threat multi-model consensus.

Parameters

id e.g. N

Parameters

history e.g. 1

Rate limited: 3 attempts/hour/IP Returns: {success, token, credits, expires_at}

GET — returns current artist DNA profile + generation stats POST {style_tags, vocabulary_level, influences, thematic_preferences, tone_profile, writing_samples}

Body (JSON): { "asset_id": int, // existing image asset to edit "edit_prompt": string, // what to change "aspect_ratio": "1:1"|..., "resolution": "1K"|"2K"|"4K" }

Body (JSON): { "prompt": string, "style": "album_cover"|"promotional"|"portrait"|"abstract"|"vintage", "aspect_ratio": "1:1"|"16:9"|"9:16"|"3:2"|"2:3"|"4:3"|"3:4"|"4:5"|"5:4", "resolution": "1K"|"2K"|"4K", "mode": "draft"|"premium" }

Body (JSON): { "prompt": string, "style": "freestyle"|"verse"|"hook"|"chorus"|"full_song", "mood": string|null, "bpm": int|null, "references": string|null, "mode": "draft"|"premium"|"triple_threat", "conversation_id": int|null }

GET ?action=list&status=draft GET ?action=get&id=123 POST action=create {title, product_type, card_type, ...} POST action=update {id, title?, description?, status?, ...} POST action=add_asset {product_id, asset_id, role} POST action=remove_asset {product_id, asset_id} POST action=submit {product_id} → bridges to card template POST action=quick_card {asset_id, title?, card_type?, price?, royalty_pct?} POST action=delete {id}

Parameters

action e.g. list
status e.g. draft

GET ?action=list&status=active GET ?action=get&id=123 GET ?action=assets&id=123 GET ?action=presets&tool=lyrics POST action=create {title, project_type, description} POST action=update {id, title?, description?, status?} POST action=delete {id} POST action=add_asset {project_id, asset_id, role} POST action=remove_asset {project_id, asset_id} POST action=reorder {project_id, asset_ids: [...]} POST action=save_preset {name, tool_type, config} POST action=delete_preset {preset_id} POST action=share {id} → generate share slug, mark public POST action=unshare {id} → mark private

Parameters

action e.g. list
status e.g. active

GET ?action=list&project_id=N → tracks with joined asset data GET ?action=get&id=N → single track with full details POST action=create { project_id, title } POST action=update { id, title?, beat_asset_id?, lyrics_asset_id?, voice_asset_id?, video_asset_id?, notes? } POST action=delete { id } POST action=reorder { project_id, track_ids: [3, 1, 2] }

Parameters

action e.g. list
project_id e.g. N

Body (JSON): { "text": string (max 500 chars), "voice": "kore"|"puck"|"charon"|"fenrir"|"aoede"|"leda"|"orus"|"zephyr" (Gemini voices) "mode": "draft"|"premium" } Primary: Gemini TTS → Fallback: ElevenLabs

GET /api/ai/video.php?action=poll&asset_id=N — Poll video generation status POST Body (JSON): { "prompt": string, "aspect_ratio": "16:9"|"9:16", "resolution": "720p"|"1080p", "duration": 4|6|8 }

Parameters

action e.g. poll
asset_id e.g. N

Params: ?sellToken=...&buyToken=...&sellAmount=...&chainId=1 Reads ZEROX_API_KEY from $_ENV. Forwards to https://api.0x.org/swap/v1/quote with API key header. Returns raw 0x response (contains pre-built transaction). Requires session auth.

Parameters

sellToken e.g. ...
buyToken e.g. ...
sellAmount e.g. ...
chainId e.g. 1

Requires session auth.

Returns historical HH Cash pricing based on completed orders.

Parameters

days e.g. 30 (1, 7, 30, 90, 365)

Accepts ?ids=bitcoin,ethereum,solana (CoinGecko IDs). Caches in exchange_price_cache table with 5-minute TTL. Public endpoint (no auth required), 60-second cache floor.

Parameters

ids e.g. bitcoin,ethereum,solana

POST: Insert new swap record Body: { chain, from_token, to_token, from_amount, to_amount, tx_hash, status, slippage_bps, swap_source } PATCH: Update swap status (e.g., initiated -> confirmed) Body: { id, status, tx_hash?, to_amount? } Requires session auth + CSRF.

GET: Returns user's watchlist from exchange_watchlists table POST: Adds token to watchlist (body: { chain, token_address, token_symbol, token_name }) DELETE: Removes token (body: { id } or { chain, token_address }) Requires session auth + CSRF for POST/DELETE.

Uses shared cash_wallets table from migration 021.

Body: {"amount_usd": 10.00, "crypto_currency": "eth"} Response: {success: true, order: {...}}

Response: {order: {order_ref, status, cash_amount, ...}}

Parameters

order_ref

Params: ?limit=20&offset=0 Response: {orders: [...], total: int}

Parameters

limit e.g. 20
offset

Response: {prices: {eth: 3000.00, ...}, user_rate: 0.10, discount: {community: 0, membership: 0, total: 0}}

Body: {"order_ref": "ORD-A1B2C3D4", "tx_hash": "0x..."} Response: {success: true, order: {...}}

Uses shared cash_wallets + cash_transactions tables from migration 021. page (default 1) limit (default 20, max 50) type (all | received | sent)

JSON body: sc2pulse_clan_id (int, required) — SC2Pulse clan ID clan_tag (string, required) — Clan tag (max 20 chars) clan_name (string, required) — Clan name (max 100 chars) reason (string, optional) — Why this clan should be tracked Requires login + CSRF. Inserts into clan_requests and approval_queue.

?action=list — List all cached clans ?action=get&id=<id> — Get a specific clan + members ?action=sync&id=<id> — Trigger sync for a clan (admin only) ?action=races&id=<id> — Get race distribution for a clan

Parameters

action e.g. list

?action=get&id=<external_id> — Get cached player data ?action=search&q=<query> — Search cached players by name/tag ?action=claim&player_id=<local_id> — Claim a gaming profile (requires login + Battle.net match) ?action=mmr-history&id=<external_id> — Get MMR history (syncs if empty) ?action=live&id=<external_id> — Fetch live data from SC2Pulse ?action=matches&id=<external_id> — Get match history from SC2Pulse

Parameters

action e.g. get
id e.g. <external_id>

?q=<query> — Search SC2Pulse for clans by name/tag (min 2 chars) Requires login. Returns enriched results with local tracking/request status.

Parameters

q e.g. <query>

No description available.

GET?action=bans — list guild bans GET?action=audit_log — view audit log POST action=unban — unban a user POST action=timeout — timeout a user POST action=remove_timeout — remove timeout

Parameters

action e.g. bans

No description available.

No description available.

No description available.

No description available.

Parameters

channel_id
before
limit

Uses the user's stored Discord OAuth2 access token.

Params: channel_id (optional) — If provided, only members who've posted in that channel

Parameters

channel_id

No description available.

Params: channel_id (required) — Discord channel ID after (optional) — Message ID to fetch messages after (delta fetch)

Parameters

channel_id
after

Parameters

channel_id

Body (JSON): { "message_id": "...", "channel_id": "...", "emoji": "...", "action": "add"|"remove" }

Params: q (required) — Search query (min 2 chars) channel_id (optional) — Filter by channel limit (optional) — Max results (default 20, max 50)

Parameters

q
channel_id
limit

Body (JSON): { "channel_id": "...", "content": "..." } Body (multipart): channel_id, content (optional if attachment present), attachment (file)

No description available.

Parameters

chat_id e.g. <telegram_chat_id>
limit e.g. 50
before e.g. <id>
after e.g. <id>

No description available.

Body: { "channel_id": "...", "visible": true/false }

Body (JSON): { "channel_id": "..." }

Body: { "lyrics_id": 1, "start_offset": 0, "end_offset": 50, "selected_text": "highlighted text", "annotation_text": "This means..." } Requires authentication. Creates a pending annotation for review.

Returns all approved annotations for the specified lyrics.

Parameters

lyrics_id e.g. 1

Body: { "song_id": 1, "content": "[VERSE 1]\nLyrics here..." } Requires authentication. Creates or updates lyrics entry for the song.

Returns previous versions of the lyrics content.

Parameters

lyrics_id

Body: { "annotation_id": 1, "direction": "up" } Requires authentication. Prevents self-voting (handled by voteAnnotation).

Requires authentication (session or Bearer token). domain = optional domain key filter (e.g. 'audio', 'cards') limit = max results (default 50, max 100) offset = pagination offset (default 0) Returns: {success: true, files: [...], quota: {...}, pagination: {limit, offset, total}}

Public endpoint — no session required, but requires a valid HMAC-signed URL. id = file ID exp = expiration timestamp sig = HMAC signature Serves the file with correct Content-Type, Content-Disposition, and cache headers.

Requires authentication (session or Bearer token). Accepts multipart/form-data with: - file: the uploaded file (required) - domain_key: category key e.g. 'audio', 'cards', 'documents' (optional, defaults to 'general') - metadata: JSON string of extra metadata (optional) Returns: {success: true, file: {id, original_name, mime_type, file_size, signed_url, created_at}}

Body (single): { "id": 123 } Body (mark all): { "all": true } Requires authentication.

If the user is not logged in, returns {authenticated: false, count: 0}.

Body: { site_id, name, email, event_type, event_date, message, phone?, venue?, budget_range?, expected_attendance?, organization?, location? }

Parameters

status e.g. new|read|replied|booked|declined

Parameters

slug e.g. )

No description available.

No description available.

No description available.

Parameters

module_type e.g. music|videos|gallery

No description available.

No description available.

No description available.

Parameters

username

No description available.

No description available.

No description available.

Handles: PAYMENT.CAPTURE.COMPLETED — recovery grant if capture-order failed PAYMENT.CAPTURE.REFUNDED — partial/full refund reversal (delta-based) PAYMENT.CAPTURE.REVERSED — chargeback reversal (delta-based) Method: POST only CORS: None (server-to-server) Uses a separate webhook ID (PAYPAL_CREDITS_WEBHOOK_ID) from the main subscription webhook to keep concerns separated.

Receives and responds to Discord Application Command interactions (slash commands) and message component interactions (buttons, selects). Discord requires: 1. Ed25519 signature verification on every request (401 if invalid) 2. Respond to PING (type 1) with {"type": 1} 3. Respond within 3 seconds (or defer with type 5) Method: POST only CORS: None (server-to-server)

Receives server-to-server event notifications from PayPal. Verifies the webhook signature using PayPal's verification API, then dispatches each event to the appropriate handler. Always returns HTTP 200 so PayPal does not retry on application-level errors (per PayPal webhook best practices). Method: POST only CORS: None (server-to-server) CSRF: None (not browser-initiated) Supported event types: BILLING.SUBSCRIPTION.ACTIVATED PAYMENT.SALE.COMPLETED BILLING.SUBSCRIPTION.CANCELLED BILLING.SUBSCRIPTION.EXPIRED BILLING.SUBSCRIPTION.SUSPENDED BILLING.SUBSCRIPTION.PAYMENT.FAILED

Receives updates from the Telegram Bot API. Verifies the secret token header, dispatches commands, and always returns HTTP 200. Method: POST only CORS: None (server-to-server) CSRF: None (not browser-initiated)

POST data: site_key: string (required) — site to scan (e.g. 'hiphop.world') Returns JSON: success: bool score: int (0-100) details: array with pillar scores checks_run: int duration_ms: int timestamp: string (ISO 8601)

No description available.

GET ?from=YYYY-MM-DD&to=YYYY-MM-DD&site_key=X&metrics=messages,sla,intent,queue,peak_hours GET ?export=csv

Parameters

from e.g. YYYY-MM-DD
to e.g. YYYY-MM-DD
site_key e.g. X
metrics e.g. messages,sla,intent,queue,peak_hours

Body: { "template_id": 123 } Admin auth required. Approves a pending template, sets it to 'active', and mints the creator's first card. Returns template and card data.

Body: { "verification_id": 123 }

Sets hd_vertical_music.is_official (migration 342). That flag is the ONLY gate on the official-releases showcase on /artist/{slug}, and it is MANUALLY CURATED by design — an owner decision. There is no auto-detection here and there must not be: is_verified unset legacy seed data from the 2026-04 import users.verified_tier the self-serve proof-of-ownership path — anyone can earn it users.is_site_founder one person, not a class 🔴 The column may not exist yet (342 is written, not applied). This endpoint says so in plain words with a 503 instead of returning a 500 out of a swallowed PDOException — an admin clicking a switch that silently does nothing is exactly how a missing migration hides for weeks.

No description available.

GET requests: (no params) — Returns booking_config (7 day rows) + booking_blackout dates POST requests (JSON body): {"action": "update_schedule", "day_of_week": 1, "start_time": "09:00", "end_time": "17:00", "is_available": 1} {"action": "add_blackout", "date": "2026-03-15", "reason": "Holiday"} {"action": "remove_blackout", "date": "2026-03-15"}

Fetches the live HTML of a registered site, runs the AlignmentScanner, and returns formatted pass/fail/warning results. Body: { "site_key": "..." } Returns: { success: true, data: { results: [...], summary: { pass: N, fail: N, warn: N } } }

Exports brand configuration in three formats: - CSS: Custom properties in :root {} block - Tailwind: tailwind.config.js theme extend - Figma: Figma Tokens JSON (color, typography, spacing groups)

Parameters

site_key e.g. X
format e.g. css|tailwind|figma

Parameters

site_key e.g. X

No description available.

Loads brand config, phase data, and assets, then generates a brand-guidelines PDF via the Pdf component and streams it as a downloadable attachment.

Parameters

site_key

GET ?site_key=X — Loads saved brand config from DB and renders preview POST (form data) — Uses posted color/font values for live preview (unsaved data) Renders a complete HTML page with: - Brand fonts (Google Fonts link) - Brand CSS variables - brand_header(), brand_hero(), sample cards, brand_footer()

Parameters

site_key e.g. X

GET - List all quotes (optionally filtered by ?site_key=X) POST - Generate a new quote for a site (auto-calculates from brand phases) PATCH - Update quote status (takes quote_id, status, optional notes)

Parameters

site_key e.g. X)

Fetches the live HTML of a registered site and extracts Phase 2 visual identity: - Colors: hex/rgb from CSS/inline styles, mapped to primary/secondary/accent/bg/surface/text - Fonts: font-family declarations, Google Fonts links, heading vs body - Logo: <img> with "logo" in src/alt/class/id - Favicon: <link rel="icon"> or <link rel="shortcut icon"> Saves to: engine_sites.branding, engine_brand_phases (Phase 2), engine_brand_assets Body: { "site_key": "..." } or form POST with site_key

Multipart/form-data with: - site_key (required) - asset_type (required: logo, logo_mark, favicon, icon_set, og_image, hero_image, pattern) - file (required: uploaded image file) - label (optional) - csrf_token (required) Admin auth required.

No description available.

Parameters

status e.g. pending|approved|rejected|auto_approved
page e.g. 1

GET requests: ?summary=1 — Stats summary + per-site counts ?limit=50&offset=0 — List consultations ?site_key=X — Filter by site ?status=pending — Filter by status ?upcoming=1 — Upcoming only (preferred_date >= today) ?upcoming=0 — Past only (preferred_date < today) PATCH requests (JSON body): {"id": 123, "status": "confirmed"} — Update status {"id": 123, "admin_notes": "Called client"} — Update admin notes {"id": 123, "status": "confirmed", "admin_notes": "..."} — Update both

Parameters

summary e.g. 1

Parameters

user_id e.g. 123

POST /api/admin/crypto-orders.php — Admin actions on orders (admin only) GET params: action=stats Return aggregate stats action=list (default) Return filtered/paginated order list status, crypto_currency, search, date_from, date_to, limit, offset, sort, order POST body: {"action": "complete|fail|expire|retry", "order_id": 123, "reason": "optional note"}

No description available.

GET actions: ?action=tree — Full hierarchical category tree ?action=get&id=123 — Single category detail POST actions (require CSRF): action=create — Create category (name, slug, parent_id, color, icon, description) action=update — Update category (category_id, ...fields) action=delete — Delete category (category_id) — fails if entities assigned action=reorder — Reorder categories (positions: [{category_id, position}])

Parameters

action e.g. tree

GET: List pending claims via getPendingClaims() ?limit=50 POST actions (require CSRF): action=approve — Approve a pending claim (claim_id required) action=reject — Reject a pending claim (claim_id required, optional reason)

Parameters

limit e.g. 50

GET actions: ?action=list — Paginated entity list (filterable: status, category, city, type, search, verified) ?action=get&id=123 — Single entity detail ?action=queue — Pending moderation queue ?action=corrections — Pending corrections queue POST actions (require CSRF): action=approve — Approve pending entity (entity_id) action=reject — Reject pending entity (entity_id, reason) action=flag — Flag entity for review (entity_id, reason) action=merge — Merge duplicate into canonical (canonical_id, duplicate_id) action=update — Admin update entity fields (entity_id, ...fields) action=delete — Soft-delete entity (entity_id)

Parameters

action e.g. list

GET actions: ?action=list&entity_id=123 — List publications for an entity ?action=sites — List all publication target sites POST actions (require CSRF): action=publish — Publish entity to a site (entity_id, site_key) action=unpublish — Remove entity from a site (entity_id, site_key) action=feature — Toggle featured status on a site (entity_id, site_key) action=auto-publish — Auto-publish entity to all eligible sites (entity_id)

Parameters

action e.g. list
entity_id e.g. 123

GET: List websites with optional filters: ?status=<status>&category=<cat>&search=<term>&is_featured=0|1&limit=25&offset=0 POST actions (require CSRF): action=create — Seed a new website entry action=update — Update website fields action=toggle_featured — Toggle is_featured flag action=toggle_verified — Toggle is_verified flag

Parameters

status e.g. <status>
category e.g. <cat>
search e.g. <term>
is_featured e.g. 0|1
limit e.g. 25
offset

GET actions: ?action=pending — List pending profiles ?action=list — List all profiles with filters (status, category, score_min, qualifier, monetization, search, email, sort, limit, offset) ?action=stats — Aggregate stats + pending count ?action=export-csv — CSV export with filters POST actions (require CSRF): action=approve — Approve a pending profile action=reject — Reject a pending profile (with reason) action=update-crm — Update CRM/intelligence fields action=toggle-featured — Toggle featured flag action=toggle-verified — Toggle verified flag

Parameters

action e.g. pending

Body: { "action": "create"|"edit"|"delete", ... }

No description available.

No description available.

Body: { "action": "sync" }

POST /api/admin/discord/channels.php — Create a channel PUT /api/admin/discord/channels.php — Modify a channel DELETE /api/admin/discord/channels.php — Delete a channel

PUT /api/admin/discord/members.php — Modify member roles DELETE /api/admin/discord/members.php — Kick or ban a member

DELETE /api/admin/discord/messages.php — Delete a message POST /api/admin/discord/messages.php — Pin or unpin a message

No description available.

No description available.

No description available.

GET /api/admin/domain-health.php — returns JSON payload matching $domainMapData structure

GET (admin): {site_key} — Return current onboarding status + data

Returns: {success, logs: [...], total, page, per_page}

POST /api/admin/enterprise.php — Admin actions on partners (admin only) GET params: action=list (default) Return filtered/paginated partner list action=stats Return aggregate stats action=detail&id=X Return full partner details including API keys and OAuth clients status, tier, search, limit, offset POST body: {"action": "approve|suspend|activate|update_tier|top_up", "partner_id": 123, ...}

Parameters

action e.g. list

GET list — optional filters: ?slug=, ?skill_affected=, ?status=, ?severity= GET detail — ?slug=<slug> returns a single row POST create — admin auth + CSRF; creates a new failure mode row PATCH update — admin auth + CSRF; increments occurrence_count, bumps last_seen_at, and optionally updates status / notes All responses include X-API-Version: v1 via jsonResponse().

Parameters

slug e.g. ,

No description available.

GET: (no params) — list all featured artists (active + inactive) ?action=search&q= — search entities to add POST: ?action=add — add entity to featured list ?action=remove — remove entity from featured list ?action=reorder — reorder featured list ?action=toggle — toggle is_active

Parameters

action e.g. search
q

Endpoints: POST ?action=save — Save or update a filter preset GET ?action=list — List all presets for current admin DELETE ?preset_id=X — Delete a specific preset Requires admin authentication and CSRF token for POST/DELETE.

Parameters

action e.g. save

Distributes Hip Hop Cash from the treasury wallet to a user. Used after a user purchases Cash with USD/crypto. Body: {"to_username": "bob", "amount": 10000, "memo": "Cash purchase - Invoice #123"} Response: {"success": true, "to_balance": 10000, "treasury_balance": 999990000, "transaction_id": 42}

GET: ?action=pending — entities pending moderation ?action=stats — overall hub statistics POST: ?action=approve_entity — approve entity + assign region ?action=reject_entity — reject entity ?action=assign_region — assign entity to region ?action=add_lineup — add artist to show lineup DELETE: ?action=remove_lineup — remove artist from lineup

Parameters

action e.g. pending

Endpoints: - POST ?action=assign — assign message(s) to user or queue - GET ?action=list_queues — list available queues + admin users - GET ?action=list_assignments — list current assignments with metadata - POST ?action=auto_assign — batch auto-assign based on intent type - POST ?action=update — update assignment notes - DELETE ?action=unassign — remove assignment

Parameters

action e.g. assign

POST requests (JSON body): {"action":"add_tag","submission_id":1,"tag_name":"urgent","color_hex":"#FF5733"} {"action":"remove_tag","submission_id":1,"tag_name":"urgent"} {"action":"bulk_tag","submission_ids":[1,2,3],"tag_name":"urgent","color_hex":"#FF5733"} GET requests: ?action=list — list all tags with counts ?action=autocomplete&q=pre — autocomplete tag names DELETE requests: ?tag_name=X — delete tag definition from tag_metadata

Parameters

action e.g. list

Requires admin session authentication.

Parameters

action e.g. queue

Returns JSON with signups, first_purchase_conversion, land_claims, credit_purchases, founder_slots, directory_stats, recent_signups, error_rate, generated_at. Also used as a library: when LAUNCH_METRICS_LIB_ONLY is defined, only the buildLaunchMetrics() function is loaded (no HTTP handler).

Response: { success: true, metrics: { funnel, totals, recent_7d, rates, top_artists, top_tracks } }

GET ?tab=shares|comments|reports&status=<status>&page=1 -> 200 { success:true, tab, status, page, counts:{...}, rows:[...] } shares/comments status ∈ pending|approved|hidden reports status ∈ open|actioned|dismissed POST { action, ... } — CSRF required { action:'approve_share'|'hide_share', id } -> {success, id, moderation_status} { action:'approve_comment'|'hide_comment', id } -> {success, id, moderation_status, aggregates} { action:'ban_member', user_id } -> {success, user_status:'suspended', shares_hidden, comments_hidden} { action:'dismiss_report', id } -> {success, status_now:'dismissed'} Approving/hiding a share or comment also auto-closes ('actioned') every open report pointing at it, so the reports tab drains as the moderator works. NOTHING here deletes. Every action writes moderation_status / users.status and is reversible (the mig 289/290 lesson, encoded).

Parameters

tab e.g. shares|comments|reports
status e.g. <status>
page e.g. 1

Parameters

action e.g. stats

Table name `1vs_categories` is backticked at every reference because it starts with a digit.

Parameters

tree e.g. 1

?format=matrix (default) — { sites, kits, matrix: { site_key: {kit: 1|0} } } ?format=catalog — { sites: [ {site_key, domain, site_type, status, kits{}, feature_tags[]} ] } ?site=<site_key> — filters either response to a single site Always returns X-API-Version: v1 via jsonResponse(). Pure GET; no mutations.

Parameters

format e.g. matrix

GET requests: ?view=all&limit=50&offset=0&status=new ?view=site&site_key=X&limit=50&offset=0 ?summary=1 POST requests (JSON body): {"action":"mark_status","ids":[1,2,3],"status":"read"} {"action":"toggle_exclude","site_key":"oregontires"}

Parameters

view e.g. all
limit e.g. 50
offset
status e.g. new

Parameters

id e.g. ...)

Outcomes: assigned · already · locked · archived · not_found · error --------------------------------------------------------------------------- WHY THIS IS ITS OWN FILE AND NOT A BRANCH INSIDE tasks.php --------------------------------------------------------------------------- The same three reasons close.php gives, and they are still the reasons: - THE RESPONSE SHAPE IS PER-ID, NOT A SINGLE TASK. admin/js/pm-board.js checks `res.success === true && res.task` on every PATCH; overloading PATCH to sometimes omit `task` is a live breakage, not a refactor. - tasks.php's arbitration block (the 403 `locked` / 409 `deferred_to_human` pair) is the most load-bearing code on the board. The safest way to preserve it is to not edit the file it lives in. - one endpoint per concern is this directory's existing pattern — close.php, events.php, sessions.php, roadmap.php, prd.php, updates.php. --------------------------------------------------------------------------- 🔴 IDS ONLY. THERE IS NO SELECTOR FORM, AND THERE MUST NEVER BE ONE. --------------------------------------------------------------------------- `{site_key: 'hiphop_world', status: ['review']}` is REFUSED with 400 `selector_not_supported`, even when `ids` is also present. This endpoint's entire job is moving ~460 cards, and a server-side selector means the owner cannot see what they are about to move. The set the server would resolve is not the set the operator was looking at — the board is written by two actors, so a `status=review` filter evaluated at request time can silently include a card the Stop hook moved into review thirty seconds ago, or exclude one it moved out. The client already holds the ids it is displaying; sending them is what makes the request reviewable, repeatable, and auditable after the fact. A selector is one line to add and impossible to un-ship once a script depends on it. --------------------------------------------------------------------------- 🔴 A LOCKED CARD IS SKIPPED, NOT A 403 FOR THE WHOLE BATCH — AND ONLY FOR AI --------------------------------------------------------------------------- Two rules, both deliberate: 1. For a BEARER caller, `manual_lock = 1` yields outcome `locked` on THAT CARD and the batch continues. tasks.php answers 403 for a single-card PATCH, which is right there and wrong here: one locked card must never stop the owner filing the other 459. The refusal is per-id because the request is per-id. 2. For a SESSION caller, manual_lock is NOT CHECKED AT ALL. The lock exists to stop the AI re-opening what a person decided; it was never meant to stop that person re-filing their own card. A human who locked a card and now wants it in a different project is exercising the authority the lock represents, not defeating it. --------------------------------------------------------------------------- WHY THE EVENT ROW SAYS NOTHING ABOUT STATUS --------------------------------------------------------------------------- Every assignment writes exactly one row to 1vs_network_pm_events with `from_status` and `to_status` BOTH NULL. 🔴 A PROJECT NAME MUST NEVER GO IN `to_status`. That column is VARCHAR(32) and both admin/js/pm-board.js and admin/network/pm-review.php read it AS A STATUS — so a project name there renders as a status transition that never happened, and a name over 32 characters is silently truncated on top of that. 460 bulk moves with no audit trail is unacceptable; a fabricated one is worse, because it is indistinguishable from the real thing. The move lives in `note`, in the one shape a reader can parse: [project] <old name|unfiled> → <new name|unfiled> --------------------------------------------------------------------------- WHAT THIS ENDPOINT DELIBERATELY DOES NOT WRITE --------------------------------------------------------------------------- Only `project_id` changes. Not `status`, not `manual_lock`, not `last_actor`, and NOT `human_touched_at` — stamping the last of those would arm tasks.php's 24-hour `deferred_to_human` arbitration on every card the owner files, blocking the Stop hook from status updates on 460 cards as a side effect of tidying them. Filing is a change of address, not a change of state.

Requires migration 348. --------------------------------------------------------------------------- WHAT THIS IS FOR --------------------------------------------------------------------------- Sessions and cards have only ever been related by site_key, which answers "some session is working somewhere on this site" — true of ten sessions at once and useful for none of them. This table answers "THIS window is on card #606", which is what makes a session row actionable and what lets a card carry a dependency edge from the session's own task list. --------------------------------------------------------------------------- 🔴 A BINDING NEVER MUTATES THE CARD --------------------------------------------------------------------------- Binding is an observation — "this session is looking at this card" — not a claim on it, not a status change, and emphatically not a lock. Nothing in this file writes to 1vs_network_pm_tasks; the only reads are to confirm the card exists and to return its title. That matters because the tempting next step is "binding a card should move it to in_progress". It must not. Status is arbitrated in tasks.php, where an AI write defers to a human touch within 24h and a locked card is refused outright. A second writer here would route around all of that, and the first symptom would be a human's `done` silently reopening because a session happened to still have it bound.

Parameters

session_uid e.g. X

Outcomes: closed · already_done · archived · not_found · error --------------------------------------------------------------------------- WHY THIS IS ITS OWN FILE AND NOT A BRANCH INSIDE tasks.php --------------------------------------------------------------------------- 🔴 A HUMAN CLOSES A CARD. THE AI NEVER DOES. That rule is why the board can be trusted at all, and here it is a property of the FILE: the first thing this script does is refuse any bearer token, before it has a database handle, before it reads a body, before it knows what an id is. There is no code path from a bearer request to an UPDATE, and tests/test-pm-board-tasks.php asserts that structurally as well as over the wire. Inside tasks.php the same rule would be a conditional in a method that bearer callers legitimately use every session — one refactor away from being wrong. Two more reasons, both concrete: - tasks.php's arbitration block (the 403 `locked` / 409 `deferred_to_human` pair) is the most load-bearing code on the board. The safest way to preserve it is to not edit the file it lives in. - the response shape is per-id, not a single task. admin/js/pm-board.js checks `res.success === true && res.task` on every PATCH; overloading PATCH to sometimes omit `task` is a live breakage. One endpoint per concern is also this directory's existing pattern — events.php, sessions.php, roadmap.php, prd.php, updates.php. --------------------------------------------------------------------------- WHY A CLOSE HERE IS BYTE-IDENTICAL TO A HUMAN PATCH TO status='done' --------------------------------------------------------------------------- status='done' + manual_lock=1 + last_actor='human' + human_touched_at=NOW(), plus exactly one row in 1vs_network_pm_events. Anything less would create two kinds of closed card, and `manual_lock=1` is specifically what stops the Stop hook re-opening a card a person just closed (tasks.php refuses a bearer write to a locked row with 403).

Direction: `task_id` BLOCKS `blocks_task_id`. Read left to right, the row is the true sentence. Requires migration 348. --------------------------------------------------------------------------- 🔴 THE CYCLE GUARD IS THE POINT OF THIS FILE --------------------------------------------------------------------------- Everything downstream of these edges is a graph walk: "what does this card unblock", "what is the critical path", "what should I do first". A cycle makes every one of those non-terminating, and the failure is not a nice exception — it is a request that hangs until the worker is killed, on an endpoint the console polls. So a cycle is refused at INSERT time, which is the only moment the graph is small enough to check cheaply and the only moment there is a human to tell. The check is: before adding A→B, can B already reach A? If yes, adding the edge closes a loop, and we 409 with the path that would have formed rather than writing it and discovering the problem during a traversal. The CHECK constraint in migration 348 only catches A→A. A→B→C→A is invisible to any per-row constraint, and that is the shape that actually occurs when two sessions each project their own local DAG onto shared cards. Every walk in this file is bounded twice — by a visited set AND by a hard step ceiling — because a guard that assumes the data is already acyclic is exactly the guard that fails on the day it is needed.

Parameters

site_key e.g. X

Read-only — no CSRF.

Parameters

task_id e.g. N

Parameters

site_key e.g. X

--------------------------------------------------------------------------- 🔴 WRITES ARE SESSION-ONLY, AND THAT IS THE WHOLE POINT OF THIS FILE --------------------------------------------------------------------------- A project is the owner's declaration of intent. An agent files cards mechanically all day — that is what the bearer token on tasks.php is for — but it does not get to decide that "books" is a thing, that "games" and "the book" are two efforts rather than one, or that either of them is over. Naming the buckets IS the judgement; filing into them is the clerical part. So, exactly as close.php does it, the refusal is a property of the FILE and not a conditional buried in a method: the first thing a POST / PATCH / DELETE meets here is a bearer refusal that runs BEFORE a database handle is opened and BEFORE a body is read. There is no code path from a bearer request to an INSERT, an UPDATE or a DELETE, and tests/test-pm-projects.php asserts that structurally (on comment-stripped source) as well as over the wire. GET is deliberately NOT session-only. Reading which projects exist is how a CLI or a hook decides where a card belongs, and refusing that would push the mapping back into a hardcoded list in a shell script — which is the thing that goes stale silently. --------------------------------------------------------------------------- WHY THIS FILE CAN DEPLOY AHEAD OF ITS SCHEMA --------------------------------------------------------------------------- pmpHasProjectTables() reads information_schema once per request (static cached, mirroring pmHasVerifyColumns() in tasks.php) and every entry point consults it first. Before migration 353 lands, every method answers a documented 503 `migration_pending` naming the migration — never a 500 out of an unknown table, which is what the same situation cost on 341/342 when the code was live for a day and logged SQLSTATE[42S02] on every write. --------------------------------------------------------------------------- NO NEW ENUM, ANYWHERE --------------------------------------------------------------------------- Prod's sql_mode is non-strict (`NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION`), so an out-of-list ENUM value is COERCED TO '' and the write REPORTS SUCCESS — measured live on 2026-08-07, reproduced by tests/test-pm-verdict-enum-guard.php. Migration 353 therefore declares no ENUM at all: `priority` is a TINYINT clamped to 1..5 in PHP, and archive state is a NULLable DATETIME. Any vocabulary this file ever grows must be VARCHAR plus an `in_array(…, true)` allow-list checked BEFORE the write, never an ENUM. ⚠️ Green on the dev box proves nothing about prod here: local is MySQL 9.5 running STRICT_TRANS_TABLES (an out-of-range value THROWS) and prod is MariaDB 10.6.27 running non-strict (it coerces). The app-layer clamp is the only guard that behaves the same on both. --------------------------------------------------------------------------- SCOPE IS DESCRIPTIVE, NEVER ENFORCING --------------------------------------------------------------------------- `sites` says what a project is ABOUT. It is not a constraint on which cards may carry the project_id, and it must never become one: enforcing it would make NARROWING a scope silently orphan cards — a destructive edit wearing the costume of a settings change. Migration 353's own header says the same thing about the table, and the test suite asserts that filing an out-of-scope card still succeeds. `sites` is also a FULL REPLACE SET rather than add/remove verbs. Two verbs mean a client has to know the current membership to compute a diff, and a client with a stale view then issues a remove for a row somebody else just added. One list, sent whole, is idempotent by construction.

Parameters

id e.g. N

Parameters

site_key e.g. X

Returns { success, generated_at, network{...}, sites[...], capabilities{...}, projects[...] (only with ?group=project), meaning{...} } 🔴 ABSENT `?group=`, THE RESPONSE IS BYTE-IDENTICAL TO WHAT IT WAS BEFORE ?group= EXISTED — same keys, same order, no new `capabilities.projects`, no new `meaning` entries. Three consumers read this payload (admin/js/pm-board.js, the console, and the Stop-hook prompt outside this repo), and a payload that grows a key on every caller is how one of them starts rendering a stray column nobody asked for. `?group=project` is strictly ADDITIVE: `sites` and `network` are still there, because both views fall out of the same GROUP BY passes and a consumer holding both in one response is exactly the check that catches a wrong JOIN. tests/test-pm-rollup.php pins the default key set at three levels. WHY THIS EXISTS The board is 900+ cards across 160+ sites. Answering "how far along is each project" from tasks.php means one request per site — 163 round trips, which on this host is also 163 chances for Imunify360 to answer a burst with a challenge. Every figure here is a GROUP BY, so the whole board costs a fixed handful of queries regardless of how many sites exist. 🔴 WHAT THE NUMBERS MEAN, AND WHAT THEY DO NOT `done / total` here is BOARD COVERAGE, not site completeness. Cards are created automatically by the Stop hook and can only be closed by a signed-in human (close.php), so the denominator grows on its own and the numerator only moves when somebody sits down and closes something. A site can be finished and read 40%. That is why `review` is reported as its own top-level figure on every site rather than being folded into "open" — it is the queue that actually gates completion, and any consumer drawing a progress bar from this endpoint is expected to show it. Read-only. There is no POST/PATCH/DELETE here by design: everything this endpoint reports is owned by tasks.php / roadmap.php / prd.php, and a second writer for the same rows is how two conventions get established.

Parameters

sites e.g. a,b,c

Auth (one of), same contract as tasks.php: 1. Valid hiphop.world admin session (human, read + write; write needs CSRF) 2. Authorization: Bearer <PM_BOARD_API_KEY> (the vault sync CLI) SOURCE PRECEDENCE — vault > live > ledger. A POST from a weaker source will NOT overwrite a row written by a stronger one. A curated note has a goal and a body; a live row is a perishable snapshot; a ledger stub has a date and a site. Letting the stub win would silently erase the write-up, and the sync pushes both in the same run. Fields the weaker source does not own are left alone rather than nulled — see pmSessionMergeableColumns() and pmSessionSourceOwnedColumns(). 🔴 AN UNRECOGNISED source / status / live_status IS REJECTED (400 single, one `errors[]` entry in a batch) — never coerced. Coercion used to promote an unknown source to 'vault', the TOP of that order, which meant it overwrote curated write-ups instead of merely being mislabelled. Full account in the header of includes/pm-sessions.php.

Parameters

since e.g. YYYY-MM-DD

Bulk close lives in its own endpoint — close.php — NOT here. It is session-only by construction; see that file's header for why. Auth (one of): 1. Valid hiphop.world admin session (human) 2. Authorization: Bearer <PM_BOARD_API_KEY> header (AI via Stop hook) Arbitration (AI writes): - manual_lock=1 → 403 "locked" - human_touched_at <24h + differing status change → 409 "deferred_to_human" Status-change side effects: - Always inserts into 1vs_network_pm_events - Human status change → sets human_touched_at=NOW(), last_actor='human' - Human status='done' → also sets manual_lock=1 - AI write → sets last_actor='ai'

Parameters

site_key e.g. X

Parameters

site_key e.g. X
limit e.g. 20

GET - List domains with optional filters GET ?action=stats - Aggregate statistics PUT ?id=X - Update domain fields

Parameters

action e.g. stats

POST ?action=add_repo - Add a GitHub repo to track POST ?action=sync&repo_id=X - Sync recent commits for a repo POST ?action=sync_all - Sync all tracked repos GET ?action=repos - List all tracked repos GET ?action=commits&repo_id=X - Get commits for a repo GET ?action=feed - Latest 50 commits across all repos DELETE ?action=remove_repo&repo_id=X - Remove a tracked repo

Parameters

action e.g. add_repo

POST ?action=check&domain_id=X - Run health check on single domain POST ?action=check_all - Run health checks on all Live domains GET ?action=results&domain_id=X - Health check history for a domain GET ?action=latest - Latest health check per Live domain

Parameters

action e.g. check
domain_id e.g. X

GET - List tasks with filters GET ?action=board - Tasks grouped by status (Kanban) GET ?action=activity - Recent activity log POST - Create a new task POST ?action=log_activity - Manually log activity PUT ?id=X - Update a task DELETE ?id=X - Delete a task

Parameters

action e.g. board

Parameters

action e.g. list

No description available.

Body: { "template_id": 123, "reason": "Does not meet quality guidelines" } Admin auth required. Rejects a pending template and refunds any embedded Cash to the creator. Returns template data with refund info.

Body: { "verification_id": 123, "reason": "Insufficient documentation" }

POST /api/admin/revenue/actions Body: {id, action: 'view'|'dismiss'|'execute'}

No description available.

No description available.

No description available.

No description available.

No description available.

No description available.

1. cross_site_journey — % of network members with activity on 2+ sites in last 30 days 2. credit_velocity — avg credits spent per active wallet per day, last 30 days 3. creator_retention — % of members active in the prior 30-day window still active today 4. time_to_first_value — approx median seconds between user creation and first credit ledger entry 5. north_star — composite score: cross_site_journey*0.4 + creator_retention*0.4 + normalized credit_velocity*0.2 Every query is wrapped in try/catch. If a table is missing (dev env) the metric returns null and a warning is appended to the `warnings[]` array — the endpoint never crashes. Spec: docs/SOUL_SYSTEM.md lines 1570-1589

GET ?action=list — paginated audit log (filters: event_type, domain, user_id, date_from, date_to) GET ?action=stats — 24h summary: failure rate, top errors by domain, suspicious IPs, token table size

Parameters

action e.g. list

Admin auth required. Returns templates filtered by status for admin review.

Parameters

status e.g. pending|approved|rejected
limit e.g. 20
offset

Parameters

id

GET /api/admin/user-roles.php?action=available — list all assignable role slugs POST /api/admin/user-roles.php — assign role: { user_id, role_slug } DELETE /api/admin/user-roles.php — remove role: { user_id, role_slug }

Parameters

user_id

PUT /api/admin/users.php — Toggle admin role (admin only)

Parameters

status e.g. pending|approved|rejected|all (default: pending)

GET ?action=list - List all webhooks for site GET ?action=logs&webhook_id=X - View delivery logs for webhook POST ?action=create - Create new webhook POST ?action=update - Update webhook (requires webhook_id) POST ?action=delete - Delete webhook (requires webhook_id) POST ?action=test&webhook_id=X - Send test payload

Parameters

action e.g. list

No description available.

No description available.

No description available.

No description available.

No description available.

No description available.

No description available.

No description available.

No description available.

HHW-native implementation. The kit passthrough was broken: - Kit redirected to /member/profile which doesn't exist on HHW - Kit's startAuthenticatedSession() doesn't set $_SESSION['username'] which HHW's profile.php and other pages expect Query parameters: token=<token> — Magic link token from email email=<email> — Email address for verification return=<path> — Optional relative redirect path after success

HHW-native implementation (replaces passthrough to kit's broken sendMagicLink()). Kit's MemberMail::sendMagicLink() does not exist — this file sends email directly via PHPMailer using the same pattern as includes/mail.php. Request body: { "email": "user@example.com", "csrf_token": "..." } Response: { "success": true, "message": "Check your email for a sign-in link" } Tables used (no prefix on HHW): - magic_link_tokens (email, token_hash, expires_at, created_at) - rate_limit_actions (action, identifier, created_at)

No description available.

No description available.

No description available.

Body: { provider: 'linkedin'|'discord'|'google'|... }

No description available.

No description available.

No description available.

No description available.

No description available.

by PROOF-OF-OWNERSHIP (design route A: automated, no Cash fee, no admin queue). Stage-1 proof: the member has a CLAIMED artist entity AND has attached their catalogue from the named provider to it (a real ownership signal, en route to full provider-OAuth "this account IS your music"). If both hold, we set verified_tier='artist' and record the proof (artist_proofs, mig 317). NOTE: site-native endpoint — verification is hiphop.world-local, NOT a member-kit concern, so this does not delegate to the kit (unlike api/member/preferences.php). Request body: { provider: youtube|soundcloud|bandcamp|audius } Rate limit: 10 / 10 min Response: { success:true, tier:'artist', provider, proof_url } { success:false, error:'...' } (403 no claim · 422 no proof)

No description available.

This endpoint queries blog_posts with optional site_key filter and degrades gracefully to an empty list when the table is absent — so Tier B sites can call it today and will get real data once the table ships.

Parameters

site_key e.g. bars.gives
limit e.g. 10
offset
slug e.g. ...

No description available.

No description available.

No description available.

Parameters

action e.g. list

Parameters

action e.g. list

Parameters

featured e.g. 1

No description available.

Parameters

action e.g. list

Returns: { events: [...], region: ?string, limit: int, offset: int }

Parameters

region e.g. dmv

Parameters

region e.g. dmv

No description available.

Rate limit: 60 requests/min per IP

Parameters

action e.g. list

No description available.

Parameters

action e.g. artists

Accessible as: - /api/v1/openapi.php - /api/v1/openapi.json (via .htaccess rewrite)

No description available.

Parameters

action e.g. me

No description available.

No description available.

One-time body: { "order_id": "PAYPAL_ORDER_ID", "tier": "quick_start", "payment_type": "one_time" } Recurring body: { "subscription_id": "PAYPAL_SUB_ID", "tier": "full_service", "payment_type": "recurring" } Response: { "success": true, "client_id": 1, "tier": "...", "redirect": "/marketing" }

Request body: { "tier": "quick_start|...|full_service", "payment_type": "one_time|recurring" } Response (one-time): { "success": true, "order_id": "PAYPAL_ORDER_ID" } Response (recurring): { "success": true, "subscription_id": "...", "approval_url": "..." }

GET Response: { "files": [...] } POST Response: { "success": true, "file_id": 1, "filename": "..." }

POST — Update onboarding checklist state (action=update_onboarding). Persists item completion to marketing_clients.onboarding_data JSON column. Response (GET): { "is_client": true, "tier": "growth_accelerator", "tier_name": "Growth Accelerator", ... } Response (POST): { "success": true, "onboarding_data": {...}, "progress": "3/5" }

GET Response: { "updates": [...] } POST Request: { "client_id": 1, "title": "...", "body": "...", "attachment_url": "..." } POST Response: { "success": true, "update_id": 1 }

Request body: { name, email, phone?, links?, budget_range, primary_goal, goal_details?, timeline, location?, how_found?, consent } Response: { success: true, lead_id: 123 }

GET Response: { "referral_code": "AB12CD34", "referral_url": "https://..." } POST Request: { "code": "AB12CD34" } POST Response: { "valid": true, "referrer_name": "...", "discount_percent": 10 }

Request body (JSON): { "event": "page_view", "data": { ... }, "session_id": "optional" } Response: { "ok": true }

No session/CSRF — PayPal sends server-to-server POST requests. Always returns 200 for valid (even unhandled) events to prevent retries. Returns 400 only for invalid signatures.

Parameters

slug e.g. username

No description available.

No description available.

Body JSON: { "user_id": int, "action": "follow"|"unfollow" }

Parameters

user_id e.g. X
limit e.g. 20
offset

Parameters

limit e.g. 20
offset

Clean URL: /social-card/{media|share|playlist}-{id}.png (.htaccess: RewriteRule ^social-card/(media|share|playlist)-([0-9]+)\.png$ api/social-card/music.php?type=$1&id=$2 [L]) Direct: /api/social-card/music.php?type=share&id=12 Renders: dark brand background, gold rule, the track/playlist cover art on the left (composited server-side), title + artist/owner + a "HipHop.World" wordmark on the right. Falls back to a text-only card when the cover cannot be fetched — it NEVER fails to return an image, because a 500 here is a blank unfurl, which this site has already been burned by once. ── WHERE IT IS USED (deliberately narrow) ──────────────────────────────────────────── track.php and playlist.php keep using the REAL cover art as og:image whenever the row has one — that already unfurls correctly and is the lower-risk path. This generated card is wired in ONLY where there is no cover, i.e. exactly the case that used to fall back to the generic site-wide OG template and told a reader nothing. A generated image is not put in front of a working one. ── SECURITY / CSP ──────────────────────────────────────────────────────────────────── The cover fetch is SERVER-side (cURL), so it is invisible to CSP — this endpoint adds ZERO new browser origins. It is same-origin from the browser's point of view. SSRF guard: the remote host must be on COVER_HOST_ALLOWLIST (the same YouTube/Spotify CDN hosts the CSP img-src already trusts). Anything else is refused and we render the text-only card. No redirects are followed. Cache: cache/og-images/music-{type}-{id}.png, 24h, plus a public max-age so the crawler CDNs hold it too. GD only (the extension is already a hard dependency of api/og-image.php, which ships today).

Parameters

type e.g. $1
id e.g. $2

── WHY ─────────────────────────────────────────────────────────────────────────────── Measured on prod 2026-08-03: 35 of 43 live pages advertised the SAME og:image, the site-wide black_gold_green template. Sharing /artists, /discover, /videos, /games, /book or /region produced six identical unfurls, so the picture carried no information about what was being shared. The image was never missing — it was just never the page's. ── WHY THE TITLE COMES FROM THE REGISTRY, NOT THE QUERY ────────────────────────────── {key} indexes includes/page-seo.php and nothing else. An unknown key is a 404, not a blank card. Rendering caller-supplied text onto a branded 1200x630 image would hand anyone who found the URL a free billboard carrying our wordmark, and it would be indistinguishable from ours in a share preview. ── DEPENDENCIES ────────────────────────────────────────────────────────────────────── Deliberately does NOT include bootstrap.php: no DB, no session, no member kit. The card is a pure function of the registry, so nothing here can fail because a table is missing or a session is stale — and an og:image that 500s is worse than a generic one, because the crawler caches the failure. ── FONTS ───────────────────────────────────────────────────────────────────────────── 🔴 CORRECTED 2026-08-03. This block used to read: "no TTF exists in this repo, so imagettftext is unavailable... this repo ships woff2 fonts, which GD cannot read", and the whole text engine below was built around that premise — bitmap font 5 drawn small and resampled up, with wrapping measured in CHARACTERS. The premise is false. assets/fonts/oswald.ttf (44KB) and assets/fonts/inter.ttf (122KB) have been in the repo and on the server since 2026-07-21 — they were converted from the woff2 for exactly this purpose, and includes/hero-card.php and includes/artist-card.php both draw with them in production. imagettftext IS available here. The cost of the wrong premise was visible on every card: 35+ pages shared a blocky, pixelated, monospaced upscale of a 9x15 bitmap, on the one surface whose entire job is to look like the brand. So: draw with the real faces when they are readable, and keep the bitmap path intact as the fallback for a server that lacks FreeType. The two paths measure differently — bitmap wraps by character budget, TTF by pixel width — so each has its own wrap.

Parameters

key e.g. $1

Body: { amount: number, currency?: string (default USD), type: "one_time" | "recurring", paypal_transaction_id?: string, paypal_subscription_id?: string, message?: string, anonymous?: bool, donor_name?: string, donor_email?: string } If the user is logged in, links donation to their user_id. Rate limited: 10 per hour per IP.

Returns total donated, donor count, and last 10 non-anonymous completed donations. No authentication required. Rate limited: 30 per minute. Cache-friendly with 60-second Cache-Control.

POST actions: action=create_key — generate new API key (name, environment, scopes) action=revoke_key — revoke an API key (key_id) action=create_client — register OAuth client (client_name, redirect_uris, scopes, etc.) action=update_profile — update partner profile (company_name, website, description, contact_email)

Body: {"amount": 500} The amount is deducted from users.cash_balance and added to enterprise_partners.cash_balance.

Requires admin authentication. Params: site_key (required), limit (default 50), offset (default 0)

Requires admin authentication. Body: { submission_id: int } or { site_key: string, mark_all: true }

POST /api/engine/contact-form/submit Headers: X-Site-Key Body (JSON): {site_key, name, email, subject, message, _hp_email}

Boots the engine, handles CORS with domain whitelist, and delegates to the component API router.

Parameters

site_key e.g. <key>

Usage: GET /api/engine/network-sites CORS: Restricted to engine_sites domains

Usage: GET /api/engine/site-bundle?site_key=<key> Cache: public, max-age=300 (5 minutes)

Parameters

site_key e.g. <key>

🔴 RETIRED 2026-08-09 by owner decision. See api/founder/status.php for the full reasoning. In short: the program was never finished — the slot table was seeded but users.founder_number / users.founder_tier were never added, so this endpoint could only ever throw and roll back. It returns an honest 410 rather than the "Failed to claim founder slot" it used to produce.

POST /api/founder/status.php — 410 Gone. Claiming is closed. 🔴 RETIRED 2026-08-09 by owner decision. Do not revive without one. The Founder Program (100 Founder + 1,000 Early Access slots) was never completed: `founder_slots` was seeded on production with 1,100 rows, but `users.founder_number` and `users.founder_tier` were never added, so claimFounderSlot() threw and rolled back on every call — which is also why zero slots were ever claimed. Meanwhile this endpoint answered `program_open: true, slots_100_remaining: 100` to anyone who asked. The choice was to finish it (one additive migration) or stop advertising it. The owner chose to discard the program, so this endpoint now says so rather than being deleted: an honest answer is more useful to any caller than a 404, and the network SDK still has a founder-counter widget pointed here. NOT touched, deliberately: - the `founder_slots` table, which holds 1,100 unclaimed rows and harms nothing; dropping data is a separate, irreversible decision - users.is_site_founder and renderSiteFounderBadge(), which are the HONORARY founding-artist crown and have nothing to do with this program - /join and the 8/11 "founding member" copy, which is a manual approval queue that never ran through this code

Parameters

input
country

Parameters

place_id

Public endpoint — no authentication required.

Parameters

slug (required) region slug

Public endpoint — no authentication required.

Parameters

status 'active' (default), 'draft', 'archived' (admin only)

No description available.

Admin auth required.

Parameters

site_key e.g. xxx

Public endpoint — no auth required

Public endpoint — no auth required

Public endpoint — no auth required

Public endpoint — no auth required

Public endpoint — no auth required

Body: { queue_id } Requires admin authentication. Uses embedded Cash (already deducted at submit).

Body: { queue_id, action: 'approve'|'reject' } Requires admin authentication. Refunds Cash on rejection.

Body: { content_type, content_id, cash_embedded? } Requires authentication. Optionally locks Hip Hop Cash into the submission.

Public endpoint — no authentication required. Returns template with mint count and creator info.

Parameters

template_id e.g. 123

Public endpoint — no authentication required. Returns paginated list of active templates sorted by the given criteria.

Parameters

sort_by e.g. cash_embedded|mint_count|created_at
source_type e.g. event
limit e.g. 20
offset

Body: { "template_id": 123 } Requires authentication. Costs 100 Cash. Creator gets royalty, remainder locked in card. Returns new card data and mint receipt.

Requires authentication. Returns paginated list of templates created by the user (all statuses).

Parameters

limit e.g. 20
offset

POST JSON: {quote_id: int, action: 'accept'|'decline', notes?: string} Returns: {success: true, data: quote}

POST multipart/form-data: site_key, asset_type, file Returns: {success: true, data: {url, asset_type, site_key}}

Body: { "artist_id": 1 } Requires authentication. Links the artist to the current user's account.

Returns artist profile data and their music catalog.

Parameters

id e.g. 1 or ?slug=mental-stamina or ?user_id=1

Writes a new entry to hd_entities (entity_type='artist') + hd_vertical_music with moderation_status='pending'. Admin approves via /admin/?page=queue. Body: { name, hometown, city, state_region, bio, genres, instagram_handle, youtube_handle, spotify_id, website_url, social_links } Only name + hometown (or city/state) are required.

Parameters

page e.g. queue.

Returns: {newly_awarded: [...]}

No description available.

Requires: logged in + SoundCloud connected with stored access token

DB-sharing tenants (e.g. bgpdx.com, whose DB *is* the HHW system DB) consume the sso_token by reading the sso_tokens table directly in their own /sso handler. Isolated-DB tenants (e.g. intergalactic.charity → hiphopwo_iaas) cannot — they have no access to hiphopwo_rld_system.sso_tokens. Instead the tenant POSTs the token here, server-to-server, authenticated by the tenant HMAC (see internal-auth-guard.php). We atomically consume the single-use, origin-bound token, verify it was minted for THIS authenticated tenant's origin, and return the central `users` profile for the tenant to seat locally. Body: { "token": "<hex sso_token>" } Returns 200: { "ok": true, "user": { id, email, username, display_name, avatar_url, is_admin } } Mirrors the mint in api/auth/google-broker-callback.php (target_origin = lowercase, trailing-slash-stripped 'https://' . tenant_domain) and the atomic-consume hardening in bgpdx.com's direct-DB sso.php.

Body: { "email": "...", "reset_url_template": "https://bgpdx.com/reset-password?token={TOKEN}", "site_name": "Bamboo Grove PDX" } Response: { "ok": true, "email": { "to": "...", "subject": "...", "body_html": "...", "body_text": "..." } } Always returns ok=true even if email unknown (prevent enumeration). Only returns email payload when the user actually exists.

Parameters

token e.g. {TOKEN}",

IP allowlist: restricted to CIDR blocks in HHW_INTERNAL_JWKS_ALLOWED_IPS env. Leave unset in dev to allow all. Production should pin to tenant server IPs.

Body: {"email": "...", "password": "...", "ip": "<optional end-user IP>", "user_agent": "..."} Response: {"jwt": "...", "user": {...}} or {"error": "..."}

Body: { "provider": "google", "provider_sub": "1234567890", "email": "...", "email_verified": true, "name": "...", "given_name": "...", "family_name": "...", "picture": "...", "locale": "..." }

Body: { "state_token": "<64-hex>", "code_verifier": "<64-hex>", "return_url": "/members", "redirect_uri": "https://1vsm.com/api/auth/google-callback.php" }

Body: {"email":"...","password":"...","display_name":"...","ip":"...","user_agent":"..."} Response: {"jwt":"...","user":{...}} or {"error":"..."}

Body: { "user_id": 123, "verify_url_template": "https://bgpdx.com/verify-email?token={TOKEN}", "site_name": "Bamboo Grove PDX" }

Parameters

token e.g. {TOKEN}",

per requireInternalTenantAuth(). Only registered + active tenants can consume tokens. Request body: {"token": "<sso_token>"} token = 32-128 hex Response: 200 {ok:true, user: {id, username, email, display_name, avatar_url, is_admin}} 400 {ok:false, error:'invalid_format'} 401 {ok:false, error:'invalid_or_expired_token'} 401 {ok:false, error:'origin_mismatch'} 401 {ok:false, error:'user_disabled'} 500 {ok:false, error:'server_error'} Single-use: a token consumed by this endpoint cannot be consumed again (atomic UPDATE…WHERE used_at IS NULL guarantees one winner across racers).

Body: {"jwt": "..."}

Body: {"token": "...", "new_password": "..."}

&category=&city=&entity_type=&sort=newest&limit=24&offset=0&featured_only=0 Returns entities published to the requesting site. Used by satellite sites (Oregon Tires, Hoku Events, etc.) to pull directory listings.

Parameters

site_key e.g. oregon_tires (required)

Returns all publication records for an entity (which sites it's published to, featured status, publish dates).

Parameters

entity_id e.g. 123 (required)

/811 is the annual hip hop anniversary program page; the Aug-11 2026 Launch Party is its first edition. Accepts the /811 form (name, email, city, sponsor_interest, message) from BOTH guests and logged-in members (members get user_id attached). Dual-mode response so the funnel works with NO JavaScript — mirrors api/launch/join.php exactly: - Plain HTML form POST (Accept: text/html, non-JSON body) → 303 redirect back to /811?joined=1 or /811?error=<code> (811.php renders the copy). - JSON request → versioned JSON body (api/v1/land.php 'api_version' idiom). Inserts hhw_811_signups (migration 309, status 'pending') then enqueues in the unified approval queue as content_type 'annual_program_signup' via the existing enqueueForApproval() helper — internally try/caught, so a DB that doesn't accept the type yet (before migration 309's enum append) never breaks the signup response. Pattern: api/launch/join.php (rate limit, guest+member, CSRF via validateCsrfToken() — session token, header or csrf_token form field).

Parameters

joined e.g. 1

Public, read-only. Two boards for the Stage-1 invite-and-grow ambassador loop: - board=most_invited (default): ranked by referral count (referral_rewards). "Who brought the most people into the world." - board=first_in: ranked by lowest hip_hop_id. "The earliest founders" — a purely intrinsic board, no action needed, the low member number is the badge. Response: { success:true, board, entries:[ {rank, username, display_name, hip_hop_id, avatar_url, invited_count} ] } Privacy: exposes only public profile fields (username, display_name, hip_hop_id, avatar_url) + the invite count. No emails, no wallets. Private profiles are excluded. Rate limit 120 / 5 min (public-endpoint bucket).

Parameters

board most_invited|first_in (default most_invited)
limit 1-50 (default 20)

Mirrors api/hero/card.php deliberately — same cache-dir hardening, same write-then-rename, same "fail with a PNG, never an HTML error page" rule.

Parameters

slug

Request: { t: string, item_id: int, verdict?: string, answer_text?: string } Success: { success: true, answered: bool, progress: { total, answered, percent } } Failure: { success: false, error: "..." } AUTH is the ?t= token from the emailed link, carried in the JSON body — there is no session and no cookie. That also settles CSRF: a cross-site page cannot forge this request because it does not hold the token. See includes/canon-review.php. Deliberately does NOT require includes/bootstrap.php — no session, no SSO probe, no member state. This endpoint must keep working when auth does not.

Body: {"album_id": int} -> {ok, order_ref, url} 🔴 THE PRICE IS ALWAYS READ FROM music_albums SERVER-SIDE. It is never taken from the request, because a client-supplied amount is a client-controlled amount. There is no code path here that reads a price, a currency or an item description from input. ── THE REFUSALS, IN ORDER, AND WHY THAT ORDER ── 1. method — a GET that creates orders is a link that creates orders 2. CSRF — authenticity is not conditional on the feature being switched on 3. auth — the buyer needs an account, because the album lives in their library permanently and an anonymous purchase has nowhere to live 4. album exists / is published / has a price 5. 🔴 DELIVERABLE — refuse if there is no file to hand over. This is the check that stops us charging $20 for a database row. It comes BEFORE the Square call so the failure costs nothing and is visible to us. 6. Square live — refuse if credentials are missing, or if they are SANDBOX credentials on a production host (a checkout that looks real, accepts a test card and moves no money) 7. already owned — 409 rather than a second charge CSRF is checked before the album lookup deliberately: a forged request should never reach the database, let alone Square.

This endpoint grants access to paid goods, so it is written to fail closed everywhere. ── 1. VERIFICATION IS MANDATORY, AND A MISSING KEY IS NOT AN EXCUSE ── With no signature key configured this answers 503 and processes nothing. It must never degrade to "accept anything" — commerce-kit's CommercePayPal::verifyWebhookSignature returns TRUE when its webhook id is empty (CommercePayPal.php:413-425), which turns an unset env var into an unauthenticated "mark this order paid" endpoint. Square retries on 503, so a genuine event is not lost while we are misconfigured. 🔴 The notification URL is read FROM ENV, never rebuilt from $_SERVER['HTTP_HOST']. It is inside the signed string, and this docroot answers on ~20 hostnames — the host that arrives is not necessarily the host that signed. inventivelywitty.com signed two different strings for twelve days that way and every payment silently failed to record. ── 2. IDEMPOTENCY IS SETTLED BY THE PRIMARY KEY, NOT BY APPLICATION LOGIC ── Square retries until it gets a 2xx and can deliver the same event concurrently. A SELECT-then-INSERT check races, and what is being raced is "grant this album". So the claim is one INSERT IGNORE and a rowCount() check. A previously FAILED event is deliberately reopened — otherwise one transient error means the purchase is never recorded however many times Square retries. ── 3. IT ALWAYS ANSWERS 200 ONCE THE EVENT IS UNDERSTOOD ── Including for events we do not handle. A non-2xx makes Square retry, and retrying an event we will never act on is noise that hides the ones that matter.

Closed proxy: takes ids only, never an arbitrary URL. Prevents abuse.

Params: album (album_slug, optional filter) · song (2-digit song_num, optional) · source (emission source, default all) · limit (default 50, max 100). Anything unrecognised is dropped rather than 400-ing, so a hand-edited URL keeps working. Response: { success:true, half_life_days:7, horizon_days:180, count:int, tracks:[{album_slug,song_num,buzz,events,last_event_at}] } If migration 320 has not been applied the ledger table is absent and this answers 200 with an empty `tracks` list — the same shape, no error.

Parameters

user
album
song
difficulty

No description available.

No description available.

No description available.

No description available.

No description available.

No description available.

No description available.

GET ?action=list&album=&song=&difficulty=[&limit=N] (public, no CSRF) The top rivals you can battle on a song@difficulty (score-only summaries, no replay blob) — powers the "choose a ghost" picker. Response: { success:true, ghosts:[{rank,user_id,name, avatar,score,updated_at}, …] }. POST { album_slug, song_num, difficulty, score, replay } (auth + CSRF) Save the caller's ghost, keeping only their best-scoring one. Response: { success:true }.

Parameters

album
song
difficulty e.g. [
opponent e.g. <user_id>]

Params: scope (default song) · difficulty (default medium) · album (album_slug; required for song & album) · song (song_num; required for song) · player (user_id int OR name substring) · sort — SCOPE-DEPENDENT: song scope: score|accuracy|max_combo|recent (default score) album/overall scope: total|average|accuracy|combo|tracks (default total) An out-of-scope value falls back to that scope's default rather than 400-ing, so an old client or a hand-edited URL keeps working. category (players scope: total_score|xp|level|s_grades|full_combos|... default total_score) · timeframe (song scope: all|week|day; week/day rank recent plays) · limit (default 50, max 100; ignored by summary) · offset (ignored by summary). `summary` scope needs only `difficulty` — lists every song@difficulty with >=1 score row (album_slug, song_num, players count), capped at 30 (the ladder's max song count). Powers a client-side "which songs have scores?" indicator without one leaderboard call per song. Response: { success:true, scope, difficulty, album?, song?, sort?, category?, timeframe?, count:int, rows?:[...], songs?:[{album_slug,song_num,players}] } (summary scope returns `songs`, not `rows`; all other scopes return `rows`.)

Response: { success:true, user:{id,name}, scores:[{album_slug,song_num,difficulty,score,accuracy,grade,max_combo}], progress:{ "<album_slug>":{ finishedTracks:[...] } }, totals:{ "<difficulty>":{ total,average,tracks_completed } }, stats:{ total_score, songs_passed, s_grades, full_combos, ... lifetime aggregate } }

Public (no CSRF, no auth) — a player's Hip Hop Hero lifetime aggregate + derived badges, for the profile / stats card. Badges are computed from the aggregate (no storage). The `u` form exists so /hero/player/{username} can be a shareable URL: a link has to name a player in a way a human can read and type, and numeric ids are neither. `user` is kept for the in-game "My Stats" path, which already holds the id from the session. Response: { success:true, user:{id,name,avatar}, stats:{ total_score, xp, level, s_grades, full_combos, ... }, badges:[{key,label,earned}] }

Parameters

user

No description available.

No description available.

No description available.

No description available.

Body: { album_slug, song_num, difficulty, score, accuracy, grade, max_combo } Response: { success:true, improved:bool, best:{score,accuracy,grade,max_combo}, rank:int|null, standings:{ song:{rank,of,score,grade}|null, album:{rank,of,total}|null, player:{rank,of,total_score}|null } }

Response: { success:true, loggedIn:bool, user:{id:int,name:string,username:string|null,avatar:string|null}|null, csrf:string } `username` is the URL-safe handle (distinct from the display `name`, which may contain spaces and emoji). The SPA needs it to build this player's own shareable links — /hero/player/{username} and /hero/score/{username}/... — which is why it is exposed here and not only on the profile endpoint.

?username=mozaycalloway Response: { success: true, artist: { username, display_name, avatar_url }, responses: [{ question_id, question_text, category, response_text, published_at }, ...] }

Parameters

username e.g. mozaycalloway

Body (JSON): { "question_id": "origin" } Response: { success: true, is_published: true/false }

?key=default (optional, defaults to 'default') Response: { success: true, interview: { id, title, description, questions: [...] } }

Parameters

key e.g. default

Body (JSON): { "interview_key": "default", "question_id": "origin", "response_text": "..." } Response: { success: true }

Hip Hop World Launch Party · 53rd Anniversary of Hip Hop · 2026-08-11 · Covert Café, Portland OR. Accepts the /perform form (stage_name, name, email, links, message, city) from BOTH members and guests (api/events/submit.php idiom — the proven intake seam). Inserts into hhw_performer_applications (migration 306) and enqueues an approval_queue row with content_type 'performer_application' via enqueueForApproval() (internally guarded — if the ENUM value isn't registered yet the application row still lands and the endpoint still succeeds). Progressive enhancement: a native (non-JSON) form POST is answered with a 303 back to /perform?sent=1 / ?perform_error=… (ContactForm no-JS idiom); JSON callers get the versioned JSON envelope from jsonResponse(). Rate limit: mirrors api/events/submit.php — checkRateLimit(), 5/hour/IP.

Parameters

sent e.g. 1

Accepts the /join form (name, email, role_interest, city, message) from BOTH guests and logged-in members (members get user_id attached). Dual-mode response so the funnel works with NO JavaScript: - Plain HTML form POST (Accept: text/html, non-JSON body) → 303 redirect back to /join?joined=1 or /join?error=<code> (join.php renders the copy). - JSON request → versioned JSON body (api/v1/land.php 'api_version' idiom). Inserts hhw_team_signups (migration 306, status 'pending') then enqueues in the unified approval queue as content_type 'team_signup' via the existing enqueueForApproval() helper (approval-queue.php — internally try/caught, so a DB that doesn't accept the type yet, e.g. before Agent E's migration 308, never breaks the signup response). Approval — and the Founding Member cutoff rule (through 2026-08-12) — live in the approval handler, NOT here. Pattern: api/events/submit.php (intake: rate limit, guest+member) + CSRF via validateCsrfToken() (session token, header or csrf_token form field).

Parameters

joined e.g. 1

Response: { success: true, canon: { slot: word, ... } }

Response: { success: true, lexicon: { currency: {value, canon, custom}, ... } }

GET ?status=pending|approved|rejected&page=1 -> 200 { success:true, status, page, counts:{pending,approved,rejected}, rows:[...] } POST { id, action:'approve'|'reject' } — CSRF required -> 200 { success:true, id, moderation_status, counts:{...} } Canonical bootstrap pattern (mirrors api/admin/music-moderation.php).

Parameters

status e.g. pending|approved|rejected
page e.g. 1

Request body — either a single slot or a batch: { "slot": "currency", "value": "cash" } { "terms": { "currency": "cash", "block": "cube", "greeting": "" } } Response (success): { success: true, results: { slot: "set"|"cleared", ... } } Response (error): { success: false, error: "..." }

No description available.

No description available.

No description available.

No description available.

── WHY ───────────────────────────────────────────────────────────────────── There are 215 regions, and every one of them shared the site-wide emblem: /region/dmv, /region/nyc and /region/tokyo produced three identical unfurls carrying no information about which scene was being shared. Regions are a primary nav surface and the discovery half of the MVP loop, so the picture should at least say the name. Reuses ArtistCard rather than adding a third card class — the layout is the same shape (eyebrow, big name, one supporting line, canonical URL), and 'eyebrow' / 'url_label' exist for exactly this. Drawing stays in includes/artist-card.php so it is testable with no server; this file is fetch, cache and headers, mirroring api/artist/card.php.

Parameters

slug

Writes to hd_entities (entity_type='venue') + hd_venues with moderation_status='pending'. Admin approves via /admin/?page=queue.

Parameters

page e.g. queue.