Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Avelero/avelero/llms.txt

Use this file to discover all available pages before exploring further.

Avelero’s API uses Supabase authentication for user-facing endpoints and API keys for server-to-server communication. All private endpoints require a valid JWT token in the Authorization header.

Authentication methods

User authentication via Supabase JWT tokens. Use this for all client-facing API calls from the web application.

JWT token authentication

How it works

When a user signs in through Supabase, the client receives a JWT access token. This token must be included in the Authorization header for all authenticated requests. The API validates tokens and extracts user context in every request:
apps/api/src/trpc/init.ts:113
export async function createTRPCContextFromHeaders(
  headers: Record<string, string | undefined>,
): Promise<TRPCContext> {
  const authHeader = headers.authorization ?? headers.Authorization;
  const supabase = createSupabaseForRequest(authHeader ?? null);

  // Extract bearer token and resolve user
  const bearerToken = authHeader
    ? authHeader.startsWith("Bearer ")
      ? authHeader.slice("Bearer ".length)
      : authHeader
    : undefined;

  let user: User | null = null;
  if (bearerToken) {
    const { data: userRes } = await supabase.auth.getUser(bearerToken);
    user = userRes?.user ?? null;
  }

  // Query user's active brand
  let brandId: string | null | undefined = undefined;
  if (user) {
    const { data: userRow } = await supabase
      .from("users")
      .select("brand_id")
      .eq("id", user.id)
      .maybeSingle();

    brandId = userRow?.brand_id ?? null;
  }

  return {
    supabase,
    user,
    brandId,
    // ... other context
  };
}

Creating a Supabase client

The API creates tenant-specific Supabase clients bound to each request’s auth token:
apps/api/src/trpc/init.ts:71
function createSupabaseForRequest(
  authHeader?: string | null,
): SupabaseClient<SupabaseDatabase> {
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL as string;
  const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string;
  
  return createSupabaseJsClient<SupabaseDatabase>(url, anon, {
    global: authHeader
      ? {
          headers: {
            Authorization: authHeader.startsWith("Bearer ")
              ? authHeader
              : `Bearer ${authHeader}`,
          },
        }
      : undefined,
  });
}

Client-side authentication

The tRPC client automatically attaches JWT tokens from the active Supabase session:
apps/app/src/trpc/client.tsx:52
async headers() {
  const supabase = createSupabaseClient();
  const {
    data: { session },
  } = await supabase.auth.getSession();
  
  const token = session?.access_token;
  
  return token
    ? { Authorization: `Bearer ${token}` }
    : {};
}

Server-side authentication

Server components use a custom fetch function that includes auth headers from Next.js request context:
apps/app/src/trpc/server.tsx:30
async function fetchWithAuth(
  input: RequestInfo | URL,
  init?: RequestInit,
): Promise<Response> {
  const token = await getAuthToken();
  const authHeaders: HeadersInit = token
    ? { Authorization: `Bearer ${token}` }
    : {};

  return fetch(input, {
    ...init,
    headers: {
      ...init?.headers,
      ...authHeaders,
    },
  });
}

Brand membership and roles

Brand context resolution

After user authentication, the API resolves the user’s active brand and role:
apps/api/src/trpc/middleware/auth/brand.ts:25
export async function ensureBrandContext(
  ctx: TRPCContext,
): Promise<BrandContextCache> {
  if (!ctx.user) {
    return { brandId: null, role: null };
  }

  const brandId = ctx.brandId ?? null;
  if (!brandId) {
    return { brandId: null, role: null };
  }

  // Query brand membership and role
  const membership = await ctx.db.query.brandMembers.findFirst({
    columns: { id: true, role: true },
    where: (brandMembers, { eq, and }) =>
      and(
        eq(brandMembers.brandId, brandId),
        eq(brandMembers.userId, ctx.user!.id),
      ),
  });

  if (!membership) {
    return { brandId: null, role: null };
  }

  const role = isRole(membership.role) ? membership.role : null;

  return { brandId, role };
}

User roles

Avelero supports two user roles within a brand:
  • Owner - Full access to brand settings, member management, and destructive operations
  • Member - Standard access to products, catalog, and integrations
See apps/api/src/config/roles.ts:6 for role definitions.
Brand context is cached per request to avoid duplicate database queries. The cache is stored as a symbol on the tRPC context object.

Procedure types and permissions

Public procedures

No authentication required. Used for health checks and public DPP access:
apps/api/src/trpc/init.ts:252
export const publicProcedure = t.procedure;
Example: Fetching a published DPP by UPID
apps/api/src/trpc/routers/dpp-public/index.ts:97
getByPassportUpid: publicProcedure
  .input(z.object({ upid: upidSchema }))
  .query(async ({ ctx, input }) => {
    const result = await getPublicDppByUpid(ctx.db, input.upid);
    return result;
  })

Protected procedures

Require valid user authentication and brand membership:
apps/api/src/trpc/init.ts:257
export const protectedProcedure = t.procedure
  .use(withBrandContext)
  .use(async (opts) => {
    const { user, brandId, role } = opts.ctx;
    if (!user) throw unauthorized();

    const authedCtx: AuthenticatedTRPCContext = {
      ...opts.ctx,
      user: user as User,
      brandId: brandId ?? null,
      role: role ?? null,
    };

    return opts.next({ ctx: authedCtx });
  });
Example: Listing products in the current brand
list: protectedProcedure
  .input(z.object({ limit: z.number().optional() }))
  .query(async ({ ctx, input }) => {
    // ctx.user is guaranteed non-null
    // ctx.brandId may be null if user hasn't selected a brand
    const products = await ctx.db.query.products.findMany({
      where: eq(products.brandId, ctx.brandId),
      limit: input.limit,
    });
    return products;
  })

Brand-required procedures

Require an active brand selection. Used for mutations that modify brand data:
apps/api/src/trpc/init.ts:285
export const brandRequiredProcedure = protectedProcedure.use(requireBrand);

const requireBrand = t.middleware(({ ctx, next }) => {
  const authedCtx = ctx as AuthenticatedTRPCContext;
  
  if (!authedCtx.brandId) {
    throw noBrandSelected();
  }
  
  const brandId = authedCtx.brandId as string;
  
  return next({
    ctx: {
      ...authedCtx,
      brandId,
    },
  });
});
Example: Creating a new product
create: brandRequiredProcedure
  .input(productSchema)
  .mutation(async ({ ctx, input }) => {
    // ctx.brandId is guaranteed non-null
    const product = await ctx.db.insert(products).values({
      ...input,
      brandId: ctx.brandId,
    });
    return product;
  })
When no brand is selected, the API returns a BAD_REQUEST error prompting the user to select or create a brand before proceeding.

API key authentication

Internal API keys

The internal router uses API key authentication for server-to-server communication. Background jobs (Trigger.dev) use this to emit WebSocket progress updates.
apps/api/src/trpc/routers/internal/index.ts:13
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY;

if (!INTERNAL_API_KEY) {
  throw new Error(
    "INTERNAL_API_KEY environment variable must be set"
  );
}

Validating API keys

Internal procedures validate the API key in the request input:
apps/api/src/trpc/routers/internal/index.ts:52
emitProgress: publicProcedure
  .input(z.object({
    apiKey: z.string(),
    jobId: z.string(),
    status: z.enum(["PENDING", "VALIDATING", "COMPLETED", "FAILED"]),
    // ... other fields
  }))
  .mutation(async ({ input }) => {
    // Verify internal API key
    if (input.apiKey !== INTERNAL_API_KEY) {
      throw badRequest("Invalid internal API key");
    }

    const { apiKey, ...progressData } = input;
    websocketManager.emit(input.jobId, progressData);

    return { success: true };
  })

Setting up API keys

Configure the internal API key in your environment:
# apps/api/.env.example
INTERNAL_API_KEY=dev-internal-key
Never commit API keys to version control. Use environment variables and secure secret management in production.

Elevated privileges (admin client)

The API creates an elevated Supabase client with service role permissions for privileged operations:
apps/api/src/trpc/init.ts:97
function createSupabaseAdmin(): SupabaseClient<SupabaseDatabase> | null {
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL as string | undefined;
  const serviceKey = process.env.SUPABASE_SERVICE_KEY as string | undefined;
  
  if (!url || !serviceKey) return null;
  
  return createSupabaseJsClient<SupabaseDatabase>(url, serviceKey);
}
The admin client is available in the tRPC context as ctx.supabaseAdmin and is used for:
  • Deleting storage objects across brands
  • Removing users from the auth system
  • Bypassing Row Level Security (RLS) policies
Use the admin client sparingly. Most operations should use the tenant-scoped client (ctx.supabase) to respect RLS policies.

Security headers

The API enforces security headers on all responses:
apps/api/src/index.ts:20
app.use(secureHeaders());
This includes:
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • X-XSS-Protection: 1; mode=block
  • Referrer-Policy: strict-origin-when-cross-origin

Error responses

Authentication errors use standardized tRPC error codes:

Unauthorized (missing auth)

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Authentication required"
  }
}

Forbidden (insufficient permissions)

{
  "error": {
    "code": "FORBIDDEN",
    "message": "Insufficient permissions"
  }
}

Bad request (no brand selected)

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "No brand selected. Please select or create a brand."
  }
}

Best practices

1

Always use HTTPS in production

Never send JWT tokens over unencrypted connections. Configure SSL/TLS certificates for your API domain.
2

Implement token refresh

Supabase tokens expire after 1 hour. Implement automatic refresh in your client to prevent session interruptions.
3

Handle brand selection

Check for BAD_REQUEST errors indicating missing brand context. Prompt users to select a brand before making mutations.
4

Use request-scoped clients

Always use ctx.supabase (not a global client) to ensure queries respect the authenticated user’s permissions.
5

Validate roles on the server

Never trust client-side role checks. Always validate permissions in API procedures using ctx.role.

Next steps

Introduction

Learn about the API architecture and design

Products API

Explore the products API endpoints

Build docs developers (and LLMs) love