/projectGet the key's project
Scope: Any key
Name, plan and limits, usage, public URLs and the block types with their sizes.
Response
200Project
curl "https://api.gravi.page/v1/project" \
-H "Authorization: Bearer $GRAVI_API_KEY"Gravi API · v1
A JSON API for everything the editor does: create pages, add and arrange blocks, read form responses and analytics. Keys belong to a project on the Pro plan.
curl "https://api.gravi.page/v1/project" \
-H "Authorization: Bearer $GRAVI_API_KEY"curl -X POST "https://api.gravi.page/v1/pages/$PAGE_ID/blocks" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"text","markdown":"Hello from the **API**"}'The reference below lists every endpoint with its parameters and responses.
Send the key in the Authorization header: Authorization: Bearer gravi_…. A key acts as the owner who created it and only reaches its own project. Keys never belong in a browser or a public repository; the API does not allow cross-origin calls.
A key stops working when it is revoked or expires, when its creator is no longer an owner, or when the project leaves the Pro plan (403 PLAN_REQUIRED; the key is kept).
Each key has scopes; a call without the right one gets 403 FORBIDDEN.
content:readRead the project, pages and blocks.content:writeCreate and change pages, blocks, header, theme, files and block passwords.responses:readRead forms and their responses.responses:writeChange and delete responses. Responses contain personal data, so this scope is off in the presets.analytics:readRead page analytics.Per key: 120 requests a minute (bursts up to 30), of which 30 may change data. Over the limit the API answers 429 with a Retry-After header in seconds.
JSON bodies up to 256 KB; images up to your plan's upload size.
Errors share one envelope. Branch on code, not on the message:
{
"error": {
"code": "VALIDATION",
"message": "The request is not valid.",
"details": { "field": "slug" }
}
}| Status | Codes |
|---|---|
| 401 | UNAUTHENTICATEDACCOUNT_BLOCKEDACCOUNT_DELETED |
| 403 | FORBIDDENPLAN_REQUIREDLIMIT_REACHED |
| 404 | NOT_FOUND |
| 409 | CONFLICTSLUG_TAKENSLUG_RESERVED |
| 422 | SLUG_INVALIDVALIDATION |
| 429 | RATE_LIMITED |
| 502 | FETCH_FAILED |
Lists return { data, nextCursor }. Pass limit (1–100, default 50) and the nextCursor of the previous page as cursor until it is null.
Generated from the OpenAPI document.
/projectScope: Any key
Name, plan and limits, usage, public URLs and the block types with their sizes.
200Project
curl "https://api.gravi.page/v1/project" \
-H "Authorization: Bearer $GRAVI_API_KEY" /pagesScope: content:read
limitinteger1–100 · default 50cursorstring≤ 200 chars200PageList
curl "https://api.gravi.page/v1/pages" \
-H "Authorization: Bearer $GRAVI_API_KEY" /pagesScope: content:write
Creates a published page from an optional template.
slugstringrequired1–200 charstemplate"blank" | "creator" | "developer" | "business"titlestring≤ 80 chars201Page
curl -X POST "https://api.gravi.page/v1/pages" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"slug":"jane","title":"Jane Doe"}' /pages /{pageId}Scope: content:read
pageIdstringrequiredPage id (from GET /v1/pages).
200Page
curl "https://api.gravi.page/v1/pages/{pageId}" \
-H "Authorization: Bearer $GRAVI_API_KEY" /pages /{pageId}Scope: content:write
pageIdstringrequiredPage id (from GET /v1/pages).
slugstring1–200 charsstatus"published" | "unpublished"seoobjecttitlestring or null≤ 70 charsdescriptionstring or null≤ 200 charsnoindexbooleanlocale"en" | "ru"200Page
curl -X PATCH "https://api.gravi.page/v1/pages/{pageId}" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status":"published","seo":{"title":"Jane Doe — designer"}}' /pages /{pageId}Scope: content:write
Soft delete: the page can be restored from the dashboard for 30 days.
pageIdstringrequiredPage id (from GET /v1/pages).
204No content
curl -X DELETE "https://api.gravi.page/v1/pages/{pageId}" \
-H "Authorization: Bearer $GRAVI_API_KEY" /pages /{pageId} /headerScope: content:write
bio takes Markdown; avatarFileId: null removes the avatar.
pageIdstringrequiredPage id (from GET /v1/pages).
titlestring≤ 80 charsbiostring≤ 40000 charsbioJsonanyavatarFileIdstring or null1–64 chars200Page
curl -X PATCH "https://api.gravi.page/v1/pages/{pageId}/header" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"Jane Doe","bio":"Designer in **Lisbon**. Say hi at [jane.dev](https://jane.dev)."}' /pages /{pageId} /themeScope: content:write
pageIdstringrequiredPage id (from GET /v1/pages).
presetstringrequired1–64 charsoverridesmap of any200Page
curl -X PUT "https://api.gravi.page/v1/pages/{pageId}/theme" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"preset":"paper"}' /pages /{pageId} /blocksScope: content:read
In desktop reading order.
pageIdstringrequiredPage id (from GET /v1/pages).
limitinteger1–100 · default 50cursorstring≤ 200 chars200BlockList
curl "https://api.gravi.page/v1/pages/{pageId}/blocks" \
-H "Authorization: Bearer $GRAVI_API_KEY" /pages /{pageId} /blocksScope: content:write
Shorthands: url (link, video), markdown (text, article), imageUrl / fileId (image). Without position the block takes the first free spot. An existing id returns the stored block with 200 (idempotent retries).
pageIdstringrequiredPage id (from GET /v1/pages).
idstringtypestringrequired1–32 charsparamsmap of anyurlstring1–2048 charsmarkdownstring≤ 40000 charsimageUrlstring1–2048 charsfileIdstring1–64 charssize"icon" | "compact" | "small" | "banner" | "wide" | "large" | "auto" | "full" | "full_auto" | objectposition"end" | "start" | "auto" | object201Block
curl -X POST "https://api.gravi.page/v1/pages/{pageId}/blocks" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"link","url":"https://github.com/jane","size":"banner"}' /pages /{pageId} /blocks /orderScope: content:write
Each list names every block once; blocks re-flow in that order, keeping sizes.
pageIdstringrequiredPage id (from GET /v1/pages).
desktoparray of string≤ 1000 itemsmobilearray of string≤ 1000 itemscurl -X PUT "https://api.gravi.page/v1/pages/{pageId}/blocks/order" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"desktop":["intro","github","photo"]}' /pages /{pageId} /blocks /{blockId}Scope: content:write
params is partial (null removes a key); markdown replaces text content.
pageIdstringrequiredPage id (from GET /v1/pages).
blockIdstringrequiredBlock id (id of a block).
paramsmap of anymarkdownstring≤ 40000 charssize"icon" | "compact" | "small" | "banner" | "wide" | "large" | "auto" | "full" | "full_auto" | object200Block
curl -X PATCH "https://api.gravi.page/v1/pages/{pageId}/blocks/{blockId}" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"markdown":"Hello **world**","size":"wide"}' /pages /{pageId} /blocks /{blockId}Scope: content:write
pageIdstringrequiredPage id (from GET /v1/pages).
blockIdstringrequiredBlock id (id of a block).
204No content
curl -X DELETE "https://api.gravi.page/v1/pages/{pageId}/blocks/{blockId}" \
-H "Authorization: Bearer $GRAVI_API_KEY" /pages /{pageId} /blocks /{blockId} /lockScope: content:write
A new password (+ hint), or lockId of another locked block on the page to share its password. Visitors see a placeholder until they unlock it; the content never reaches them.
pageIdstringrequiredPage id (from GET /v1/pages).
blockIdstringrequiredBlock id (id of a block).
One of:
passwordstringrequired4–128 charshintstring≤ 80 charslockIdstringrequired1–64 chars200Block
curl -X PUT "https://api.gravi.page/v1/pages/{pageId}/blocks/{blockId}/lock" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"password":"open-sesame","hint":"The magic words"}' /pages /{pageId} /blocks /{blockId} /lockScope: content:write
pageIdstringrequiredPage id (from GET /v1/pages).
blockIdstringrequiredBlock id (id of a block).
204No content
curl -X DELETE "https://api.gravi.page/v1/pages/{pageId}/blocks/{blockId}/lock" \
-H "Authorization: Bearer $GRAVI_API_KEY" /filesScope: content:write
multipart/form-data with a file field (up to the plan's upload size), or JSON { url } to import an image. Unused files are removed after 24 hours.
Also accepts multipart/form-data with a file field.
urlstringrequired1–2048 chars201File
curl -X POST "https://api.gravi.page/v1/files" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/photo.jpg"}' /formsScope: responses:read
Newest response first. Forms whose block was deleted keep their responses.
limitinteger1–100 · default 50cursorstring≤ 200 chars200FormList
curl "https://api.gravi.page/v1/forms" \
-H "Authorization: Bearer $GRAVI_API_KEY" /forms /{formId} /responsesScope: responses:read
Newest first (with q: by relevance). Without status, spam is left out. from / to bound the submission time (to exclusive).
formIdstringrequiredlimitinteger1–100 · default 50cursorstring≤ 2000 charsstatus"new" | "in_progress" | "done" | "spam"fromstring (date-time)tostring (date-time)qstring≤ 200 charscurl "https://api.gravi.page/v1/forms/{formId}/responses" \
-H "Authorization: Bearer $GRAVI_API_KEY" /responses /{responseId}Scope: responses:write
Status, read flag and the internal note (an empty note removes it).
responseIdstringrequiredstatus"new" | "in_progress" | "done" | "spam"notestring≤ 2000 charsreadboolean200FormResponse
curl -X PATCH "https://api.gravi.page/v1/responses/{responseId}" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status":"done","read":true,"note":"Replied by email"}' /responses /{responseId}Scope: responses:write
responseIdstringrequired204No content
curl -X DELETE "https://api.gravi.page/v1/responses/{responseId}" \
-H "Authorization: Bearer $GRAVI_API_KEY" /forms /{formId} /responses /bulkScope: responses:write
Up to 100 ids, or every response matching filter (the first 200 now, the rest in the background: pending: true).
formIdstringrequiredidsarray of string1–100 itemsfilterobjectstatus"new" | "in_progress" | "done" | "spam"fromstring (date-time)tostring (date-time)qstring≤ 200 charsaction"setStatus" | "markRead" | "markUnread" | "delete"requiredstatus"new" | "in_progress" | "done" | "spam"200BulkResult
curl -X POST "https://api.gravi.page/v1/forms/{formId}/responses/bulk" \
-H "Authorization: Bearer $GRAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filter":{"status":"new"},"action":"markRead"}' /pages /{pageId} /analyticsScope: analytics:read
Totals, a daily series and the most clicked blocks and links for UTC days from–to (default: the last 30 days). Days before the plan's history are clamped (clamped: true).
pageIdstringrequiredPage id (from GET /v1/pages).
fromstring (date)tostring (date)200Analytics
curl "https://api.gravi.page/v1/pages/{pageId}/analytics" \
-H "Authorization: Bearer $GRAVI_API_KEY" /openapi.jsonScope: No key
200application/json
curl "https://api.gravi.page/v1/openapi.json"Every error uses this envelope.
errorobjectrequiredcodestringrequiredStable code to branch on, e.g. VALIDATION
messagestringrequireddetailsmap of anyidstringrequirednamestringrequiredplanstringrequiredscopesarray of stringrequiredScopes of the calling key
limitsmap of number | booleanrequiredusageobjectrequiredpagesintegerrequired≥ 0publishedPagesintegerrequired≥ 0storageBytesintegerrequired≥ 0urlsobjectrequireddashboardstringrequiredpagesarray of stringrequiredblockTypesarray of objectrequiredtypestringrequiredsizesarray of stringrequireddefaultSizestringrequiredA size key such as compact or wide (see blockTypes)
entitlementstringidstringrequiredslugstringrequiredurlstringrequiredstatus"published" | "unpublished"requiredunpublishedReasonstring or nullrequiredtitlestringrequiredlocale"en" | "ru"requiredblockCountintegerrequired≥ 0versionintegerrequired≥ 0createdAtstring (date-time) or nullrequiredupdatedAtstring (date-time) or nullrequiredpublishedAtstring (date-time) or nullrequirednextCursorstring or nullrequiredidstringrequiredslugstringrequiredurlstringrequiredstatus"published" | "unpublished"requiredunpublishedReasonstring or nullrequiredtitlestringrequiredlocale"en" | "ru"requiredblockCountintegerrequired≥ 0versionintegerrequired≥ 0createdAtstring (date-time) or nullrequiredupdatedAtstring (date-time) or nullrequiredpublishedAtstring (date-time) or nullrequiredseoobjectrequiredtitlestring or nullrequireddescriptionstring or nullrequirednoindexbooleanrequiredheaderobjectrequiredtitlestringrequiredbioanyrequiredRich text JSON (bio profile) or null
bioMarkdownstringrequiredavatarobject or nullrequiredfileIdstringrequiredurlstringrequiredwidthnumberheightnumberthememap of anyrequiredblocksarray of Blockidstringrequiredtypestringrequiredparamsmap of anyrequiredStored block parameters
markdownstringtext / article: the content as Markdown
sizeobjectrequireddesktopstring or nullrequiredmobilestring or nullrequiredlayoutobjectrequireddesktopobjectrequiredxintegerrequired≥ 0yintegerrequired≥ 0wintegerrequired≥ 0hintegerrequired≥ 0mobileobjectrequiredxintegerrequired≥ 0yintegerrequired≥ 0wintegerrequired≥ 0hintegerrequired≥ 0lockedbooleanrequiredlockIdstring or nullrequiredThe block's password lock, if any
filesmap of objectrequiredSigned URLs of the files it uses
updatedAtstring (date-time) or nullrequiredmetadata"pending"POST: link metadata is being fetched in the background
nextCursorstring or nullrequiredfileIdstringrequiredurlstringrequiredidstringrequiredtitlestring or nullrequiredpageIdstringrequiredpageSlugstringrequiredblockIdstringrequiredacceptingbooleanrequiredresponseCountintegerrequired≥ 0Every status but spam
unreadCountintegerrequired≥ 0statusCountsobjectrequirednewintegerrequired≥ 0in_progressintegerrequired≥ 0doneintegerrequired≥ 0spamintegerrequired≥ 0columnsarray of objectrequiredfieldIdstringrequiredlabelstringrequiredtype"short_text" | "long_text" | "email" | "phone" | "number" | "url" | "choice" | "date" | "time" | "rating" | "consent"requiredremovedbooleanrequiredlastResponseAtstring (date-time) or nullrequiredblockRemovedAtstring (date-time) or nullrequiredSet once the form block was deleted
createdAtstring (date-time)requiredISO 8601, UTC
nextCursorstring or nullrequiredidstringrequiredformIdstringrequiredstatus"new" | "in_progress" | "done" | "spam"requiredreadbooleanrequirednotestring or nullrequiredanswersarray of objectrequiredfieldIdstringrequiredlabelstringrequiredtype"short_text" | "long_text" | "email" | "phone" | "number" | "url" | "choice" | "date" | "time" | "rating" | "consent"requiredvaluestring | array of string | number | booleanrequiredmetaobjectrequiredreferrerHoststringutmmap of stringdevice"mobile" | "tablet" | "desktop"requiredlocale"en" | "ru"requiredcreatedAtstring (date-time)requiredISO 8601, UTC
updatedAtstring (date-time)requiredISO 8601, UTC
nextCursorstring or nullrequiredaffectedintegerrequired≥ 0Responses changed or deleted by this call
pendingbooleanrequiredA filter's remaining matches continue in the background
pageIdstringrequiredfromstring (date)requiredFirst UTC day, after clamping
tostring (date)requiredLast UTC day (inclusive)
clampedbooleanrequiredfrom was moved to the plan's first visible day
analyticsDaysintegerrequired≥ 0Days of history the plan shows
totalsobjectrequiredviewsintegerrequired≥ 0uniquesintegerrequired≥ 0clicksintegerrequired≥ 0dailyarray of objectrequireddatestring (date)requiredviewsintegerrequired≥ 0uniquesintegerrequired≥ 0clicksintegerrequired≥ 0topBlocksarray of objectrequiredblockIdstringrequiredtypestring or nullrequiredlabelstringrequiredclicksintegerrequired≥ 0headerClicksintegerrequired≥ 0linksarray of objectrequiredClicked URLs (top 20, the rest in other)
keystringrequiredcountintegerrequired≥ 0referrersarray of objectrequiredkeystringrequiredcountintegerrequired≥ 0countriesarray of objectrequiredkeystringrequiredcountintegerrequired≥ 0devicesarray of objectrequiredkeystringrequiredcountintegerrequired≥ 0