Ulabase gives an application its core APIs, for data, files, users and permissions. It also gives features that are ready to use, such as sign-up, API keys and webhooks.

Every application also has code of its own: a file format to parse, a third-party API to call, a job that runs at night. That code runs on a function platform such as Cloudflare Workers or AWS Lambda. Running functions is their business, and they do it well. Ulabase leaves that job to them by design.

Ulabase connects to your code in three ways:

  • The API. Your code calls Ulabase over HTTP, with a credential of its own.
  • Webhooks. Ulabase calls a URL of yours when a request matches a condition.
  • Change streams. Your code opens a WebSocket and receives the changes of a collection as they happen.

This post presents the official pattern for the first two, with an integration from a real application. The reasons for the design come at the end.

The example

A customer application loads files from two outside systems: bank statements, and the daily sales files of a point-of-sale system. Each file is parsed, compared with what is already stored, and written as records.

The bank sends the statements on a schedule, every morning. A person can also load a file from the app. The loading code runs in a function, and both paths reach it the same way.

The pattern in four steps

  1. A client writes the file to Ulabase.
  2. Ulabase calls a function of yours, through a webhook.
  3. The function reads the file from Ulabase and writes the records.
  4. The function writes the outcome to a collection, where the app reads it.

The rest of this section shows each step.

Step 1: the file goes to a file bucket

A file bucket is a collection that stores files. Its name ends in .files.

The client uploads the file with an ordinary authenticated request. The file's id is the SHA-256 hash of its content, so the same file sent twice is stored once.

curl -X PUT "https://c0ffee.ulabase.app/inbox.files/$(shasum -a 256 statement.txt | cut -d' ' -f1)" \
  -H "Authorization: Bearer <token>" \
  -F "file=@statement.txt" \
  -F 'metadata={"filename":"statement.txt"}'

The app sends this request with the user's session. The scheduler sends it with an API key. A permission on /inbox.files decides who may upload.

Step 2: a webhook calls the function

A webhook is a call the service makes to a URL of yours when a request matches a condition.

This one fires when a file is written to the bucket. It is part of the service's setup, kept in the repository:

{
  id: 'inboxToWorker',
  type: 'http',
  condition: "path-prefix('/inbox.files/') and (method('PUT') or method('PATCH'))",
  url: 'https://app.example.com/inbox/webhook',
  headers: { Authorization: `Bearer ${process.env.WEBHOOK_SECRET}` },
  timeout_ms: 30000,
  transform: { enabled: true, template: '{ "id": "{{{request.path}}}" }' },
}

The Authorization header is a secret the function checks. The template sends one thing: the path of the upload request, which ends with the file's id.

Steps 3 and 4: the function loads the file

Here is the function, a Cloudflare Worker, shortened:

export default {
  async fetch(request, env) {
    // Only the webhook knows the secret.
    if (request.headers.get('Authorization') !== `Bearer ${env.WEBHOOK_SECRET}`) {
      return new Response(null, { status: 401 });
    }

    // The body is { "id": "/inbox.files/<id>" }: keep the id.
    const { id: path } = await request.json();
    const id = /([0-9a-f]{64})$/.exec(path)?.[1];
    if (!id) return new Response(null, { status: 400 });

    // Step 3: read the file, write the records.
    const api = ulabase(env.ULABASE_URL, env.ULABASE_API_KEY);
    const file = await api.binary(`inbox.files/${id}/binary`);
    const outcome = await load(api, id, file);

    // Step 4: record the outcome.
    await api.put(`loads/${id}`, { _id: id, ...outcome });
    return Response.json(outcome);
  },
};

ulabase() is a small wrapper around fetch. load() is the application's own code: it parses the file and writes the records through the same REST API every client uses.

The function calls Ulabase with an API key of its own. The section on API keys below says what that key may do.

The first statement the function loaded was a 77 KB file with 115 bank movements. The function is about 240 lines, and the integration took one morning to build.

Loading a file again

Change the file's metadata. The webhook fires on PATCH too, and the function loads the file again:

curl -X PATCH https://c0ffee.ulabase.app/inbox.files/<id> \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{"reloadedAt":"2026-11-06T10:00:00Z"}'

Each record has an id derived from its content. A second load writes nothing twice.

API keys: one credential for each machine

Two of the callers in this integration are machines: the scheduler and the function. Neither can sign in as a person does. A user's session token lasts 15 minutes and is renewed by the app, and a function that wakes up at any hour has no session to renew.

An API key is the credential for this case. It is a long-lived secret, sent as Authorization: Bearer ulak_…. It belongs to a user of the service and carries only the roles it was issued with.

Each machine gets a user of its own, and a key with a role that covers its job:

Caller Credential What it may do
The app The user's session Upload files, read outcomes
The scheduler API key, role uploader Upload files
The function API key, role loader Read the bucket, write records and outcomes
Ulabase, calling the function Static secret in the webhook's header Call the function

API keys are a feature you turn on for the service. Once it is on, the user issues the key with a request to /keys:

curl -u loader:<password> -X POST https://c0ffee.ulabase.app/keys \
  -H 'Content-Type: application/json' \
  -d '{ "name": "inbox function", "roles": ["loader"], "expiresIn": 365 }'

The answer shows the key one time. The service stores only its hash. The key goes into the function's secrets, and the password is not needed again.

Which roles a key may carry is decided by a permission on /keys, like every other grant:

{ "_id": "loaderIssuesKeys",
  "predicate": "path-prefix('/keys')",
  "roles": ["loader"],
  "priority": 10,
  "apiKeys": { "roles": ["loader"], "max-expires-in-days": 365 } }

Three properties matter for an integration:

  • Each key is revoked on its own. If the scheduler's key leaks, you delete that key. The function and the users keep working.
  • A key expires. You choose the number of days when you issue it.
  • A key records when it was last used. A key that nobody uses any more is easy to find and remove.

Why the security stays simple

The browser never calls the function. It uploads a file to Ulabase, and the permissions of the service decide whether it may.

Only the webhook calls the function. Suppose someone steals the webhook's secret and calls it anyway. They can only make it load a file that is already in the bucket. They cannot send it data of their own.

A function called by the browser would have to check the user's token, find the user's roles, and repeat rules Ulabase already enforces. In this pattern it does none of that work.

So the application has one place where access is decided: the permissions of the service.

Three things to know before you copy this

  • Write the outcome to another collection. A function that writes back to the file's metadata fires the webhook again, and loads the file again, without end.
  • Put the file's name in the metadata. The bucket's own filename field holds the name of the form field, file.
  • Send _id in the body when a schema requires it. A record with the id only in the URL is refused.

What to plan for

  • No synchronous answer. A webhook runs after the response has gone back to the client. The app reads the outcome from the loads collection.
  • A deadline. A webhook waits 5 seconds for the answer, or the timeout_ms you set, up to 30 seconds on Free and 60 on Starter and Pro. A longer job needs a queue, which the function platform provides.
  • Two things to deploy. The service's setup and the function. Both are files in the same repository.

Where the line sits

The table shows the split. The left column is what Ulabase provides.

In Ulabase In your function
Collections, documents, aggregations Parsing a file format
File buckets Calling a third-party API
Users, sign-in, roles, permissions, API keys A long computation
JSON Schema validation A scheduled job
Webhooks and change streams, to tell your code what happened Your business rules

Ulabase leaves the right column to function platforms by design, for two reasons.

A function runtime is a product of its own. Someone has to keep cold starts short, enforce time and memory limits, collect logs, hold secrets, run schedules and queues. Cloudflare and AWS do this as their business.

A runtime built into Ulabase would be one more thing to learn, with its own limits and its own deploy. Code written for it would run only there. The function above talks to Ulabase over HTTP, so it can move to AWS Lambda by changing the lines that receive the request.

Try it

  1. Create a free service at ulabase.com.
  2. Create a collection whose name ends in .files: that is a file bucket.
  3. Add a webhook on writes to it, pointing at a function of yours.
  4. Give the function a user and a role of its own, write its permissions, and issue it an API key.

Ulabase is a MongoDB Technology Partner.

Build your app on Ulabase

Your backend is ready in minutes, and you spend your time on your app. The Free plan needs no credit card.