The stable tag has moved: npm i skapi-js installs 2.0 today. Private records can now be encrypted in the browser, under your user's own password.

npm i skapi-js
In August that command still installed 1.8.3, and we said nothing you were running would change until you asked for it. This is you asking for it. If you are not ready, npm i skapi-js@1.8.3 keeps you exactly where you are.
The release candidate post covered the naming, the polling queues and the session storage work. This one is about what landed on top of them.
Private records can be encrypted in the browser now
new Skapi("<Project ID>", { encryption: true }) encrypts the data of every record you write to access_group: 'private', and the contents of the files attached to it, before any of it leaves the page. The key is derived from the user's own password and never leaves their device.
const skapi = new Skapi("<Project ID>", {
encryption: { iterations: 600000, minPasswordLength: 12 }
});
await skapi.postRecord(
{ diagnosis: 'confidential' },
{ table: { name: 'notes', access_group: 'private' } }
);
The calls do not change. postRecord() seals, getRecords() opens, and there is no key for your end user to manage. Sharing is the call it always was: grantPrivateRecordAccess() wraps that record's data key to the recipient's published public key, so the owner does not have to be online when the grantee reads, and removePrivateRecordAccess() rolls the data key, which makes revocation forward only.
Skapi has always decided who may read a record, on the server, against a signed identity the browser cannot forge. What is new is that the bytes stop being readable by the people running the database. In the words our documentation uses: a provider who dumps the database, the storage bucket and every request log cannot recover that record's data without a successful offline guess against the user's password.
An API response can render while it is still being written
clientSecretRequest() now takes stream: true and onStream(chunk, seq, via). The server reads the destination's response incrementally and relays the raw bytes as they arrive, so your page renders an answer while the destination is still writing it. Add realtime: true and each piece also comes over skapi's websocket the moment it is relayed, instead of on the next poll tick.
skapi.clientSecretRequest({
clientSecretName: 'openai',
url: 'https://api.openai.com/v1/chat/completions',
method: 'POST',
headers: { Authorization: 'Bearer $CLIENT_SECRET' },
data: { model: 'gpt-4.1', stream: true, messages }, // the destination's own flag
stream: true, // skapi's flag: relay the answer as it arrives
realtime: true,
poll: 1000,
onStream: (chunk, seq, via) => readEvents(chunk)
});
Your key still never reaches the browser: $CLIENT_SECRET is filled in on the server as the call goes out. Skapi parses nothing, so the framing and the buffering of a chunk that ended mid frame are yours, which is why this works against any destination on day one rather than only the ones we know about. The websocket is an accelerator and never the source of truth: a dropped socket is not an error, and the read continues at poll speed.
One trap. stream says how skapi reads the response; asking the destination to produce one incrementally is a separate flag in your own request body. Skapi inspects neither, so setting only one of the two goes wrong quietly, with no error from either side.
forwardRequest is the method we promised and never named
The release candidate post said 2.0 had one genuinely new method, then no section ever said which. It is forwardRequest().
skapi.forwardRequest({ question: 'How did last quarter go?' }, {
url: 'https://api.yourbackend.com/chat',
onStream: chunk => output.textContent += chunk
});
It relays a request to your own backend from skapi's servers and streams the response back, with your project's API key attached server side where the browser cannot read it. Your backend also receives x-skapi-user, taken from the verified session, and a request that tries to set an x-skapi- header itself is rejected, so that attribution is safe to trust. The first argument can be a submit event, a form element, a FormData or a plain object, and a form goes as multipart/form-data with its files.
Your backend has to resolve to a public address, so private ranges, loopback and cloud metadata addresses are refused. The destination instructions ride in a header capped at 4096 characters measured after escaping, where each non-ASCII character costs six, so put anything long in the body. And there is no re-attach call for this one: the response is yours once, as it arrives.
Three things to know before you upgrade
Your data is stored as JSON text, and the SDK is the only decoder. The API encodes it on every write regardless of which client wrote it, so an older SDK reading one of these records gets the raw encoded string instead of your object, quietly, with no error. The test is one line: a data you expected as an object that reads back as a string beginning !J% is this. If a project has more than one client on it, a mobile build, a Node worker, a REST caller, upgrade them together.
Two changes ship with the API rather than the SDK, so they are already true for you. Moving a record into or out of 'private' clears every private access grant on it, encryption or not, so grant those users again after the move. And only a record's owner may cross that line: a master account still reads, edits and deletes anything in its project, but it can no longer move somebody else's record between 'private' and a numbered group, because with encryption in play that would destroy the record while reporting success.
Four small changes arrive whether you ask or not. skapi.util.decodeServiceId() is decodeProjectId(), with no alias. table: 'name' on an update no longer moves that record to the public access group, so pass table: { name: 'name', access_group: 0 } if you wanted the move. deleteRecords() returns normalized records, so read record_id and table.name rather than the old short keys. And data: null stores a null instead of removing the field.
Everything else waits to be asked for, including the new require_login project setting, read from getConnectionInfo() as conf.require_login. It makes the SDK refuse record reads from a signed out session: a guard rail against a page leaking your public records by accident, not an access control, since the API still serves access_group: 0 to anyone who asks.
One correction while you have the changelog open. condition: '<=' on a string index value as an "ends with" search is a 1.8.0 change, not a 2.0 one, and the API serves it, so it already works from whatever version you are running today.
The bugs we could not have found on our own
None of the fixes in that data section came from reading our own code. A string containing *add destroying a record is not something a test suite finds: somebody has to be storing a sentence a real person wrote, in a real app, before the storage format ever gets the chance to misread it.
So thank you to everyone who put a project that mattered to them on a release candidate, kept skapi-js@rc installed while it settled, and wrote in with the boring exact detail: the record id, the payload, the call that came back wrong. That is the part of 2.0 we could not have written ourselves.
Encryption is the first place where Skapi holds something it genuinely cannot read, and widening that is where the next stretch goes. data is sealed and index.value is not, because a range read over ordered keys is what keeps a schemaless query cheap. Protecting what you query on without losing the ability to query it is a hard question, and we are not going to answer it badly.
There is one more thing your backend does not have now. A copy of your users' data it can read.
Go build with Skapi, and let your users keep their own keys.