/ Docs

Functions

Volcano Functions run serverless backend logic that can't happen in the browser, like complex database queries or third-party API calls.

Volcano Functions are serverless functions that run your custom backend logic. They're perfect for operations that can't be done from the browser, like complex database queries, third-party API integrations, or secure operations.

Overview

Functions provide:

  • Secure Execution - Code runs server-side, away from client inspection
  • Full SQL Access - Write complex queries with JOINs, CTEs, and more
  • Third-Party APIs - Call external services with secrets kept secure
  • Background Jobs - Process data, send emails, generate reports
  • User Context - Functions receive the authenticated user's identity

Invoking Functions

Basic Invocation

const { data, status, version, error } = await volcano.functions.invoke('send-welcome-email', {
  template: 'welcome',
  recipientId: user.id,
});

if (error) {
  console.error('Function failed:', error.message);
  return;
}

console.log('Status:', status);
console.log('Version:', version);
console.log('Result:', data);

version maps to the X-Volcano-Version response header (<version> in production, <env>-<version> in non-production).

Function resolution and invocation recover from a platform HTTP 401 before dispatch: the SDK refreshes the captured session and retries the rejected request once. Concurrent calls share successful recovery. Replacing or signing out that session prevents replay under another identity. The call preserves its original payload values. A function's own response, HTTP 403, or a network failure never triggers this retry. Anonymous and service keys do not refresh.

Starting sign-out prevents a pending invocation from dispatching. An invocation already sent to the function cannot be cancelled by changing the local session.

With Typed Response

interface DashboardStats {
  totalUsers: number;
  activeToday: number;
  revenue: number;
}

const { data, status, headers, version, error } = await volcano.functions.invoke<
  { timeframe: string },
  DashboardStats
>('get-dashboard-stats', {
  timeframe: 'last-30-days',
});

if (data) {
  console.log('HTTP status:', status);
  console.log('X-Volcano-Version:', version);
  console.log('Total users:', data.totalUsers);
  console.log('Active today:', data.activeToday);
}

No Payload

const { data, status, headers, version, error } = await volcano.functions.invoke('health-check');

Where the request goes

Functions answer on their own domain, not on your API URL. invoke looks the name up once, then sends the invocation to the endpoint the platform returned:

https://<function-id>.functions.volcano.run/

If you restrict outbound requests, allow that domain alongside your API host. In a browser, it needs a connect-src entry in your Content Security Policy; on a server, it needs an egress rule. A request blocked here comes back as a VolcanoSystemError with status: null, which is how you tell it apart from a function that ran and returned an error of its own.

The lookup is cached for as long as the platform says it is valid, so repeated calls to the same function do not repeat it. Nothing to configure: the SDK never builds the host from apiUrl, because the two differ per deployment.

Deployments without a public function domain — local development, for one — invoke through the API host instead. Same call, same result.

Authentication

The SDK uses the user's access token when a user is signed in. The function receives the user's context and can:

  1. Verify the user's identity
  2. Query the database with Row-Level Security
  3. Access user-specific data
// Client-side
await volcano.auth.signIn({ email: 'alice@example.com', password: '...' });

// Function is called with Alice's identity
const { data } = await volcano.functions.invoke('get-my-profile');
// Returns Alice's profile data

When no user is signed in, the SDK uses the project's anon key. This works only for functions configured as public. Public invocations do not receive user context.

const volcano = new VolcanoAuth({ anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY });
const { data } = await volcano.functions.invoke('contact-form', {
  email: 'lead@example.com',
});

Writing Functions

Functions are deployed through the Volcano dashboard or CLI. Here's what they look like:

Basic Function

// functions/hello.js
exports.handler = async (event) => {
  const name = event.name || 'World';

  return {
    statusCode: 200,
    body: JSON.stringify({
      message: `Hello, ${name}!`,
    }),
  };
};

With Authentication

// functions/get-my-posts.js
const { Client } = require('pg');

exports.handler = async (event) => {
  // User context is injected by Volcano
  const auth = event.__volcano_auth;

  if (!auth) {
    return {
      statusCode: 401,
      body: JSON.stringify({ error: 'Unauthorized' }),
    };
  }

  // Connect to database with user context. DATABASE_URL already carries the
  // unique username the proxy routes by; databaseConnectionString only sets
  // application_name to impersonate the auth user so Row-Level Security applies.
  const { databaseConnectionString } = require('@volcano.dev/sdk');
  const connStr = databaseConnectionString(process.env.DATABASE_URL, { userId: auth.user_id });

  const client = new Client({ connectionString: connStr });
  await client.connect();

  try {
    // RLS automatically filters to this user's posts
    const { rows } = await client.query('SELECT * FROM posts ORDER BY created_at DESC');

    return {
      statusCode: 200,
      body: JSON.stringify({ posts: rows }),
    };
  } finally {
    await client.end();
  }
};

Complex Database Query

// functions/dashboard-stats.js
const { Client } = require('pg');

exports.handler = async (event) => {
  const auth = event.__volcano_auth;
  if (!auth) {
    return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) };
  }

  const { timeframe } = event;
  const days = timeframe === 'last-7-days' ? 7 : 30;

  const { databaseConnectionString } = require('@volcano.dev/sdk');
  const connStr = databaseConnectionString(process.env.DATABASE_URL, { userId: auth.user_id });

  const client = new Client({ connectionString: connStr });
  await client.connect();

  try {
    // Complex query with CTE and aggregations
    const result = await client.query(`
      WITH recent_posts AS (
        SELECT * FROM posts
        WHERE created_at > NOW() - INTERVAL '${days} days'
      ),
      stats AS (
        SELECT
          COUNT(*) as total_posts,
          COUNT(DISTINCT DATE(created_at)) as active_days,
          SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END) as published
        FROM recent_posts
      )
      SELECT * FROM stats
    `);

    return {
      statusCode: 200,
      body: JSON.stringify(result.rows[0]),
    };
  } finally {
    await client.end();
  }
};

Calling External APIs

// functions/send-slack-notification.js
exports.handler = async (event) => {
  const auth = event.__volcano_auth;
  if (!auth) {
    return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) };
  }

  const { channel, message } = event;

  // Webhook URL stored as environment variable
  const webhookUrl = process.env.SLACK_WEBHOOK_URL;

  const response = await fetch(webhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      channel,
      text: message,
      username: 'Volcano Bot',
    }),
  });

  if (!response.ok) {
    return {
      statusCode: 500,
      body: JSON.stringify({ error: 'Failed to send notification' }),
    };
  }

  return {
    statusCode: 200,
    body: JSON.stringify({ success: true }),
  };
};

Work That Runs for Hours

A function invocation is bounded by its timeout, so work that has to survive longer — a multi-step pipeline, an approval that arrives tomorrow, a batch job over a flaky API — belongs in a durable function, which checkpoints its progress and resumes where it left off. Those are started with volcano.durable.start instead of functions.invoke, and answer with an execution to follow rather than a result.

Use Cases

When to Use Functions

Functions are ideal for:

Use CaseExample
Complex QueriesJOINs, CTEs, window functions
AggregationsDashboard statistics, reports
Stored ProceduresBusiness logic in PostgreSQL
External APIsStripe payments, SendGrid emails
File ProcessingImage resizing, PDF generation
Scheduled TasksDaily reports, cleanup jobs
Admin OperationsBulk updates, data migrations
Secure OperationsSecret key usage, privileged actions

When to Use Query Builder

The browser-based query builder is better for:

  • Simple CRUD operations
  • Single-table queries with filters
  • Real-time updates (less latency)
  • Reducing backend code

Error Handling

Client-Side

Resolution and pre-dispatch HTTP errors retain error.status, plus error.code and error.retryAfter when the server supplies them. retryAfter is a delay in seconds. These fields remain available after narrowing an invocation error with VolcanoSystemError.is(error). Transport failures have a null status and no HTTP metadata. A function's own HTTP response remains in data and status, with error set to null.

const { data, error } = await volcano.functions.invoke('process-payment', {
  amount: 1999,
  currency: 'usd',
});

if (error) {
  // Platform-layer failure — the invocation never reached your function
  // (deploy down/failed, gateway error, or a network failure). These are a
  // `VolcanoSystemError`; detect with `error.isSystemError === true`.
  console.error('Function error:', error.message);
  showErrorToast('Payment failed. Please try again.');
  return;
}

// Check for business logic errors in the response
if (data.error) {
  console.error('Payment error:', data.error);
  showErrorToast(data.error);
  return;
}

console.log('Payment successful:', data.paymentId);

Function-Side

exports.handler = async (event) => {
  try {
    // Function logic
    const result = await processPayment(event);

    return {
      statusCode: 200,
      body: JSON.stringify({ success: true, paymentId: result.id }),
    };
  } catch (error) {
    console.error('Payment error:', error);

    // Return structured error
    return {
      statusCode: 400,
      body: JSON.stringify({
        error: error.message,
        code: error.code || 'PAYMENT_FAILED',
      }),
    };
  }
};

Environment Variables

Functions can access environment variables configured in the Volcano dashboard:

exports.handler = async (event) => {
  // Built-in variables. DATABASE_URL already carries the unique username the
  // proxy routes by; pass it to databaseConnectionString rather than building
  // application_name yourself.
  const dbUrl = process.env.DATABASE_URL;

  // Custom variables (set in dashboard)
  const stripeKey = process.env.STRIPE_SECRET_KEY;
  const sendgridKey = process.env.SENDGRID_API_KEY;

  // Use them
  const stripe = require('stripe')(stripeKey);
  // ...
};

Store sensitive data like API keys as environment variables rather than in code.

Database Access Patterns

User Context (RLS Enforced)

For queries that should respect Row-Level Security:

const { Client } = require('pg');
const { databaseConnectionString } = require('@volcano.dev/sdk');

// Inside exports.handler, where `event` is available:
const auth = event.__volcano_auth;
// Impersonates the auth user: application_name=volcano_user_access:{userId}
const connStr = databaseConnectionString(process.env.DATABASE_URL, { userId: auth.user_id });

const client = new Client({ connectionString: connStr });
// Queries filtered by RLS

Admin Access (Bypass RLS)

For administrative operations:

const { Client } = require('pg');
const { databaseConnectionString } = require('@volcano.dev/sdk');

// No userId: application_name=volcano_full_access
const connStr = databaseConnectionString(process.env.DATABASE_URL);

const client = new Client({ connectionString: connStr });
// Full access to all data

The proxy routes by the globally-unique username (volcano_client_{id}) that is already in DATABASE_URL; application_name only selects the access mode. Prefer databaseConnectionString over hand-building application_name. The helper preserves libpq connection syntax, including hostless and multi-host targets, and leaves unrelated query values unchanged.

Connection Pooling

The access mode and RLS identity are selected by application_name at connection startup, so they cannot be changed on a pooled connection after it is established. Pool connections that all share one access mode (e.g. a per-user function whose pool connection string already targets that user, or admin work), and open a fresh connection when the identity differs.

const { Pool } = require('pg');
const { databaseConnectionString } = require('@volcano.dev/sdk');

// Admin pool created outside the handler (reused across invocations). Its
// application_name is fixed to full_access at startup.
const adminPool = new Pool({
  connectionString: databaseConnectionString(process.env.DATABASE_URL),
  max: 20,
});

exports.handler = async (event) => {
  const auth = event.__volcano_auth;

  // Per-user RLS query: the connection must start up as this user, so use a
  // short-lived client with the user-access connection string.
  const { Client } = require('pg');
  const client = new Client({
    connectionString: databaseConnectionString(process.env.DATABASE_URL, { userId: auth.user_id }),
  });
  await client.connect();

  try {
    const { rows } = await client.query('SELECT * FROM posts'); // filtered by RLS
    return {
      statusCode: 200,
      body: JSON.stringify({ posts: rows }),
    };
  } finally {
    await client.end();
  }
};

Best Practices

Validate Input

Always validate function input:

exports.handler = async (event) => {
  const { email, amount } = event;

  if (!email || typeof email !== 'string') {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: 'Invalid email' }),
    };
  }

  if (typeof amount !== 'number' || amount <= 0) {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: 'Invalid amount' }),
    };
  }

  // Proceed with validated input
};

Check Authentication

Most functions should require authentication:

exports.handler = async (event) => {
  const auth = event.__volcano_auth;

  if (!auth) {
    return {
      statusCode: 401,
      body: JSON.stringify({ error: 'Authentication required' }),
    };
  }

  // auth.user_id - The authenticated user's ID
  // auth.email - The user's email
  // auth.role - The user's role (if set)
};

Handle Timeouts

Functions have execution time limits. Handle long operations gracefully:

exports.handler = async (event) => {
  // Set a timeout shorter than the function limit
  const timeout = setTimeout(() => {
    console.error('Operation taking too long');
  }, 25000);

  try {
    const result = await longRunningOperation();
    return { statusCode: 200, body: JSON.stringify(result) };
  } finally {
    clearTimeout(timeout);
  }
};

Log for Debugging

Use console.log for debugging - logs appear in the Volcano dashboard:

exports.handler = async (event) => {
  console.log('Function invoked with:', JSON.stringify(event));
  console.log('User:', event.__volcano_auth?.user_id);

  try {
    const result = await processData(event);
    console.log('Result:', result);
    return { statusCode: 200, body: JSON.stringify(result) };
  } catch (error) {
    console.error('Error:', error);
    throw error;
  }
};

Next Steps

  • Database - Use the query builder for simple operations
  • Authentication - Understand user context in functions
  • Storage - Process uploaded files in functions

On this page