reMarkable Cloud API
reMarkable Cloud API
This is an unofficial documentation for the cloud based sync service that comes with the reMarkable 1 tablet.
This document is based on an existing documentation 3, the existing implementation in rmapi 4 and observations made while implementing my own client 5.
The service is used to sync content from the tablet to a remote service and make it available on other devices. One can also upload new content to the cloud service and it will be synced to the device.
Disclaimer
This is a hobby project and not associated with the reMarkable company.
The API is a simple ReST oriented web service using JSON as a transfer format. The content of the notes is very close to the file based structure 2 which can be found on the tablet itself.
The API is split into separate parts:
Authentication
Storage
Notifications
Authentication
Users authenticate by logging into the reMarkable website and generating a one-time code. That code can be used to "register" a new device and request a Device Token from the API. That device token can then be used to request a User Token.
The user token is used to authorize requests against the API The user tokens expire after some time (less 24h?). The assumption is to generate a new user token for each session.
Host: https://my.remarkable.com
Endpoint |
URL |
---|---|
Device Token |
|
User Token |
|
One Time Code
There are two URLs for generating the one time code:
A user must be logged in with his or her reMarkable account to generate a one time code.
The code is alphanumeric with 8 digits (e.g: apwngead
).
The code can be used for one request, whether it is successful or not. Users can create as many codes as they want.
Not sure how long a code is valid.
Not sure in which way the
.../desktop
and.../mobile
URLs are different.
Device Token
The device token can be requested by sending a POST
request against the
authentication API:
POST /token/json/2/device/new
with the following JSON body:
{ "code": "apwngead", "deviceDesc": "desktop-windows", "deviceID": "d4605307-a145-48d2-b60a-3be2c46035ef" }
The response is the token as a text/plain
response body.
Unsuccessful requests contain a plain text error message in the response body.
Field |
Description |
---|---|
code |
the one time code |
deviceDesc |
enum, see below |
deviceID |
any UUID, generate one |
Values for deviceDesc are:
desktop-windows
desktop-macos
mobile-android
mobile-ios
browser-chrome
remarkable
Not sure if the device description has any effect on the behavior of the service but invalid values are rejected by the API.
The deviceID
is used in the notifications API,
presumably to allow clients to recognize notifications that were caused
by their own actions.
User Token
To request a user token send an empty POST
request against the
authentication API, using the device token as Authorization: Bearer
header.
POST /token/json/2/user/new
On success, this will return the user token in text/plain
.
Again, the service returns error message as text/plain
.
Discovery
The host for the Storage and Notification API is different from the authentication host. It is determined dynamically by requesting it from a "service manager".
Storage
GET https://service-manager-production-dot-remarkable-production.appspot.com/service/json/1/document-storage?environment=production&group=auth0%7C5a68dc51cb30df1234567890&apiVer=2
The URL parameters are as follows:
Name |
Value |
---|---|
environment |
|
group |
|
apiVer |
|
Note
It seems that the group parameter does not have any effect. Responses are the same with or without that parameter.
The response looks like this:
{ "Status": "OK", "Host": "document-storage-production-dot-remarkable-production.appspot.com" }
The Status
field can contain the text "OK" on success
and an error message on failure.
This request is not authenticated.
Notifications
Discovery for the Notification API works the same as for storage, only the URL is different:
GET https://service-manager-production-dot-remarkable-production.appspot.com/service/json/1/notifications?environment=production&group=auth0%7C5a68dc51cb30df1234567890&apiVer=1
Returns:
{ "Status": "OK", "Host": "08z1-notifications-production.cloud.remarkable.engineering" }
Storage
The content model for the API is a tree structure which consists of "Notebooks" (leafs) and "Folders" (nodes). It resembles the structure of the folders and notebooks found on the tablet.
As with the tablet file system, the API returns a flat list and clients need to create the tree structure by looking at the references to parent id of each element.
Endpoint |
Description |
---|---|
|
List documents |
|
Prepare upload |
|
Update metadata |
|
Delete a document |
The API only works on the metadata. The content (Notebooks, PDF documents, EPUB files) is handled separately by down- or uploading a ZIP archive.
The metadata is represented by the following JSON document:
{ "ID": "0631045c-e3a9-45a0-8446-abcdef012345", "Version": 4, "Message": "", "Success": true, "BlobURLGet": "https://storage.googleapis.com/remarkable-production-document-storage/...[snip]", "BlobURLGetExpires": "2020-12-20T07:07:56.628298857Z", "ModifiedClient": "2020-12-12T22:16:39.539539Z", "Type": "DocumentType", "VissibleName": "My Notebook", "CurrentPage": 0, "Bookmarked": false, "Parent": "ec53580c-3579-4fe7-a096-012345abcdef" }
Note that there is a typo in the VissibleName
field.
Not all of the fields are needed in each request.
Error messages can bee included in a "200 OK" response
if the Success
field is set to false.
Example:
{ "ID": "ec53580c-3579-4fe7-a096-fd1de8011b70", "Version":0, "Message": "Not found or access denied", "Success":false, "BlobURLGet":"", "BlobURLGetExpires":"0001-01-01T00:00:00Z", "ModifiedClient":"0001-01-01T00:00:00Z", "Type":"", "VissibleName":"", "CurrentPage":0, "Bookmarked":false, "Parent":"" }
For status codes other than 200 the error message seems to be sent as plain text.
Example:
Serving request failed, Msg: Authorization failed, invalid token: Could not validate jwt token: Token is expired, Origin: Authorization failed, invalid token: Could not validate jwt token: Token is expired, HTTPCode: 401
List Documents
The request is a GET
on the list endpoint:
GET /document-storage/json/2/docs
It returns an array of metadata entries:
[ { "ID": "ec53580c-3579-4fe7-a096-fd1de8011b70", "Version": 10, "Message": "", "Success": true, "BlobURLGet": "", "BlobURLGetExpires": "0001-01-01T00:00:00Z" ... }, {...} ]
The BlobURLGet
field is always empty when requesting the list.
Other than that, the documents are complete.
Fetch a Single Item
There is no actual endpoint to request a single item. Instead, one fetches the list with a filter on an item's ID:
GET /document-storage/json/2/docs?doc=abcdef012345&withBlob=true
Parameters are:
Name |
Description |
---|---|
doc |
The ID of the requested item (folder or notebook) |
withBlob |
if present, the BlobURLGet field be set |
The blob URL is used to retrieve a ZIP archive with the actual content. The archive contains all files that can be found on the tablet's file system:
<archive> ├── 04387b11-4fe3-41c5-9854-e0a5cbf463d5 │ ├── 2cb61608-9092-4c22-876c-b7ce0970ef4e-metadata.json │ ├── 2cb61608-9092-4c22-876c-b7ce0970ef4e.rm │ └── a370ab54-0e0f-4558-94d1-63c74d641c40.rm ├── 04387b11-4fe3-41c5-9854-e0a5cbf463d5.content ├── 04387b11-4fe3-41c5-9854-e0a5cbf463d5.lock ├── 04387b11-4fe3-41c5-9854-e0a5cbf463d5.metadata ├── 04387b11-4fe3-41c5-9854-e0a5cbf463d5.pagedata └── 04387b11-4fe3-41c5-9854-e0a5cbf463d5.thumbnails ├── 2cb61608-9092-4c22-876c-b7ce0970ef4e.jpg └── a370ab54-0e0f-4558-94d1-63c74d641c40.jpg
The file system layout is described in the unofficial reMarkable wiki 2 in detail.
Authentication details for this call are already part of the URL and no additional authentication is needed.
The service will send error messages in XML format:
<?xml version='1.0' encoding='UTF-8'?> <Error> <Code>AuthenticationRequired</Code> <Message>Authentication required.</Message> </Error>
Update Metadata
To update the metadata for an item, send a PUT
request to the update-status
endpoint:
PUT /document-storage/json/2/upload/update-status
The request must include a JSON document with at least the following fields:
[ { "ID": "0631045c-e3a9-45a0-8446-abcdef012345", "Version": 4, "ModifiedClient": "2020-12-12T22:16:39.539539Z" } ]
Note that we always need to send an array, although we update a single document.
The
Version
must be incremented by one, based on the last version that you have seen.The
ModifiedClient
field should be set to the current time (UTC). Requests will still be processed even if the timestamp is not recent.
Additionally, set the fields you want to change:
Field |
Description |
---|---|
|
Rename an item |
|
Move an item to a different parent folder |
|
Bookmark an item (or remove a bookmark |
When moving a CollectionType item, all its children are also moved. Specify an empty Parent to move an item to the root folder.
Warning
The API does not seem to validate the parent ID. Setting the parent to a non-existing ID will make the document appear in the root folder.
This should return a list of items with the updated status.
On success:
[ { "ID": "25e3a0ce-080a-4389-be2a-f6aa45ce0207", "Version": 48, "Message": "", "Success":true } ]
On error:
[ { "ID": "25e3a0ce-080a-4389-be2a-f6aa45ce0207", "Version": 48, "Message": "Version on server is not -1 of what you supplied: Server: 48, Client req: 48", "Success": false } ]
Upload a Document
Uploading documents like PDF or EPUB or notebooks is a multi-step process:
Create an "Upload Request" (returns
BlobURLPut
).Use the
BlobURLPut
to upload a zip file with the content.Make another request to Update (rather: set) the metadata for the item.
If the upload for the zipped content fails, the item is not created (it cannot be deleted and apparently does not have to be deleted).
The first request is made against the upload request endpoint:
PUT /document-storage/json/2/upload/request
With some basic data as input:
[ { "ID": "923812ec-ae98-40c3-bc59-e7ef065537ff", "Version": 1, "ModifiedClient": "2020-12-20T12:00:34.814814Z" } ]
The response (if successful) contains the upload URL:
[ { "ID": "923812ec-ae98-40c3-bc59-e7ef065537ff", "Version": 1, "Message": "", "Success": true, "BlobURLPut": "https://storage.googleapis.com/remarkable-production-document-storage/...[snip]", "BlobURLPutExpires": "2020-12-20T12:20:48.434022276Z" } ]
Another request is made to upload content:
PUT {{BlobURLPut}}
The content is a Zip archive with the remarkable file format.
And a third request against the update endpoint to set the metadata:
PUT /document-storage/json/2/upload/update-status
[ { "ID": "923812ec-ae98-40c3-bc59-e7ef065537ff", "Version": 1, "ModifiedClient": "2020-12-20T12:00:34.814814Z", "Type": "DocumentType", "VissibleName": "My Document", "Bookmarked": false, "Parent": "f86e2e28-234d-485d-ab34-fe1184571a92" } ]
Note
The Version
field is set to 1, as this is a new item.
The first request which created the upload URL does not create a
"first version".
Delete an Item
To delete an item, send a PUT request with the metadata of the item to be deleted:
PUT /document-storage/json/2/delete
The request body needs the basic metadata of the document:
[ { "ID": "81c58d3d-a550-4079-85c8-b581d7673390", "Version": 1, "ModifiedClient": "2020-12-20T12:00:34.814814Z" } ]
Deleting an item will set its parent
to the special value trash
.
If you delete a folder, only that folder's parent will be changed to trash
.
Any document contained inside the folder remains unchanged.
It will become "invisible" though, as its parent element is no more accessible.
Notifications
The notifications host is obtained via the Discovery URL.
The endpoint is
/notifications/ws/json/1
(found in this reMarkable github repository 6).
The complete URL might look like this:
wss://299t-notifications-production.cloud.remarkable.engineering/notifications/ws/json/1
The connection must be authenticated with a User Token in the Authentication: Bearer header.
Assumption: The notifications are one-way, meaning clients will will only receive and never send messages.
Messages
Messages are JSON objects an look like this:
{ "message": { "attributes": { "auth0UserID": "auth0|123456abcdef130000000000", "bookmarked": "false", "event": "DocAdded", "id": "b083f079-c45e-4b1f-81ea-32c36a672142", "parent": "106ec061-d552-417b-a09c-e207cf87f597", "sourceDeviceDesc": "remarkable", "sourceDeviceID": "RM110-123-12345", "type": "DocumentType", "version": "2", "vissibleName": "My Notebook" }, "messageId": "1804732942123456", "message_id": "1804732942123456", "publishTime": "2020-12-21T10:09:59.016Z", "publish_time": "2020-12-21T10:09:59.016Z" }, "subscription": "projects/remarkable-production/subscriptions/sub-299t-notifications-production" }
The message
object consists of some metadata like messageId
and publishTime
.
The actual message content is contained in the attributes
property:
Name |
Type |
Description |
---|---|---|
auth0UserID |
string |
Seems to be the same as the one used in discovery; not sure what it means or why it is there |
bookmarked |
bool |
true if the item is bookmarked |
event |
string |
One of "DocAdded" or "DocDeleted" |
id |
string |
The UUID of the item |
parent |
string |
The UUID of the parent item |
sourceDeviceDesc |
string |
These seems to be the device description from the registration process |
sourceDeviceID |
string |
could be the ID of the device used during registration |
type |
string |
"DocumentType" or "CollectionType" |
version |
int |
The change version of the item |
vissibleName |
string |
The name of the item, typo included |
Note that all attributes are encoded as strings. One needs to parse the actual data type from that string.
The sourceDeviceID
allows us to filter notifications caused by our own actions.
Events
We will receive notifications on the following events:
New notebook created
Notebook deleted
Notebook changed (bookmarked, renamed or moved)
Folder created
Folder deleted
Folder Changed (bookmarked, renamed or moved)
Creating items and updating items generates the same type of notification:
DocAdded
.
When an item is deleted, we will receive a notification with the
DocDeleted
type.
Note that the UI on the tablet does not actually delete items but moves them
to a "Trash" folder.
In that case the parent ID is set to the special value trash
and we receive a DocAdded
event.