CodeWithBotina API

JSON API for blog.codewithbotina.com

Checking status...

Built with Deno, Fresh, Supabase, and Resend.

Quick Start

Base URL: https://api.codewithbotina.com

Auth header: Authorization: Bearer <access_token>

Auth cookies: cwb_access (access token), cwb_refresh (refresh token)

Admin endpoints: require an authenticated user with is_admin=true.

CORS: production responses include CORS headers for the configured frontend origin (defaults to https://blog.codewithbotina.com).

Responses: many endpoints return a wrapped JSON shape { success, message, data }. Some endpoints return custom JSON on success (explicitly marked below).

Authentication

OAuth Flow (as implemented)

1) Browser navigates to: GET /api/auth/google?next={frontendUrl}
2) Backend sets PKCE cookie and 302 redirects to Supabase OAuth authorize URL
3) Supabase redirects to: {frontendUrl}/auth/callback?code=...&pkce=...&next=...
4) Frontend forwards the code to: GET /api/auth/callback?code=...&pkce=...&next=...
5) Backend exchanges code, sets auth cookies, and 302 redirects to next (or /{lang}/)

GET https://api.codewithbotina.com/api/auth/google

Initiate Google OAuth with PKCE and redirect the user.

Auth: Public

Query: next (optional; must be same origin as the configured frontend or it is ignored)

Example

GET /api/auth/google?next=https://blog.codewithbotina.com/es/

Success

302 redirect to Supabase OAuth authorize URL. Sets a short-lived cwb_pkce cookie.

Errors

  • 429 Too many requests
  • 500 Internal server error

GET https://api.codewithbotina.com/api/auth/callback

Finalize OAuth: exchange code for a session, set auth cookies, clear PKCE cookie, then redirect to the frontend.

Auth: Public

Query: code (required), pkce (optional token), next (optional)

Example

GET /api/auth/callback?code=AUTH_CODE&pkce=PKCE_TOKEN&next=https://blog.codewithbotina.com/es/

Success

302 redirect to next (if valid) or to /{lang}/ on the configured frontend origin (defaults to /en/).

Errors

  • 400 Missing authorization code
  • 400 Missing PKCE code verifier
  • 500 Internal server error

GET https://api.codewithbotina.com/api/auth/me

Get the current user. If the access token is invalid/expired but a refresh cookie exists, this endpoint attempts a refresh and sets updated cookies.

Auth: Authenticated user

Auth

Authorization: Bearer <access_token>
or Cookie: cwb_access=...; cwb_refresh=...

Success (custom JSON)

{
  "success": true,
  "user": {
    "id": "uuid",
    "email": "user@example.com",
    "full_name": "Name",
    "avatar_url": "https://...",
    "google_id": "123456",
    "created_at": "2026-02-01T10:00:00Z",
    "last_login": "2026-02-07T15:30:00Z",
    "is_admin": false
  }
}

Errors

  • 401 Unauthorized
  • 500 Internal server error

POST https://api.codewithbotina.com/api/auth/refresh

Refresh an access token. Refresh token is taken from the JSON body (refresh_token) or the cwb_refresh cookie.

Auth: Public (refresh token required)

Request body (optional)

{ "refresh_token": "v1.MR..." }

Success (custom JSON)

{
  "success": true,
  "access_token": "eyJhbG...",
  "refresh_token": "v1.MR...",
  "expires_in": 3600
}

Errors

  • 401 Missing refresh token
  • 500 Internal server error

POST https://api.codewithbotina.com/api/auth/signout

Sign out (Supabase global sign-out) and clear auth cookies.

Auth: Authenticated user

Auth

Authorization: Bearer <access_token>
or Cookie: cwb_access=...

Success (custom JSON)

{ "success": true, "message": "Signed out successfully" }

Errors

  • 401 Unauthorized
  • 500 Internal server error

GET https://api.codewithbotina.com/api/auth/debug

Debug endpoint: reports whether auth header/cookies exist and resolves the user if a token is present.

Auth: Public

Success (custom JSON)

{
  "hasAuthHeader": true,
  "hasCookies": true,
  "hasAccessCookie": true,
  "hasPkceCookie": false,
  "user": { "id": "...", "email": "...", "is_admin": false },
  "error": null
}

Posts

GET https://api.codewithbotina.com/api/posts

List posts. Supports language filtering and a basic autocomplete query on titulo/slug.

Auth: Public

Query: language (optional), q (optional), limit, offset

Example

GET /api/posts?language=es&q=react&limit=20&offset=0

Success (wrapped)

{
  "success": true,
  "message": "Posts fetched successfully",
  "data": {
    "posts": [
      { "id": "uuid", "titulo": "...", "slug": "...", "body": "...", "imagen_url": null, "fecha": "...", "updated_at": "...", "language": "es" }
    ],
    "limit": 20,
    "offset": 0
  }
}

GET https://api.codewithbotina.com/api/posts/{slug}

Fetch a post by slug, optionally by language, and include its tags.

Auth: Public

Query: language (optional)

Example

GET /api/posts/my-post?language=en

Success (wrapped)

{
  "success": true,
  "message": "Post fetched successfully",
  "data": {
    "id": "uuid",
    "titulo": "...",
    "slug": "my-post",
    "body": "...",
    "imagen_url": null,
    "fecha": "...",
    "updated_at": "...",
    "language": "en",
    "tags": [{ "id": "uuid", "name": "Tag", "slug": "tag", "usage_count": 3 }]
  }
}

POST https://api.codewithbotina.com/api/posts/create

Create posts (admin only). Accepts single post or a batch payload.

Auth: Admin

Request body (single)

{
  "titulo": "Title",
  "slug": "title",
  "body": "Min 100 chars...",
  "language": "en",
  "imagen_url": "https://...",
  "tag_ids": ["uuid"]
}

Request body (batch)

{
  "posts": [
    { "titulo": "Hola", "slug": "hola", "body": "Min 100 chars...", "language": "es", "tag_ids": [] },
    { "titulo": "Hello", "slug": "hello", "body": "Min 100 chars...", "language": "en", "tag_ids": [] }
  ]
}

Success (wrapped, single)

{
  "success": true,
  "message": "Post created successfully",
  "data": { "id": "uuid", "titulo": "Title", "slug": "title", "fecha": "..." }
}

Success (wrapped, batch)

{
  "success": true,
  "message": "Posts created successfully",
  "data": {
    "posts": [{ "id": "uuid", "titulo": "Hola", "slug": "hola", "fecha": "...", "language": "es" }],
    "translation_group_id": "uuid-or-null"
  }
}

PUT https://api.codewithbotina.com/api/posts/{slug}/update

Update a post by slug (admin only).

Auth: Admin

Request body

{
  "titulo": "Updated",
  "slug": "updated",
  "body": "Min 100 chars...",
  "language": "en",
  "imagen_url": null,
  "tag_ids": ["uuid"]
}

Success (wrapped)

{
  "success": true,
  "message": "Post updated successfully",
  "data": { "id": "uuid", "titulo": "Updated", "slug": "updated" }
}

PUT https://api.codewithbotina.com/api/posts/bulk-update

Bulk update/create/unlink translations (admin only).

Auth: Admin

Request body

{
  "updates": [{ "post_id": "uuid", "post": { "titulo": "...", "slug": "...", "body": "...", "language": "en" } }],
  "creates": [{ "base_post_id": "uuid", "post": { "titulo": "...", "slug": "...", "body": "...", "language": "es" } }],
  "unlinks": [{ "post_id": "uuid", "linked_post_id": "uuid" }]
}

Success (wrapped)

{
  "success": true,
  "message": "Posts updated successfully",
  "data": {
    "updated_post_ids": ["uuid"],
    "created_post_ids": ["uuid"],
    "unlinked_post_ids": ["uuid"],
    "translation_group_id_by_base_post_id": { "uuid": "uuid-or-null" }
  }
}

DELETE https://api.codewithbotina.com/api/posts/{slug}/delete

Delete a post (admin only). Requires confirm=true to execute.

Auth: Admin

Query: confirm (optional), language (optional)

Success (wrapped, confirm missing/false)

{
  "success": true,
  "message": "Confirmation required",
  "data": { "post_id": "uuid", "titulo": "...", "comments_count": 12, "reactions_count": 30, "likes_count": 25, "dislikes_count": 5, "imagen_url": null, "requires_confirmation": true }
}

Success (wrapped, confirm=true)

{
  "success": true,
  "message": "Post deleted successfully",
  "data": { "post_id": "uuid", "comments_deleted": 12, "reactions_deleted": 30 }
}

GET https://api.codewithbotina.com/api/posts/{slug}/exists

Check whether a slug exists.

Auth: Public

Query: language (optional)

Success (custom JSON)

{ "exists": true }

POST https://api.codewithbotina.com/api/posts/upload-image

Upload a featured image (admin only). Uses multipart/form-data.

Auth: Admin

Form fields

image: file
title: string
slug: string

Success (wrapped)

{
  "success": true,
  "message": "Image uploaded successfully",
  "data": { "url": "https://...", "filename": "file.webp", "size": 123 }
}

GET https://api.codewithbotina.com/api/posts/{slug}/tags

Fetch tags for a post slug.

Auth: Public

Success (wrapped)

{
  "success": true,
  "data": { "tags": [{ "id": "uuid", "name": "Tag", "slug": "tag", "description": null, "usage_count": 3 }] }
}

GET https://api.codewithbotina.com/api/posts/{postId}/translations

List translations for a post by UUID. Note: the route param name is slug in code, but this endpoint expects a UUID.

Auth: Public

Success (wrapped)

{
  "success": true,
  "message": "Translations fetched successfully",
  "data": {
    "translations": [
      { "post_id": "uuid", "language": "es", "slug": "hola", "titulo": "Hola", "fecha": "...", "imagen_url": null, "translation_group_id": "uuid" }
    ]
  }
}

GET https://api.codewithbotina.com/api/posts/{postId}/translation/{language}

Fetch the translation summary for a given target language.

Auth: Public

Success (wrapped)

{
  "success": true,
  "message": "Translation fetched successfully",
  "data": { "post_id": "uuid", "language": "en", "slug": "hello", "titulo": "Hello", "fecha": "...", "imagen_url": null, "translation_group_id": "uuid" }
}

GET https://api.codewithbotina.com/api/posts/test

Diagnostics endpoint for Supabase connectivity and auth decoding.

Auth: Public (Authorization header is optional)

Success (custom JSON)

{ "success": true, "logs": ["..."], "timestamp": "..." }

Tags

GET https://api.codewithbotina.com/api/tags

List tags with optional query/sort/pagination and optional language filter.

Auth: Public

Query: q (optional), sort (most_used|az|za|recent), limit, offset, language (optional; use all to disable)

Success (wrapped)

{
  "success": true,
  "data": { "tags": [{ "id": "uuid", "name": "Tag", "slug": "tag", "description": null, "created_at": null, "usage_count": 3 }], "total": 1, "limit": 20, "offset": 0 }
}

Note

When language is set (and not all), tags are filtered to those used by posts in that language and each tag includes usage_count_language.

GET https://api.codewithbotina.com/api/tags/{slug}

Fetch a tag and its posts.

Auth: Public

Query: language (optional), limit (10|50|100; defaults to 20), offset

Success (wrapped)

{
  "success": true,
  "data": { "tag": { "id": "uuid", "name": "Tag", "slug": "tag", "description": null, "created_at": null }, "posts": [], "total_posts": 0 }
}

GET https://api.codewithbotina.com/api/tags/autocomplete

Autocomplete tag names (min 2 characters).

Auth: Public

Query: q

Success (wrapped)

{ "success": true, "data": { "tags": [{ "id": "uuid", "name": "DevOps", "slug": "devops", "usage_count": 8 }] } }

POST https://api.codewithbotina.com/api/tags/suggest

Suggest tags based on a title/body payload.

Auth: Public

Request body

{ "title": "Post title", "body": "Post body" }

Success (wrapped)

{ "success": true, "data": { "suggestions": [{ "id": "uuid", "name": "Tag", "slug": "tag", "usage_count": 3 }] } }

POST https://api.codewithbotina.com/api/tags/create

Create a tag (admin only).

Auth: Admin

Request body

{ "name": "New Tag", "description": "Optional" }

Success (wrapped)

{ "success": true, "message": "Tag created", "data": { "tag": { "id": "uuid", "name": "New Tag", "slug": "new-tag", "usage_count": 0, "description": null } } }

Comments

Route note: /api/comments/{commentId} is used for all methods. In the implementation, GET and POST treat that parameter as a post id, while PUT and DELETE treat it as a comment id.

GET https://api.codewithbotina.com/api/comments/{postId}

List comments for a post.

Auth: Public

Success (custom JSON)

{ "success": true, "data": [], "meta": { "total": 0, "pinned_count": 0 } }

POST https://api.codewithbotina.com/api/comments/{postId}

Create a comment for a post.

Auth: Authenticated user

Request body

{ "content": "Min 10 chars..." }

Success (custom JSON)

{ "success": true, "data": { "id": "uuid", "post_id": "uuid", "user_id": "uuid", "content": "...", "is_pinned": false, "created_at": "...", "updated_at": "...", "user": { "id": "uuid", "full_name": "Name", "avatar_url": null } } }

PUT https://api.codewithbotina.com/api/comments/{commentId}

Update a comment (author only).

Auth: Authenticated user

Request body

{ "content": "Min 10 chars..." }

Success (custom JSON)

{ "success": true, "data": { "id": "uuid", "post_id": "uuid", "user_id": "uuid", "content": "...", "created_at": "...", "updated_at": "...", "is_pinned": false } }

DELETE https://api.codewithbotina.com/api/comments/{commentId}

Delete a comment (author or admin).

Auth: Authenticated user

Success (custom JSON)

{ "success": true, "message": "Comment deleted successfully" }

POST https://api.codewithbotina.com/api/comments/{commentId}/pin

Pin a comment (admin only).

Auth: Admin

Success (custom JSON)

{ "success": true, "data": { "id": "uuid", "is_pinned": true, "updated_at": "..." } }

POST https://api.codewithbotina.com/api/comments/{commentId}/unpin

Unpin a comment (admin only).

Auth: Admin

Success (custom JSON)

{ "success": true, "data": { "id": "uuid", "is_pinned": false, "updated_at": "..." } }

Reactions

GET https://api.codewithbotina.com/api/reactions/{postId}

Get like/dislike counts for a post.

Auth: Public

Success (custom JSON)

{ "success": true, "data": { "post_id": "uuid", "likes": 10, "dislikes": 2, "total": 12 } }

POST https://api.codewithbotina.com/api/reactions/{postId}/like

Toggle like (authenticated user).

Auth: Authenticated user

Success (custom JSON)

{ "success": true, "data": { "reaction": "like", "counts": { "likes": 11, "dislikes": 2, "total": 13 } } }

POST https://api.codewithbotina.com/api/reactions/{postId}/dislike

Toggle dislike (authenticated user).

Auth: Authenticated user

Success (custom JSON)

{ "success": true, "data": { "reaction": "dislike", "counts": { "likes": 10, "dislikes": 3, "total": 13 } } }

GET https://api.codewithbotina.com/api/reactions/user/{postId}

Get the current user reaction for a post.

Auth: Authenticated user

Success (custom JSON)

{ "success": true, "data": { "user_reaction": "like" } }

Storage

GET https://api.codewithbotina.com/api/storage/images

List images in the storage bucket (admin only).

Auth: Admin

Query: limit, offset, q (optional)

Success (wrapped)

{
  "success": true,
  "message": "Images fetched successfully",
  "data": { "bucket": "blog-images", "images": [], "limit": 48, "offset": 0, "next_offset": 48, "has_more": false }
}

Users

GET https://api.codewithbotina.com/api/users/profile

Fetch the current user profile stats and liked posts.

Auth: Authenticated user

Query: limit (1..200), offset

Success (wrapped)

{
  "success": true,
  "message": "Profile fetched successfully",
  "data": { "stats": { "likes_given": 0, "dislikes_given": 0, "comments_posted": 0 }, "liked_posts_total": 0, "liked_posts": [], "pagination": { "limit": 20, "offset": 0 } }
}

POST https://api.codewithbotina.com/api/users/delete-account

Delete the current user account (authenticated user) and clear auth cookies.

Auth: Authenticated user

Success (wrapped)

{ "success": true, "message": "Account deleted successfully", "data": { "deleted": true } }

Cookie Consent

Contact

POST https://api.codewithbotina.com/api/contact

Submit a contact form message.

Auth: Public

Request body

{
  "nombre": "string (max 100 chars)",
  "correo": "valid email",
  "mensaje": "string (10-1000 chars)"
}

Success (wrapped)

{
  "success": true,
  "message": "Contact form submitted successfully",
  "data": { "id": "uuid", "nombre": "Name", "correo": "email@example.com", "mensaje": "...", "created_at": "..." }
}

Errors

  • 400 Validation failed (details provided)
  • 429 Too many requests
  • 500 Internal server error

⚡ Try It Out

Test the live API directly from your browser.

Health

GET https://api.codewithbotina.com/api/health

Service health check for monitoring.

Auth: Public

Success (custom JSON)

{ "status": "ok", "timestamp": "2026-03-01T12:00:00Z" }

Error Handling

Most endpoints return errors using the backend helper errorResponse:

{
  "success": false,
  "error": "Error message",
  "details": { "field": "optional detail" }
}

Endpoints marked “custom JSON” still use errorResponse for errors.