Folslate API
One HTTP endpoint that hosts a Markdown or HTML file at a public link. Anonymous uploads need no account and no token: the body is the document. Uploads post to api.folslate.com; the returned link resolves on fol.ink for 24 hours — or permanently when posted with an account key, in which case expires is null. Every endpoint below carries a tester: fill the credential box once, then send live calls from this page.
Try-it credentials
Every JSON response has the same four fields
Both Workers answer with success, message, data and error at every status. success is true only on a 2xx. message is human prose and carries no contract — branch on error.code, which is a closed set. data is null on every failure; error is null on every success. Failures that carry extra facts put them beside the code: accepts on a 415, maxBytes on a 413.
The one exception is GET /:id on fol.ink: its success is the stored document bytes rather than JSON, and its failures return this envelope like every other route. A HEAD /:id probe answers with headers only at every status, success or failure — there is no body to parse.
{
"success": true,
"message": "Document stored",
"data": {
"url": "https://fol.ink/doc_01m0t1nhwdsz815jvq92q00fkf",
"expires": "2026-08-25T14:09:26.070Z",
"title": "Hello",
"ownership": "fsk_A1accYsYJrk83UVKwpjlsflMcWkruQbQTC0_cQcEOOQ"
},
"error": null
}{
"success": false,
"message": "Unsupported Content-Type",
"data": null,
"error": {
"accepts": [
"text/markdown",
"text/html"
],
"code": "unsupported_media_type"
}
}Anonymous
GETAnonymousapi.folslate.com/
Service health
Reports that the Worker booted and its configuration is complete. It deliberately reaches no binding, so R2 and D1 are not probed. A degraded service answers 503 with the unbound names in error.missingBindings. It answers access-control-allow-origin: * with no credentials, so a browser page can read it from any origin.
Access
Success
Response data
| status | ok when every required binding is present, degraded otherwise. |
|---|---|
| service | Always folslate-api. |
| version | Always v1. |
| missingBindings | Required bindings that are absent. Non-empty only beside a 503. |
| warnings | Degraded but serving: missing limiters, salts and secrets. |
Errors
curl https://api.folslate.com/{
"success": true,
"message": "Service is healthy",
"data": {
"status": "ok",
"service": "folslate-api",
"version": "v1",
"missingBindings": [],
"warnings": []
},
"error": null
}POSTAnonymousapi.folslate.com/v1/upload
Host a document
Anonymous. No token. The body is the raw file bytes: text/markdown is rendered to HTML and wrapped in a titled page, text/html is sanitized and kept. Use --data-binary, never -d — -d strips newlines and collapses the whole file into one paragraph. The response carries an ownership capability that authorizes updating the document until it expires. With a session cookie or a Bearer fsa_ key the same route stores a permanent owned document instead: no capability is minted and data.expires is null.
Access
Headers
| Content-Type | text/markdown or text/html; it decides the pipeline. Any other value answers 415 with error.accepts listing both. |
|---|---|
| X-Folslate-Title | Optional. Overrides the derived title. A CLI or agent carries UTF-8 in the header; a browser fetch() cannot, so put the title in the document instead. |
| Authorization | Optional. Bearer fsa_… for a permanent owned upload. A malformed, revoked or unknown key — or an fsk_ capability — answers 401 and stores nothing. |
Success
Response data
| url | Public fol.ink link for the document. |
|---|---|
| expires | ISO-8601 instant reads start answering 404, or null for a permanent owned document. |
| title | Derived or overridden title. |
| ownership | Bearer fsk_ capability for updating. Anonymous uploads only, shown once. |
curl -X POST https://api.folslate.com/v1/upload \
-H "Content-Type: text/markdown" \
--data-binary @notes.md# Hello
Write the document here.
{
"success": true,
"message": "Document stored",
"data": {
"url": "https://fol.ink/doc_01m0t1nhwdsz815jvq92q00fkf",
"expires": "2026-08-25T14:09:26.070Z",
"title": "Hello",
"ownership": "fsk_A1accYsYJrk83UVKwpjlsflMcWkruQbQTC0_cQcEOOQ"
},
"error": null
}PUTAnonymousapi.folslate.com/v1/documents/:id
Replace a document
Replaces the document at the same fol.ink link. Authorized by the ownership capability the upload returned — nothing else proves ownership, and the public URL alone grants nothing — or by the owning account's session cookie or Bearer fsa_ key. A non-owner answers 404, the same answer a stranger gets for a revoked document. The body follows the same rules as an upload, including the title pipeline, and an authorized anonymous update restarts the 1-day retention window, which the echoed expiry reflects. An owned document stays permanent and echoes expires: null.
Access
Headers
| Authorization | Bearer <ownership> or Bearer fsa_… — the capability from the upload's 201, or the owning account's key. Sent only to api.folslate.com; the capability was shown once at upload and is never recoverable. |
|---|---|
| Content-Type | text/markdown or text/html, deciding the pipeline again for the new bytes. |
| X-Folslate-Title | Optional. Overrides the derived title of the replacement document. |
Success
Response data
| url | Unchanged public fol.ink link. |
|---|---|
| expires | Restarted 1-day expiry for anonymous documents, null for owned ones. |
| title | Title of the replacement. |
curl -X PUT https://api.folslate.com/v1/documents/doc_01m0t1nhwdsz815jvq92q00fkf \
-H "Authorization: Bearer fsk_…" \
-H "Content-Type: text/markdown" \
--data-binary @notes-revised.md# Hello, revised
Write the replacement here.
{
"success": true,
"message": "Document updated",
"data": {
"url": "https://fol.ink/doc_01m0t1nhwdsz815jvq92q00fkf",
"expires": "2026-08-26T14:09:26.070Z",
"title": "Hello, revised"
},
"error": null
}DELETEAnonymousapi.folslate.com/v1/documents/:id/ownership
Revoke the ownership capability
Invalidates the capability so the document can no longer be updated or re-revoked by anyone, its holder included. Revocation is irreversible: only a one-way hash is stored, so the capability cannot be re-issued. The document itself keeps serving until its expiry — this endpoint never takes bytes down.
Access
Headers
| Authorization | Bearer <ownership> — the capability from the upload's 201. |
|---|
Success
Response data
| id | Document id whose capability died. |
|---|
curl -X DELETE https://api.folslate.com/v1/documents/doc_01m0t1nhwdsz815jvq92q00fkf/ownership \
-H "Authorization: Bearer fsk_…"{
"success": true,
"message": "Ownership capability revoked",
"data": {
"id": "doc_01m0t1nhwdsz815jvq92q00fkf"
},
"error": null
}GETAnonymousapi.folslate.com/v1/report/:id
Describe the abuse channel
Describes the channel and records nothing. Link unfurlers, prefetchers and security scanners all issue GETs, so the read path is deliberately write-free.
Access
Success
Response data
| id | Echoed document id. |
|---|---|
| reportTo | Monitored mailbox for direct reports. |
| howTo | How to file: POST here with ?reason=. |
Errors
curl https://api.folslate.com/v1/report/doc_…{
"success": true,
"message": "Abuse report channel",
"data": {
"id": "doc_…",
"reportTo": "abuse@folslate.com",
"howTo": "POST to this URL with an optional ?reason= to file a report"
},
"error": null
}POSTAnonymousapi.folslate.com/v1/report/:id
File an abuse report
Files a report against a document. The write is awaited and result-bearing: a 202 means the record was kept, and 503 means it was not.
Access
Query parameters
| reason | Optional. Capped at 500 characters. |
|---|
Success
Response data
| id | Reported document id. |
|---|
curl -X POST "https://api.folslate.com/v1/report/doc_…?reason=phishing"{
"success": true,
"message": "Abuse report received",
"data": {
"id": "doc_…"
},
"error": null
}Auth
POSTAuthapi.folslate.com/v1/auth/email-otp/send-verification-otp
Send a sign-in code
Step one of sign-in. Mails a 6-digit code valid for 5 minutes. Unknown addresses receive the same success shape, so the endpoint never reveals which addresses hold accounts. Send type sign-in: type forget-password answers the enveloped 404, and the password-reset and email-change paths below are not sign-in routes at all.
Access
Headers
| Content-Type | application/json carrying the email body. |
|---|
Body fields
| Address to mail the code to. | |
| type | Always "sign-in". Any other value answers 404. |
Success
Response data
| success | Always true on a 200. Check the mail next. |
|---|
curl -X POST https://api.folslate.com/v1/auth/email-otp/send-verification-otp \
-H "Content-Type: application/json" \
--data '{"email":"you@example.com","type":"sign-in"}'{
"email": "you@example.com",
"type": "sign-in"
}{
"success": true
}POSTAuthapi.folslate.com/v1/auth/sign-in/email-otp
Sign in with the code
Step two of sign-in. Exchanges the emailed code for a session: the response sets the session cookie and echoes the token and user as JSON. The first sign-in creates the account. Three wrong attempts invalidate the code.
Access
Headers
| Content-Type | application/json carrying the email and code body. |
|---|
Body fields
| Same address the code went to. | |
| otp | 6-digit code from the mail. |
Success
Response data
| token | Session token, mirrored by the cookie. |
|---|---|
| user | Signed-in user: id, email, plan and timestamps. |
curl -X POST https://api.folslate.com/v1/auth/sign-in/email-otp -c cookies.txt \
-H "Content-Type: application/json" \
--data '{"email":"you@example.com","otp":"123456"}'{
"email": "you@example.com",
"otp": "123456"
}{
"token": "<session-token>",
"user": {
"id": "…",
"email": "you@example.com",
"name": "",
"emailVerified": true,
"plan": "free",
"createdAt": "2026-09-07T16:03:50.000Z",
"updatedAt": "2026-09-07T16:03:50.000Z"
}
}POSTAuthapi.folslate.com/v1/auth/sign-out
Sign out
Ends the session the cookie carries. Afterwards the cookie authenticates nothing: GET /v1/me with it answers 401.
Access
Headers
| Cookie | Session cookie from sign-in. The call ends that session. |
|---|---|
| Content-Type | application/json. Required even though the body says nothing. |
Success
Response data
| success | Always true on a 200. The session is over. |
|---|
curl -X POST https://api.folslate.com/v1/auth/sign-out -b cookies.txt \
-H "Content-Type: application/json" \
--data '{}'{}{
"success": true
}POSTAuthapi.folslate.com/v1/auth/* (blocked paths)
Paths that answer 404 by design
Password-reset and email-change paths exist on the auth mount, but Folslate excludes credential and email-mutation flows. Those paths answer the enveloped 404 from an explicit route in front of the mount — a 404 here means unsupported, not missing. The send-code endpoint with type forget-password answers the same way.
Access
Documented answer
Response data
| error.code | Always not_found on these paths. |
|---|
Errors
curl -X POST https://api.folslate.com/v1/auth/email-otp/request-password-reset{
"success": false,
"message": "The requested resource does not exist or has expired",
"data": null,
"error": {
"code": "not_found"
}
}Account
GETAccountapi.folslate.com/v1/documents
List owned documents
Lists the calling account's documents newest first, with cursor pagination. Deleted rows and other users' rows never appear. Needs a session cookie or a Bearer fsa_ key.
Access
Headers
| Authorization | Bearer fsa_… unless the session cookie authenticates. An fsk_ capability answers 401 here. |
|---|
Query parameters
| cursor | Optional. A document id; the page starts after it. Omit for the first page. |
|---|---|
| limit | Optional. Items per page, default 50, maximum 100. |
Success
Response data
| items[] | One entry per document: id, url, title, contentType, sourceBytes, renderedBytes, createdAt, updatedAt. |
|---|---|
| nextCursor | Document id to pass as cursor for the next page, or null on the last page. |
curl https://api.folslate.com/v1/documents -H 'Authorization: Bearer fsa_…'{
"success": true,
"message": "Documents",
"data": {
"items": [
{
"id": "doc_01m0t1nhwdsz815jvq92q00fkf",
"url": "https://fol.ink/doc_01m0t1nhwdsz815jvq92q00fkf",
"title": "Hello",
"contentType": "text/markdown",
"sourceBytes": 12,
"renderedBytes": 3456,
"createdAt": "2026-08-25T14:09:26.000Z",
"updatedAt": null
}
],
"nextCursor": null
},
"error": null
}GETAccountapi.folslate.com/v1/documents/:id
Inspect an owned document
Returns one owned document's record with its lifetime view count — rolled daily totals plus unrolled raw events. Foreign, deleted and unknown ids answer 404, indistinguishably. Needs a session cookie or a Bearer fsa_ key.
Access
Headers
| Authorization | Bearer fsa_… unless the session cookie authenticates. |
|---|
Success
Response data
| id | Document id. |
|---|---|
| url | Public fol.ink link. |
| title | Derived or overridden title. |
| contentType | text/markdown or text/html. |
| sourceBytes | Uploaded bytes. |
| renderedBytes | Stored bytes. |
| createdAt | ISO-8601 upload instant. |
| updatedAt | ISO-8601 last update, null until the first PUT. |
| views | Lifetime served reads. HEAD probes are excluded. |
curl https://api.folslate.com/v1/documents/doc_… -H 'Authorization: Bearer fsa_…'{
"success": true,
"message": "Document",
"data": {
"id": "doc_01m0t1nhwdsz815jvq92q00fkf",
"url": "https://fol.ink/doc_01m0t1nhwdsz815jvq92q00fkf",
"title": "Hello",
"contentType": "text/markdown",
"sourceBytes": 12,
"renderedBytes": 3456,
"createdAt": "2026-08-25T14:09:26.000Z",
"updatedAt": null,
"views": 9
},
"error": null
}DELETEAccountapi.folslate.com/v1/documents/:id
Delete an owned document
Tombstones the row and removes the bytes, so the link stops resolving. The row is marked first and the R2 object deleted after; an R2 failure after the mark answers 503 with the row already tombstoned. A retry deletes the leftover object and answers 200; a delete once the object is gone answers 404. Needs a session cookie or a Bearer fsa_ key.
Access
Headers
| Authorization | Bearer fsa_… unless the session cookie authenticates. |
|---|
Success
Response data
| id | Deleted document id. |
|---|
curl -X DELETE https://api.folslate.com/v1/documents/doc_… -H 'Authorization: Bearer fsa_…'{
"success": true,
"message": "Document deleted",
"data": {
"id": "doc_01m0t1nhwdsz815jvq92q00fkf"
},
"error": null
}GETAccountapi.folslate.com/v1/usage
Usage against the plan limits
Returns the calling account's plan, its limits, and current usage: uploads and requests since UTC midnight, live document and key counts, and stored bytes. Needs a session cookie or a Bearer fsa_ key.
Access
Headers
| Authorization | Bearer fsa_… unless the session cookie authenticates. |
|---|
Success
Response data
| plan | Calling account's plan. |
|---|---|
| limits | Plan row: activeDocuments, uploadsPerDay, requestsPerDay, storageBytes, activeKeys. |
| usage | uploadsToday and requestsToday since UTC midnight; activeDocuments, storageBytes and activeKeys as live counts. |
| resetsAt | Next UTC midnight, when daily counters restart. |
curl https://api.folslate.com/v1/usage -H 'Authorization: Bearer fsa_…'{
"success": true,
"message": "Usage",
"data": {
"plan": "free",
"limits": {
"activeDocuments": 100,
"uploadsPerDay": 50,
"requestsPerDay": 2000,
"storageBytes": 104857600,
"activeKeys": 10
},
"usage": {
"uploadsToday": 3,
"requestsToday": 41,
"activeDocuments": 7,
"storageBytes": 123456,
"activeKeys": 2
},
"resetsAt": "2026-09-07T00:00:00.000Z"
},
"error": null
}GETAccountapi.folslate.com/v1/me
Who am I
Returns the calling account. Accepts a session cookie or a Bearer fsa_ key, so a key checks itself here without spending a document. The call itself is metered like any authenticated request. A failed session lookup answers 503 while a valid key still succeeds.
Access
Headers
| Authorization | Bearer fsa_… unless the session cookie authenticates. |
|---|
Success
Response data
| id | Account id. |
|---|---|
| Sign-in address. | |
| plan | Plan name, free until pricing lands. |
| createdAt | ISO-8601 account creation. |
curl https://api.folslate.com/v1/me -H 'Authorization: Bearer fsa_…'{
"success": true,
"message": "Current user",
"data": {
"id": "…",
"email": "you@example.com",
"plan": "free",
"createdAt": "2026-09-07T16:03:50.000Z"
},
"error": null
}GETAccountapi.folslate.com/v1/keys
List API keys
Lists the calling account's active keys, newest first. Revoked keys never appear, and the plaintext never does either. Accepts a session cookie or a Bearer fsa_ key.
Access
Headers
| Authorization | Bearer fsa_… unless the session cookie authenticates. |
|---|
Success
Response data
| items[] | One entry per active key: id, name, hint, createdAt, lastUsedAt. Never the plaintext. |
|---|
curl https://api.folslate.com/v1/keys -H 'Authorization: Bearer fsa_…'{
"success": true,
"message": "API keys",
"data": {
"items": [
{
"id": "key_01m0t1nhwdsz815jvq92q00fkf",
"name": "agent",
"hint": "EOOQ",
"createdAt": "2026-09-07T16:14:21.000Z",
"lastUsedAt": null
}
]
},
"error": null
}Session-only
POSTSession-onlyapi.folslate.com/v1/keys
Mint an API key
Mints one named key for the calling account. Session cookie only: a Bearer key answers 403, so a leaked key cannot mint its successors. The body is { name }, 1 to 64 characters with blank names rejected. At 10 active keys the answer is 429 with metric activeKeys. The plaintext is shown once and never stored; only its hash is kept.
Access
Headers
| Cookie | Session cookie from sign-in. A Bearer key is refused here. |
|---|---|
| Content-Type | application/json carrying { name }. |
Body fields
| name | Key name, 1 to 64 characters. Blank names are rejected. |
|---|
Success
Response data
| id | Key id, key_… prefixed. |
|---|---|
| name | Echoed key name. |
| key | Plaintext fsa_ secret. Shown once, never stored. |
| hint | Last four plaintext characters. |
| createdAt | ISO-8601 mint instant. |
curl -X POST https://api.folslate.com/v1/keys -b cookies.txt \
-H "Content-Type: application/json" \
--data '{"name":"agent"}'{
"name": "agent"
}{
"success": true,
"message": "API key created",
"data": {
"id": "key_01m0t1nhwdsz815jvq92q00fkf",
"name": "agent",
"key": "fsa_A1accYsYJrk83UVKwpjlsflMcWkruQbQTC0_cQcEOOQ",
"hint": "EOOQ",
"createdAt": "2026-09-07T16:14:21.000Z"
},
"error": null
}DELETESession-onlyapi.folslate.com/v1/keys/:id
Revoke an API key
Revokes one key now. Unknown, foreign, or malformed ids answer 404. Session cookie only, like minting.
Access
Headers
| Cookie | Session cookie from sign-in. A Bearer key is refused here. |
|---|
Success
Response data
| id | Revoked key id. |
|---|
curl -X DELETE https://api.folslate.com/v1/keys/key_… -b cookies.txt{
"success": true,
"message": "API key revoked",
"data": {
"id": "key_01m0t1nhwdsz815jvq92q00fkf"
},
"error": null
}Reader
GETReaderfol.ink/:id
Read a document
Public and unauthenticated. Returns the stored HTML bytes — the one success that is not JSON. A failed GET answers 404 or 503 with the JSON envelope under content-type application/json; tell failure from document by status and content type, never by the body's first character, which a valid document may start with. A read past the expiry answers 404 even while the bytes are still in the bucket.
Access
Success
Response headers
| content-security-policy | Inert-page policy from §4. No sandbox. |
|---|---|
| content-type | The stored document type. |
| cache-control | Remaining lifetime, not a fixed window. |
curl -fsS https://fol.ink/doc_…HTTP/1.1 200 OK
Content-Security-Policy: default-src 'none'; script-src 'sha256-blo5hYNaQKIRflegUj5xXDxymQmEqDztupWN7IXtzGU='; style-src 'unsafe-inline'; img-src data:; font-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'none'
Content-Type: text/html; charset=utf-8
X-Content-Type-Options: nosniffHEADReaderfol.ink/:id
Probe a document
Same route and headers as the read, with no body. A HEAD probe is not recorded as a view, which makes it the way to check existence or headers without spending an analytics event.
Access
Success
Response headers
| content-security-policy | Inert-page policy from §4. No sandbox. |
|---|---|
| content-type | The stored document type. |
| cache-control | Remaining lifetime, not a fixed window. |
curl -I https://fol.ink/doc_…HTTP/1.1 200 OK
Content-Security-Policy: default-src 'none'; script-src 'sha256-blo5hYNaQKIRflegUj5xXDxymQmEqDztupWN7IXtzGU='; style-src 'unsafe-inline'; img-src data:; font-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'none'
Content-Type: text/html; charset=utf-8
X-Content-Type-Options: nosniffErrors
error.code is drawn from a closed set shared by both Workers. Statuses and prose live in one table, so two handlers cannot answer the same failure differently. internal_error can surface on any route; request_rejected covers rejections that carry no more specific code. Both are also the only codes whose status can move: a thrown HTTPException keeps its own status, so the values below for those two are catalogue defaults, not guarantees.
| error.code | Status | Message | What to do |
|---|---|---|---|
| not_found | 404 | The requested resource does not exist or has expired | On a read: expired, never existed, or a malformed id. These are deliberately indistinguishable, so do not retry. |
| unsupported_media_type | 415 | Unsupported Content-Type | Send text/markdown or text/html. error.accepts lists the accepted types. |
| payload_too_large | 413 | Document exceeds the maximum upload size | The body is over error.maxBytes. Split the document or trim it. |
| unprocessable_document | 422 | The document could not be rendered | Rendering failed. The same bytes fail the same way, so change them before retrying. |
| rate_limited | 429 | Too many requests | Wait the Retry-After seconds, then retry. Counters run per IP, per Cloudflare location. |
| unauthorized | 401 | A valid ownership capability or user credential is required | The Authorization header is missing, malformed, or carries a capability this document does not match. Nothing else authorizes document management. |
| storage_unavailable | 503 | Document storage is temporarily unavailable | Transient. Retry once. |
| report_not_recorded | 503 | The abuse report was not recorded | The report was not kept. Retry, and treat a 503 as authoritative — the endpoint never acknowledges a report it did not store. |
| revocation_not_recorded | 503 | The ownership capability was not revoked | The capability is still live. Retry; treat a 503 as authoritative, exactly like a report 503. |
| degraded | 503 | Service configuration is incomplete | The service is running with required bindings missing. Nothing a client can do; the binding names are in error.missingBindings. |
| configuration_error | 503 | Service configuration is incomplete | A required origin variable is unusable. Not a client error; nothing to retry. |
| request_rejected | 400 (default) | The request was rejected | The request was rejected without a more specific code. Check the URL, method and body. |
| session_required | 403 | Key management needs a session, never a key | Key management needs a session cookie, never a Bearer key. Sign in and retry with the cookie. |
| quota_exceeded | 429 | Quota exceeded | The plan quota in error.metric is spent. For activeKeys, revoke a key; for the daily uploadsPerDay and requestsPerDay quotas, wait for error.resetsAt; for activeDocuments and storageBytes, delete or reduce stored documents — error.resetsAt is null there, so nothing resets on its own. |
| internal_error | 500 (default) | Unexpected server error | Unexpected server error. Retry once; if it persists, only the operator's logs can say why — GET / reports configuration, not runtime health. |
Limits
- Max upload size
- Retention
- Upload rate limit
- Report rate limit
- Auth rate limit