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
- A client writes the file to Ulabase.
- Ulabase calls a function of yours, through a webhook.
- The function reads the file from Ulabase and writes the records.
- 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
filenamefield holds the name of the form field,file. - Send
_idin 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
loadscollection. - A deadline. A webhook waits 5 seconds for the answer, or the
timeout_msyou 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
- Create a free service at ulabase.com.
- Create a collection whose name ends in
.files: that is a file bucket. - Add a webhook on writes to it, pointing at a function of yours.
- 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.