diff --git a/apps/api/v1/routes/budgets.ts b/apps/api/v1/routes/budgets.ts
index 18fc2de8..6d8dc2e8 100644
--- a/apps/api/v1/routes/budgets.ts
+++ b/apps/api/v1/routes/budgets.ts
@@ -2,7 +2,18 @@ import { BudgetUserPermissionSchema } from '@/prisma/generated/zod'
 import { zValidator } from '@hono/zod-validator'
 import { Hono } from 'hono'
 import { z } from 'zod'
-import { getAuthUserStrict } from '../middlewares/auth'
+import { getAuthUser, getAuthUserStrict } from '../middlewares/auth'
+import {
+  canUserDeleteBudgetInvitation,
+  canUserGenerateBudgetInvitation,
+  canUserInviteUserToBudget,
+  deleteBudgetInvitation,
+  findBudgetInvitation,
+  generateBudgetInvitation,
+  inviteUserToBudget,
+  respondToBudgetInvitation,
+  verifyBudgetInvitationToken,
+} from '../services/budget-invitation.service'
 import {
   canUserCreateBudget,
   canUserDeleteBudget,
@@ -14,14 +25,22 @@ import {
   findBudgetsOfUser,
   updateBudget,
 } from '../services/budget.service'
-import { zCreateBudget, zUpdateBudget } from '../validation'
+import { zCreateBudget, zCreateUser, zUpdateBudget } from '../validation'
 
 const router = new Hono()
 
-const zBudgetIdParamValidator = zValidator(
+const zBudgetParamValidator = zValidator(
+  'param',
+  z.object({
+    budgetId: z.string(),
+  }),
+)
+
+const zInvitationParamValidator = zValidator(
   'param',
   z.object({
     budgetId: z.string(),
+    invitationId: z.string(),
   }),
 )
 
@@ -47,7 +66,7 @@ router.post('/', zValidator('json', zCreateBudget), async (c) => {
   const user = getAuthUserStrict(c)
 
   if (!(await canUserCreateBudget({ user }))) {
-    return c.json({ message: 'User cannot create budget' }, 403)
+    return c.json({ message: 'user cannot create budget' }, 403)
   }
 
   const createBudgetData = c.req.valid('json')
@@ -57,14 +76,14 @@ router.post('/', zValidator('json', zCreateBudget), async (c) => {
   return c.json(budget, 201)
 })
 
-router.get('/:budgetId', zBudgetIdParamValidator, async (c) => {
+router.get('/:budgetId', zBudgetParamValidator, async (c) => {
   const user = getAuthUserStrict(c)
   const { budgetId } = c.req.valid('param')
 
   const budget = await findBudget({ budgetId })
 
   if (!(budget && (await canUserReadBudget({ user, budget })))) {
-    return c.json(null, 404)
+    return c.json({ message: 'budget not found' }, 404)
   }
 
   return c.json(budget)
@@ -72,7 +91,7 @@ router.get('/:budgetId', zBudgetIdParamValidator, async (c) => {
 
 router.put(
   '/:budgetId',
-  zBudgetIdParamValidator,
+  zBudgetParamValidator,
   zValidator('json', zUpdateBudget),
   async (c) => {
     const user = getAuthUserStrict(c)
@@ -81,11 +100,11 @@ router.put(
     const budget = await findBudget({ budgetId })
 
     if (!(budget && (await canUserReadBudget({ user, budget })))) {
-      return c.json(null, 404)
+      return c.json({ message: 'budget not found' }, 404)
     }
 
     if (!(await canUserUpdateBudget({ user, budget }))) {
-      return c.json({ message: 'User cannot update budget' }, 403)
+      return c.json({ message: 'user cannot update budget' }, 403)
     }
 
     const updateBudgetData = c.req.valid('json')
@@ -99,18 +118,18 @@ router.put(
   },
 )
 
-router.delete('/:budgetId', zBudgetIdParamValidator, async (c) => {
+router.delete('/:budgetId', zBudgetParamValidator, async (c) => {
   const user = getAuthUserStrict(c)
   const { budgetId } = c.req.valid('param')
 
   const budget = await findBudget({ budgetId })
 
   if (!(budget && (await canUserReadBudget({ user, budget })))) {
-    return c.json(null, 404)
+    return c.json({ message: 'budget not found' }, 404)
   }
 
   if (!(await canUserDeleteBudget({ user, budget }))) {
-    return c.json({ message: 'User cannot delete budget' }, 403)
+    return c.json({ message: 'user cannot delete budget' }, 403)
   }
 
   await deleteBudget({ budgetId })
@@ -118,4 +137,146 @@ router.delete('/:budgetId', zBudgetIdParamValidator, async (c) => {
   return c.json(budget)
 })
 
+/** Generate sharable invitation link */
+router.post(
+  '/:budgetId/invitations/generate',
+  zBudgetParamValidator,
+  async (c) => {
+    const user = getAuthUserStrict(c)
+    const { budgetId } = c.req.valid('param')
+
+    const budget = await findBudget({ budgetId })
+
+    if (!(budget && (await canUserReadBudget({ user, budget })))) {
+      return c.json({ message: 'budget not found' }, 404)
+    }
+
+    if (!(await canUserGenerateBudgetInvitation({ user, budget }))) {
+      return c.json(
+        { message: 'user cannot generate invite link to budget' },
+        403,
+      )
+    }
+
+    const invitation = await generateBudgetInvitation({
+      budgetId,
+      userId: user.id,
+    })
+
+    return c.json(invitation)
+  },
+)
+
+/** Invite user to budget by email */
+router.post(
+  '/:budgetId/invitations',
+  zBudgetParamValidator,
+  zValidator(
+    'json',
+    z.object({
+      email: z.string().email(),
+      permission: BudgetUserPermissionSchema.optional(),
+    }),
+  ),
+  async (c) => {
+    const user = getAuthUserStrict(c)
+    const { budgetId } = c.req.valid('param')
+
+    const budget = await findBudget({ budgetId })
+
+    if (!(budget && (await canUserReadBudget({ user, budget })))) {
+      return c.json({ message: 'budget not found' }, 404)
+    }
+
+    if (!(await canUserInviteUserToBudget({ user, budget }))) {
+      return c.json({ message: 'user cannot invite users to this budget' }, 403)
+    }
+
+    const { email, permission } = c.req.valid('json')
+
+    const invitation = await inviteUserToBudget({
+      inviter: user,
+      budget,
+      email,
+      permission,
+    })
+
+    return c.json(invitation, 201)
+  },
+)
+
+/** Delete/revoke invitation */
+router.delete(
+  '/:budgetId/invitations/:invitationId',
+  zInvitationParamValidator,
+  async (c) => {
+    const user = getAuthUserStrict(c)
+    const { budgetId, invitationId } = c.req.valid('param')
+
+    const budget = await findBudget({ budgetId })
+
+    if (!(budget && (await canUserReadBudget({ user, budget })))) {
+      return c.json({ message: 'budget not found' }, 404)
+    }
+
+    const invitation = await findBudgetInvitation({ invitationId })
+
+    if (!invitation) {
+      return c.json({ message: 'invitation not found' }, 404)
+    }
+
+    if (!(await canUserDeleteBudgetInvitation({ user, invitation }))) {
+      return c.json({ message: 'user cannot delete this invitation' }, 403)
+    }
+
+    await deleteBudgetInvitation({ invitationId })
+
+    return c.json(invitation)
+  },
+)
+
+/** Join budget with token */
+router.post(
+  '/response-invitation',
+  zValidator(
+    'json',
+    z.object({
+      token: z.string(),
+      userData: zCreateUser.optional(),
+      accept: z.boolean(),
+    }),
+  ),
+  async (c) => {
+    const user = getAuthUser(c)
+    const { token, userData, accept } = c.req.valid('json')
+
+    if (!user && !userData) {
+      return c.json({ message: 'user data is required' }, 400)
+    }
+
+    const invitation = await verifyBudgetInvitationToken({ token })
+
+    if (!invitation) {
+      return c.json(
+        {
+          message: 'invalid or expired invitation token',
+        },
+        404,
+      )
+    }
+
+    const response = await respondToBudgetInvitation({
+      invitation,
+      accept,
+      userData: {
+        id: user?.id,
+        email: (invitation.email ?? user?.email ?? userData?.email)!,
+        name: (user?.name ?? userData?.name)!,
+      },
+    })
+
+    return c.json(response)
+  },
+)
+
 export default router
diff --git a/apps/api/v1/services/budget-invitation.service.ts b/apps/api/v1/services/budget-invitation.service.ts
index 2ad29c78..ee3a8eca 100644
--- a/apps/api/v1/services/budget-invitation.service.ts
+++ b/apps/api/v1/services/budget-invitation.service.ts
@@ -121,6 +121,16 @@ export async function generateBudgetInvitation({
   return invitation
 }
 
+export async function findBudgetInvitation({
+  invitationId,
+}: {
+  invitationId: string
+}): Promise<BudgetUserInvitation | null> {
+  return prisma.budgetUserInvitation.findUnique({
+    where: { id: invitationId },
+  })
+}
+
 export async function findBudgetInvitations({
   budgetId,
   permission,