API docs
Bearer-token REST. Plain notes only. Available on the
Dev and Team tiers.
For the systemic pitch — every script gets its own notebook, append-by-filename for cron jobs and
AI-session digests, real-world workflows — see
/for/developers.
Base URL: https://freshjots.com/api/v1
Quick reference — clients
Quick API use examples.
Ready-to-copy curl for the common workflows: create, append-only logs, append by filename, list.
Working with folders? Create folders, drop notes into them on create, bulk-upload into a folder.
Folders API →On Windows, or prefer no install? Write to your account from PowerShell with zero install, or the npm / pip client — no WSL needed.
Windows / PowerShell →Authentication
Generate a personal token in /settings/api_tokens. Tokens are shown once on creation and stored as a one-way hash — copy to your password manager or shell profile immediately.
Authorization: Bearer mn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Missing or invalid token returns 401 unauthenticated.
Token holder lacks API access (Free / Personal tier) returns 403 forbidden.
Scopes. Each token is minted with a permission level —
read_write (full access, the default),
read_only (GET / HEAD only), or
write_only (POST / PATCH / PUT / DELETE only).
A token can also be locked to a single note, so it can read or
append to exactly that one stream and nothing else — handy for a per-script credential. A request
that falls outside the token's scope returns
403 forbidden with a message naming the
restriction. Pick the scope when you create the token.
Team tokens
Workspaces on the Team tier mint tokens at /team/api_tokens (owner / admin only). Same bearer-token wire format, same endpoints, same error envelope — but every read and write resolves to the team's notes, folders, and storage pool rather than the actor's personal pool.
GET /noteslists only the team's notes; the actor's personal notes never appear.POST /notescreates withteam_idset; the row'suser_idrecords the actor (who wrote it) for the audit log at /team/audit_events./folders/*,/notes/bulk, and/notes/:id/moveare scope-aware — a team token only sees the team's folders, never personal ones, and refuses cross-pool placement with404 not_found.- Storage, rate limits, and per-tier caps come from the team's subscription, not the actor's. Bulk is enabled on every team token — the team subscription is the entitlement.
A team's token-active cap is 30 active tokens at once. Revoking lives at the same panel — revoked tokens stay listed for audit but stop authenticating immediately.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /notes | List notes (summary). Filter ?format=plain|rich, ?folder_id=N (or none for un-foldered). Sort ?sort=created|updated|appended (default updated). Paginate ?limit=N&offset=N (max 200/page). |
| GET | /notes/:id | Full note (plain_body + byte_size). |
| POST | /notes | Create plain note. Body: {note: {title, plain_body, folder_id?}}. |
| PATCH | /notes/:id | Update title / plain_body / settings. Format is immutable. On append-only notes, content fields are refused but settings (folder_id, append_deadline_hours, alert_email, webhook_url, webhook_secret) are accepted. |
| DELETE | /notes/:id | Delete note. |
| POST | /notes/:id/append | Atomic append to plain_body. Body: {text}. |
| POST | /notes/:id/move | Move to folder. Body: {folder_id} or null. |
| GET | /notes/by-filename/:filename | Find by filename instead of id. |
| PATCH | /notes/by-filename/:filename | Same body shape as PATCH /notes/:id, addressed by stream name. Useful for reconfiguring a script's note (deadline, webhook URL) without first looking up the id. |
| POST | /notes/by-filename/:filename/append | Stream addressing — append by filename. Creates the note if missing. |
| POST | /notes/bulk | Up to 50 creates per call. Body: {notes: [...]}. |
| GET | /folders | List folders. |
| GET | /folders/:id | Show a single folder. |
| POST | /folders | Create folder. Body: {folder: {name}}. |
| PATCH | /folders/:id | Rename folder. |
| DELETE | /folders/:id | Delete folder. Notes inside are preserved (un-foldered). |
Rate limits
- Dev: 600 reads / 60 writes / 300 appends per minute, per token. 3 active tokens. 15 GB storage. Bulk endpoint enabled.
- Team: 2,000 reads / 200 writes / 1,000 appends per minute, per token. 30 active tokens per team. 50 GB workspace storage. Bulk endpoint enabled. 50,000 plain notes / 1,000 rich notes team-wide.
Throttled requests return 429 rate_limited
with Retry-After,
X-RateLimit-Limit,
X-RateLimit-Remaining, and
X-RateLimit-Reset headers.
The full per-tier breakdown — including per-note size caps, browser-surface throttles, pagination ceilings, token-expiry policy, and the export window — is on the Service limits page.
Idempotent retries
Every write endpoint (POST,
PATCH,
PUT,
DELETE) accepts an
Idempotency-Key request header.
A repeated request with the same key + same body fingerprint replays the original response
instead of running the action twice — safe for "did the previous request succeed before my
timeout?" retries from cron jobs and CI scripts.
- Header:
Idempotency-Key: <your-key> - Key format: 8..255 characters. UUIDs work; any string in that range does.
- Replay window: 24 hours. After that, the same key is accepted as a fresh request.
- Replays carry
Idempotent-Replay: trueon the response so your client can tell.
A repeated key with a different body fingerprint returns
409 idempotency_key_conflict — the
server refuses to silently overwrite a previous request's response. Either retry with the
original body or generate a new key.
curl -X POST https://freshjots.com/api/v1/notes \
-H "Authorization: Bearer $FRESH_JOTS_TOKEN" \
-H "Idempotency-Key: cron-2026-05-06-evening-digest" \
-H "Content-Type: application/json" \
-d '{"note":{"title":"Evening digest","plain_body":"...","format":"plain"}}'
Error envelope
All error responses share the same shape:
{ "error": { "code": "validation_failed", "message": "Title can't be blank", "details": ["Title can't be blank"] } }
Stable error codes:
unauthenticated— missing / invalid token (401)forbidden— token lacks API access, is out of scope, or account unconfirmed (403)note_locked— update or delete attempted on an append-only note; only further appends are allowed (403)not_found— record absent or owned by another user (404)validation_failed— bad input or schema violation (422)cap_exceeded— note count over your tier's cap (422)storage_cap_exceeded— total bytes would exceed your storage cap (422)content_too_large— single note over per-format byte cap (413)content_type_mismatch— attempted rich-note write via API (422)rate_limited— throttle window exceeded (429)idempotency_key_conflict— replay key reused with a different body (409). See Idempotent retries above.service_unavailable— backend temporarily unreachable; safe to retry with backoff (503)
Watchdog & webhooks
Two per-note knobs for turning a stream into a monitoring substrate.
Both are configured via PATCH /notes/:id
(or PATCH /notes/by-filename/:filename) and
can also be set from the per-note Settings page in the web UI. Both require the note to be
append-only; the underlying flag is set at create time
({"append_only": true}) or via the
browser-side lock toggle.
Dead-man's-switch alerts
Set append_deadline_hours on an
append-only note (1–720) and Fresh Jots emails you when no append has landed in that
window. The next append clears the alert; if the script recovers and then fails again, you
get a fresh email — no manual reset. Optional
alert_email overrides the
destination (defaults to your account email).
curl -X PATCH https://freshjots.com/api/v1/notes/by-filename/cron-jobs-prod \
-H "Authorization: Bearer $FRESHJOTS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"note":{"append_deadline_hours":2,"alert_email":"oncall@example.com"}}'
Outbound webhooks
Set webhook_url on an append-only
note and every successful append POSTs the new content to your endpoint. By default the
body is a signed JSON envelope — HMAC-SHA256 in the
X-FreshJots-Signature header
(format: sha256=<hex>) using
the webhook_secret you configure;
a blank secret signs with the empty string, and the secret is never read back via the API.
Set webhook_format to
slack or
discord to send a native chat
message instead (see "Payload formats" below).
Ten consecutive non-2xx responses (or transport failures) auto-disables the webhook so a
dead receiver doesn't drain the job queue. Re-arm it by saving the note again — either
with a new URL, or, once auto-disabled, with the URL unchanged (the deliberate save is
the acknowledgement).
webhook_failure_count and
webhook_disabled_at are exposed on
GET /notes/:id for monitoring.
curl -X PATCH https://freshjots.com/api/v1/notes/by-filename/payments-prod \
-H "Authorization: Bearer $FRESHJOTS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"note":{"webhook_url":"https://hooks.example.com/x","webhook_secret":"sk_..."}}'
Payload shape:
{
"event": "note.appended",
"delivered_at": "2026-05-02T12:34:56Z",
"delivery_id": "<uuid>",
"note": {
"id": 123, "filename": "payments-prod", "title": "payments-prod",
"byte_size": 4096, "last_appended_at": "2026-05-02T12:34:55Z"
},
"appended_text": "...new chunk (capped at 8 KB)...",
"appended_bytes": 42,
"appended_truncated": false
}
Payload formats
webhook_format controls the
payload shape and accepts three values:
generic (default — the signed
note.appended envelope shown
above, for your own server or an automation hub like Zapier / Make / n8n);
slack (a native Slack chat
message — paste a
https://hooks.slack.com/services/...
URL and you're done, no adapter to write); and
discord (the same idea for
https://discord.com/api/webhooks/...
URLs). Set it from the Settings page drop-down or from the same PATCH endpoint that
takes webhook_url /
webhook_secret; the current
value rides back on every
GET /notes/:id. An unset
note defaults to generic.
Chat-format messages are clipped to fit each platform: Slack to 3 500 characters,
Discord to 1 900 characters under Discord's hard 2 000-character ceiling on the
content field. The Generic
appended_text preview is
unchanged (capped at 8 KB; the full body is always reachable through
GET /notes/:id).
Slack and Discord deliveries are not signed. Neither
platform's incoming webhooks can verify a signature, so the
X-FreshJots-Signature header
is omitted entirely. For those formats the unguessable hook URL is the credential;
treat it like a password. The signing rules in "Verifying deliveries" below apply to
the Generic format only.
curl -X PATCH https://freshjots.com/api/v1/notes/by-filename/cron-jobs-prod \
-H "Authorization: Bearer $FRESHJOTS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"note":{"webhook_url":"https://hooks.slack.com/services/T.../B.../...","webhook_format":"slack"}}'
Verifying deliveries (Generic only)
Your endpoint is a public URL, so verify every delivery before trusting it.
Set the same
webhook_secret on your
receiving server, recompute the HMAC over the raw, unparsed request body,
and compare it to the
X-FreshJots-Signature
header with a constant-time comparison. Reject on mismatch before parsing JSON.
(If you configured a blank secret, the key is the empty string.)
require "openssl"
require "active_support/security_utils"
SECRET = ENV.fetch("FRESHJOTS_WEBHOOK_SECRET") # the same value you set on the note
def verified?(raw_body, header)
expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", SECRET, raw_body)}"
header.to_s.bytesize == expected.bytesize &&
ActiveSupport::SecurityUtils.secure_compare(header.to_s, expected)
end
# Rails: verify request.raw_post (NOT params) against
# request.headers["X-FreshJots-Signature"], then head(:unauthorized) on false.
Live browser updates
Heads-up:
when you have a note open in the web UI and an API write hits it,
only append-only notes update in place — the browser
subscribes to a per-user stream and replaces the body as new content lands, no refresh
needed. Try it: open an append-only note in one tab, then
curl an append from another window.
Editable (CRUD) notes updated through the API
(PATCH /api/v1/notes/:id,
POST /notes/:id/append on a non-locked
note) are not pushed to an open browser tab — refresh the page
to pick up the new content. This is by design: pushing mid-edit content swaps would clash with
the editor's in-flight autosave.
Questions? Contact me directly.