| title | Functions |
|---|---|
| description | 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.
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
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.
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);
}const { data, status, headers, version, error } = await volcano.functions.invoke('health-check');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.
A function's visibility
decides which credentials can invoke it. New functions are private.
| Visibility | Service keys and schedulers | Your project's signed-in users | Anon keys with functions.invoke |
|---|---|---|---|
private (default) |
Yes | No | No |
authenticated |
Yes | Yes | No |
public |
Yes | Yes | Yes |
Signed-in users include anonymous sign-ins. Set the level in
volcano-config.yaml for every function your app calls:
# volcano-config.yaml
version: 1
functions:
- name: get-my-profile
visibility: authenticated
- name: contact-form
visibility: publicDeploying visibility needs Volcano CLI 0.42.0 or later; earlier releases
reject the field. public: false still means authenticated on any version.
A caller the level does not admit gets an error, and the function does not
run. invoke looks the name up first, and the lookup answers a refused caller
exactly as it answers a missing function: error.status is 404 and status
is null, because the invocation was never sent. So a signed-in user can't
tell a private function exists, and neither can an anon key on any function
that isn't public. 403 comes only from requests that skip the lookup: a
direct POST /functions/{id}/invoke with an anon key on an authenticated
function, and
durable.start.
The SDK remembers a failed lookup for about 30 seconds per credential. After you
widen a function's level, a client that was just refused keeps getting 404
until that passes.
When a user is signed in, the SDK sends their access token. The function must be
authenticated or public. It receives the user's context and can:
- Verify the user's identity
- Query the database with Row-Level Security
- 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 dataWhen no user is signed in, the SDK sends the project's anon key. This works only
for public functions, and the anon key needs the functions.invoke
permission. These invocations do not receive user context. Anon keys ship in
browser code, so treat a public function as internet-facing.
const volcano = new VolcanoAuth({ anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY });
const { data } = await volcano.functions.invoke('contact-form', {
email: 'lead@example.com',
});Functions are deployed through the Volcano dashboard or CLI. Here's what they look like:
// functions/hello.js
exports.handler = async (event) => {
const name = event.name || 'World';
return {
statusCode: 200,
body: JSON.stringify({
message: `Hello, ${name}!`,
}),
};
};// 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();
}
};// 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();
}
};// 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 }),
};
};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.
Functions are ideal for:
| Use Case | Example |
|---|---|
| Complex Queries | JOINs, CTEs, window functions |
| Aggregations | Dashboard statistics, reports |
| Stored Procedures | Business logic in PostgreSQL |
| External APIs | Stripe payments, SendGrid emails |
| File Processing | Image resizing, PDF generation |
| Scheduled Tasks | Daily reports, cleanup jobs |
| Admin Operations | Bulk updates, data migrations |
| Secure Operations | Secret key usage, privileged actions |
The browser-based query builder is better for:
- Simple CRUD operations
- Single-table queries with filters
- Real-time updates (less latency)
- Reducing backend code
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);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',
}),
};
}
};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.
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 RLSFor 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 dataThe 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.
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();
}
};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
};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)
};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);
}
};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;
}
};- Database - Use the query builder for simple operations
- Authentication - Understand user context in functions
- Storage - Process uploaded files in functions