# Build and Ship APIs | Volcano

URL: https://volcano.dev/solutions/apis

> Build APIs on Volcano: each endpoint a deployed function with TLS, verified sessions on the event, endpoints only signed-in users can reach, and Postgres policies deciding what each response contains.

## Endpoints without the scaffolding

Each endpoint is a function with TLS, a verified caller, and a database that already knows what they may read.

```console
$ volcano cloud functions deploy --all
  Deployed list-orders
  Deployed get-order
  Deployed sync-ledger
$ volcano cloud functions list
list-orders   nodejs22   active
  invoke: https://3cd3e058.functions.volcano.run/
# list prints the invocation URL; deploy does not
# sessions are verified before your handler runs
```

## Nobody ships an API. They ship the framework around it

The endpoint itself is usually a dozen lines. Around it goes a router, a middleware chain, token verification, a connection pool, request logging, a TLS termination story and a deployment pipeline — none of which is the thing your customers asked for.

On Volcano the endpoint is the unit. Deploy a function and it has a URL with TLS, receives the caller's verified identity on the event, and reaches a database that applies your policies. What is left to write is the part that is actually yours.

## One function, one endpoint

*Surface*

No router to register in and no shared app object to break. Each endpoint deploys, logs and fails independently, so a bad release affects one route rather than all of them.

- Deploy or roll back one endpoint at a time
- Per-endpoint build and runtime logs
- HTTPS with a certificate you never handle

```console
list-orders   authenticated   nodejs22   2m ago
get-order     authenticated   nodejs22   2m ago
create-order  authenticated   nodejs22   2m ago
sync-ledger   private         nodejs22   2m ago
# sync-ledger answers only service keys; to a browser it is a 404
```

## The caller is verified before you run

*Auth*

Volcano checks the session and puts the user on the event, so your handler starts with a known identity instead of a token to validate. An authenticated endpoint refuses a caller carrying only the anon key before your code runs; a public one lets it in with no identity, which is your cue to return a 401.

- user\_id, email, role and project\_id on the event
- No JWT library or key rotation in your code
- The same identity Postgres sees as auth.uid()

Image: Deployed endpoints with their runtime and visibility.

## The database decides what comes back

*Responses*

Connect as the caller and row-level policies filter the result before your code serialises it. An authorization bug in one endpoint cannot expose rows the policy already excluded.

- No per-endpoint ownership checks to forget
- New endpoints inherit the existing rules
- Service key stays server-side for admin work

```console
$ POST list-orders  { limit: 20 }
ada   → 200  [ 3 orders ]
cleo  → 200  [ 1 order  ]
anon  → 401  sign in required
one query · the policy scoped each response
```

## How it works

1. **A function per route**: Name it after what it does. Leave it private if only your backend calls it, authenticated if signed-in users do, public if the anon key should reach it.
2. **Read the caller**: Take the identity off the event and connect as that user so policies apply.
3. **Validate the payload**: Reject bad input with a 400 before touching the database, and return a useful message.
4. **Deploy and watch it**: Ship the set, then follow runtime logs to see real status codes and latency.

## An endpoint, its rules, its client, its deploy

### Endpoint

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

const MAX_LIMIT = 100;

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

  // A session from an anonymous sign-in has role 'anonymous'. This endpoint
  // wants a real account, so both that and a missing session are rejected.
  if (!auth || auth.role === 'anonymous') {
    return json(401, { error: 'sign in required' });
  }

  const limit = Number(event.limit ?? 20);
  if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
    return json(400, { error: `limit must be an integer between 1 and ${MAX_LIMIT}` });
  }

  // Connect as the caller: this query cannot return anyone else's orders,
  // whatever the rest of this handler does. Omit userId for admin access.
  const db = new Client({
    connectionString: databaseConnectionString(process.env.DATABASE_URL, {
      userId: auth.user_id,
    }),
  });
  await db.connect();

  try {
    const { rows } = await db.query(
      'select id, status, total_cents, placed_at from orders order by placed_at desc limit $1',
      [limit],
    );
    return json(200, { orders: rows });
  } finally {
    await db.end();
  }
};

function json(statusCode, body) {
  return { statusCode, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
}
```

### Rules

```sql
create table if not exists orders (
  id           uuid primary key default gen_random_uuid(),
  customer_id  uuid not null,
  status       text not null default 'pending',
  total_cents  integer not null check (total_cents >= 0),
  placed_at    timestamptz not null default now()
);

alter table orders enable row level security;

-- Every endpoint that connects as the caller inherits this. Adding a new
-- read endpoint later does not mean writing the ownership check again.
drop policy if exists own_orders on orders;
create policy own_orders on orders
  for select to authenticated
  using (customer_id = auth.uid());

drop policy if exists place_own_orders on orders;
create policy place_own_orders on orders
  for insert to authenticated
  with check (customer_id = auth.uid());

-- Staff read everything, through a role rather than a second key.
drop policy if exists staff_read on orders;
create policy staff_read on orders
  for select to authenticated
  using (exists (
    select 1 from staff s where s.user_id = auth.uid() and s.role in ('support', 'admin')
  ));
```

### Calling it

```typescript
import { VolcanoAuth } from '@volcano.dev/sdk';

const volcano = new VolcanoAuth({
  apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,
  anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,
});

export async function listOrders(limit = 20) {
  // The payload is passed straight through, and the current session travels
  // with the call — nothing to attach by hand.
  const { data, error } = await volcano.functions.invoke('list-orders', { limit });

  if (error) {
    // Errors are returned, not thrown.
    return { orders: [], error: error.message };
  }

  return { orders: data.orders, error: null };
}

// Or call it directly, from anything that speaks HTTP. Over the wire the
// payload is wrapped; the SDK is doing that for you above. INVOKE_URL is the
// invoke: line from `volcano cloud functions list` — deploy does not print it,
// and it is not on the API's domain, so it cannot be assembled from the id.
//
//   curl -X POST "$INVOKE_URL" \
//     -H "Authorization: Bearer $TOKEN" \
//     -H "Content-Type: application/json" \
//     -d '{"payload": {"limit": 20}}'
```

### Deploy

```bash
volcano use my-api

volcano cloud databases migration up --all -d app
volcano cloud functions deploy --all

# Functions start private, which suits sync-ledger: only service keys and
# schedulers reach it. Let signed-in users call the browser-facing ones.
volcano cloud functions update list-orders --visibility authenticated
volcano cloud functions update get-order --visibility authenticated
volcano cloud functions update create-order --visibility authenticated

volcano cloud variables deploy

# Ship a fix to one endpoint without touching the others
volcano cloud functions deploy -f list-orders

volcano cloud functions logs list-orders --type runtime --follow
```

## What you do not build

- **0 lines of token verification**: The caller arrives on the event already verified, with their role attached.
- **1 policy for every endpoint**: Authorization is written next to the data instead of repeated per route.
- **Per route deploys and logs**: One endpoint can be fixed and shipped without redeploying the rest.

## Outcomes

- **Smaller handlers**: Validation and business logic, with the plumbing removed.
- **Consistent authorization**: New endpoints inherit rules instead of restating them.
- **Isolated failures**: A bad deploy affects one route, not the whole surface.

## Built on Volcano

- [The endpoints](https://volcano.dev/features/edge-functions): Functions in Node, Python or Ruby with HTTPS, public or private, deployed independently.
- [Callers](https://volcano.dev/features/authentication): Verified sessions on the event, with email, password and OAuth sign-in behind them.
- [Scoping in the database](https://volcano.dev/features/postgres): Row-level policies the database applies itself, so scoping is not per-endpoint code.
- [Work behind a 202](https://volcano.dev/features/durable-functions): An endpoint starts a durable function for the slow part and answers at once, idempotently, keyed on the request.

## Frequently asked questions

### How are endpoints addressed?

Each deployed function gets its own HTTPS hostname, with DNS answering from the closest region. Call it with the SDK, which attaches the session for you, or POST to it from anything that speaks HTTP. In the default mode the body is {"payload": {…}} and a bearer token is required. Switch a function to HTTP mode to take GET, PUT and the rest on nested paths, and on a public function, http\_auth\_mode: none lets a third-party webhook in while your handler verifies its signature.

### Do I have to verify tokens myself?

No. Volcano validates the session first and attaches the user to the event as \_\_volcano\_auth. An authenticated endpoint refuses a caller carrying only the anon key; a public one lets it in without that object, so checking whether it is there replaces the token handling you would otherwise write.

### What do the visibility levels do?

They decide which callers an endpoint runs for. Private, the default, admits only service keys and schedulers; to anyone else the endpoint does not exist and answers 404, which suits reconciliation and admin routes. Authenticated also admits your project's signed-in users, including anonymous sign-ins, and refuses a bare anon key with a 403. Public also admits anon keys holding functions.invoke. Once a caller is in, check their role or rely on a policy for what they may do.

### Is there built-in rate limiting?

Not per caller. For per-caller limits, keep a counter table in Postgres and return a 429 from the endpoint.

### How long can a request take?

Up to 30 seconds per invocation on Hobby or 180 seconds on Superagent. Work that runs longer should start a durable function and return 202 straight away. The function writes its result somewhere the caller can read, such as a row their policies allow, rather than holding the request open.

### Can I put my own domain on an endpoint?

Yes, through a frontend. Mount an HTTP-mode function on a frontend path prefix such as /api, and it answers on that frontend's hostname, including its custom domain.

## Get started

Ready to ship your API? Create a free account at https://volcano.dev/signup, or have your coding agent follow the setup playbook at https://volcano.dev/setup.md.
