Wisegrid API
A clean, predictable REST API for your sheets, columns and rows. Scoped keys, idempotent writes, partial-success bulk operations, and 600 requests/min/key with no 10× attachment tax — it stays the same fast call on a 1M-cell sheet.
Getting Started
From zero to your first row in five minutes.
The Wisegrid API is a versioned REST API (v1) over your
spreadsheet data: list the sheets a key can read, read columns and rows,
and write rows — single or in bulk. Every response is JSON with a stable
requestId, every error uses one envelope, and every write can
be safely retried.
The one-liner: same Smartsheet-style API surface, but with self-documenting string error codes, per-object scoped keys, true partial-success bulk writes, first-class idempotency, and 2× the rate budget — on sheets twice as large.
1. Mint an API key
Sign in to Wisegrid, then go to Settings → API Keys and
create a key. Choose a name and the scopes it should carry (which sheets or
projects, read or write — see Authentication &
Scopes). The secret looks like wg_live_… and is
shown exactly once — copy it into your secret manager
immediately.
2. Authenticate
Send the key as a Bearer token on the Authorization header.
The base URL is https://wisegrid.co/api/v1.
Authorization: Bearer wg_live_xxx
3. Your first call — confirm the key works
GET /me echoes the key's id, its owner, its scopes, and your
current rate-limit window. It's the fastest way to confirm a key is live.
curl https://wisegrid.co/api/v1/me \ -H "Authorization: Bearer wg_live_xxx"
import { WisegridClient } from '@wisegrid/api';
const wg = new WisegridClient({ apiKey: 'wg_live_xxx' });
const { data } = await wg.getMe();
console.log(data.keyId, data.scopes, data.rateLimit);from wisegrid import WisegridClient wg = WisegridClient(api_key="wg_live_xxx") me = wg.get_me() print(me.key_id, me.scopes, me.rate_limit)
A successful response:
{
"keyId": 42,
"ownerId": 7,
"scopes": [
{ "verb": "write", "scopeType": "sheet", "scopeId": 128 }
],
"rateLimit": { "limit": 600, "remaining": 599, "reset": 1717536000 },
"requestId": "req_3kF9aZ…"
}
4. List your sheets, then add a row
Pick a sheet id from GET /sheets, then add a row with
:bulkAdd. Cells are keyed by column id (as a string). Pass an
Idempotency-Key so a network retry never double-inserts.
# List the sheets this key can read
curl https://wisegrid.co/api/v1/sheets \
-H "Authorization: Bearer wg_live_xxx"
# Add a row to sheet 128 (cells keyed by column id)
curl -X POST "https://wisegrid.co/api/v1/sheets/128/rows:bulkAdd" \
-H "Authorization: Bearer wg_live_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 1f7c2b9a-uuid" \
-d '{ "rows": [ { "cells": { "88": "Doing", "90": "2026-06-30" } } ] }'import { WisegridClient } from '@wisegrid/api';
const wg = new WisegridClient({ apiKey: 'wg_live_xxx' });
const { data: sheets } = await wg.listSheets();
const sheetId = sheets!.data[0].id;
await wg.bulkAddRows({
path: { sheet_id: sheetId },
headers: { 'Idempotency-Key': crypto.randomUUID() },
body: { rows: [{ cells: { '88': 'Doing' } }] },
});from wisegrid import WisegridClient, BulkAddRequestV1
from wisegrid_public_api_client.models import RowAddItemV1
from wisegrid_public_api_client.models.row_add_item_v1_cells import RowAddItemV1Cells
wg = WisegridClient(api_key="wg_live_xxx")
sheets = wg.list_sheets(limit=50)
sheet_id = sheets.data[0].id
wg.bulk_add_rows(
sheet_id=sheet_id,
body=BulkAddRequestV1(rows=[
RowAddItemV1(cells=RowAddItemV1Cells.from_dict({"88": "Doing"}))
]),
idempotency_key="<a-uuid>",
)5. Install an SDK (optional)
Official, fully-typed SDKs — generated from the same OpenAPI contract this
page renders — handle auth headers, retries on 429, and
pagination for you.
# No SDK — just curl + the Bearer header, as shown above.
npm install @wisegrid/api
pip install wisegrid
Jump to the TypeScript or Python quickstart, or keep reading the core concepts below.
Authentication & Scopes
Per-object scoped keys that can never exceed their owner.
API keys
Every request authenticates with an API key sent as a Bearer token:
Authorization: Bearer wg_live_xxx. A missing, malformed,
revoked, or expired key returns 401
unauthorized — the response never reveals
whether the token shape was recognized.
- Format. Live keys are prefixed
wg_live_followed by high-entropy random characters. The full secret is shown only at creation; afterwards only the shortprefix(e.g.wg_live_3kF9) is visible in Settings so you can tell keys apart. - Storage. Wisegrid stores only a SHA-256 hash of the key — the raw secret is never persisted and can't be re-fetched.
- Expiry. Keys expire (default one year, or a date you
set at creation). An expired key returns
401.
Per-object scopes
A key isn't all-or-nothing. Each key carries one or more grants, and a grant is a verb on a scope object:
| Field | Values | Meaning |
|---|---|---|
verb | read · write | read grants viewer-level access; write grants editor-level (which includes read). |
scopeType | project · folder · sheet | The kind of object the grant covers. |
scopeId | integer | The id of that object. |
Grants flow down the hierarchy: a write grant
on a project covers every folder and sheet inside it; a grant on a
folder covers the sheets in it. So you can mint one narrowly-scoped
key for a single sheet, or one broad key for a whole project.
Scopes intersect with the owner's live access
A key can never exceed its owner's current access. On every request, the effective permission on a target is:
# the security boundary, evaluated live on every call
effective_role = min( owner_live_role(target), key_ceiling(verb) )
- Live, not snapshotted. If the owner loses access to a sheet, every key they minted instantly loses it too — there's no cached grant to go stale.
- Mint-time guard. You can only grant a scope you
currently have at least that level of access to. Trying to over-grant
returns
422validation_failed identifying the offendingscopeId. - Live revocation. Revoking a key takes effect on the
very next request — the key returns
401immediately.
404 not_found
(so the API never confirms an object exists outside your scope). A target
you can read but a write you're not scoped for returns 403
forbidden.
Storing & rotating the secret
- Treat
wg_live_…like a password: keep it in a secret manager / server-side env var, never in client-side code or a public repo. - Rotate by minting a new key, deploying it, then revoking the old one — zero-downtime. Revocation is immediate and irreversible.
- Use several narrow keys (one per integration) rather than one broad key, so revoking one never breaks the others.
Rate Limits
600 requests per minute, per key — 2× the typical budget, with no attachment tax.
Each API key gets 600 requests per minute. There is no extra throttle on attachment or cell-history operations — the exact calls a migration leans on aren't penalized.
Headers on every response
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The ceiling for this key (600). |
X-RateLimit-Remaining | Requests left in the current window for this key. |
X-RateLimit-Reset | Unix epoch (seconds) when a slot frees up. |
Retry-After | (429 only) Whole seconds to wait before retrying. |
Handling 429
Over the limit, the API returns 429
rate_limit_exceeded with a Retry-After
header (also echoed as detail.retry_after_seconds). Wait that
many seconds, then retry. Both official SDKs do this automatically
(maxRetries / max_retries, honoring
Retry-After).
# 429 response body
{
"errorCode": "rate_limit_exceeded",
"message": "Rate limit exceeded for this key.",
"detail": { "retry_after_seconds": 12 },
"requestId": "req_…"
}
# Honor the Retry-After header before retrying.// The SDK retries 429s for you, honoring Retry-After:
const wg = new WisegridClient({ apiKey: 'wg_live_xxx', maxRetries: 2 });
// Manual handling:
const { error, response } = await wg.listRows({ path: { sheet_id: 128 } });
if (response.status === 429) {
const wait = Number(response.headers.get('Retry-After') ?? 1);
await new Promise(r => setTimeout(r, wait * 1000));
}# The SDK retries 429s for you, honoring Retry-After: wg = WisegridClient(api_key="wg_live_xxx", max_retries=2) # Manual handling with the raw client would read the Retry-After header # and sleep that many seconds before retrying.
Errors
One envelope, stable string codes, a requestId on every response.
Every 4xx/5xx response uses the same shape:
{
"errorCode": "validation_failed", // a stable string slug you can switch on
"message": "A cell value failed type validation.",
"detail": { "column_id": 90, "reason": "not_a_date" }, // optional, code-specific
"requestId": "req_3kF9aZ…" // also in the X-Request-Id header
}
Switch on errorCode, not message
— the slug is the stable contract; messages may be reworded. The
requestId is on success and error alike (and in
X-Request-Id), so quote it in a support ticket.
The frozen error-code set
These 11 slugs are the complete v1 set (additive only — never removed or redefined):
| errorCode | HTTP | When |
|---|---|---|
| unauthorized | 401 | Missing, malformed, revoked, or expired key. |
| forbidden | 403 | Readable target, but the key lacks write scope. |
| not_found | 404 | Object doesn't exist, or is outside the key's reach (opaque — no existence oracle). |
| validation_failed | 422 | A cell value failed type validation, or a malformed cursor / over-grant. |
| version_conflict | 409 | The row changed since the version you sent (carries currentVersion + current). |
| capacity_exceeded | 409 | A tier / per-sheet capacity limit was reached. |
| rate_limit_exceeded | 429 | Over 600 req/min for this key (see Retry-After). |
| idempotency_key_reused | 409 | An Idempotency-Key was reused with a different request body. |
| idempotency_in_progress | 409 | A request with the same Idempotency-Key is still being processed. |
| payload_too_large | 413 | The request body exceeded the allowed size. |
| internal_error | 500 | An unexpected server error (still enveloped — nothing leaks a non-enveloped body). |
{ errorCode, message, detail? } envelope on each item — the
request as a whole still returns 200. See
Bulk Writes.
Pagination
Opaque cursors, stable on a moving table — no offsets, no totals.
List endpoints (/sheets and /sheets/{id}/rows)
are keyset (cursor) paginated. Pass limit
(default 100, max 500) and an opaque
cursor. Each page returns a nextCursor; pass it
back to get the next page. nextCursor is null on
the last page.
- The cursor is an opaque token — pass it back verbatim, don't parse it.
- No
COUNT(*)/ totals are exposed — keyset paging stays fast and stable even as rows are added mid-iteration (no skips or duplicates). - An over-max
limitis clamped to500(not an error). A malformedcursorreturns422validation_failed.
# First page
curl "https://wisegrid.co/api/v1/sheets/128/rows?limit=500" \
-H "Authorization: Bearer wg_live_xxx"
# Response: { "data": [ … ], "nextCursor": "c_eyJ…", "requestId": "req_…" }
# Next page — pass nextCursor back
curl "https://wisegrid.co/api/v1/sheets/128/rows?limit=500&cursor=c_eyJ…" \
-H "Authorization: Bearer wg_live_xxx"let cursor: string | undefined;
do {
const { data } = await wg.listRows({
path: { sheet_id: 128 },
query: { limit: 500, cursor },
});
for (const row of data!.data) { /* … */ }
cursor = data!.nextCursor ?? undefined;
} while (cursor);cursor = None
while True:
page = wg.list_rows(128, limit=500, cursor=cursor)
for row in page.data:
...
cursor = page.next_cursor
if cursor is None:
breakIdempotency
Retry any write safely — the same key replays one result, never a double write.
Every write endpoint accepts an optional Idempotency-Key
header (use a UUID per logical operation). If a request with that key
already completed, the API replays the stored response and
does not write again — so a dropped connection or an automatic retry can't
create duplicate rows.
- Replay window. A retried request with the same key replays the stored response within 24 hours.
- Same key, different body →
409idempotency_key_reused (you reused a key for a different operation). - Original still in flight →
409idempotency_in_progress (wait and retry). - Scope. Keys are namespaced per API key — two different API keys may use the same idempotency string independently.
curl -X POST "https://wisegrid.co/api/v1/sheets/128/rows:bulkAdd" \
-H "Authorization: Bearer wg_live_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 1f7c2b9a-3e6d-4c11-9a2f-uuid" \
-d '{ "rows": [ { "cells": { "88": "Doing" } } ] }'
# Re-send the exact same request with the same key → the SAME result, no second row.const key = crypto.randomUUID();
await wg.bulkAddRows({
path: { sheet_id: 128 },
headers: { 'Idempotency-Key': key }, // safe to retry with this key
body: { rows: [{ cells: { '88': 'Doing' } }] },
});wg.bulk_add_rows(
sheet_id=128,
body=BulkAddRequestV1(rows=[
RowAddItemV1(cells=RowAddItemV1Cells.from_dict({"88": "Doing"}))
]),
idempotency_key="1f7c2b9a-3e6d-4c11-9a2f-uuid", # replay-safe
)Bulk Writes & Partial Success
One bad row never sinks the batch — every row gets its own result.
POST …/rows:bulkAdd and POST …/rows:bulkUpdate
accept up to 2000 rows and return
HTTP 200 even on partial failure. Each input row gets a
result, in order:
{
"results": [
{ "index": 0, "status": "succeeded", "row": { "id": 5012, "version": 1, "cells": { … } } },
{ "index": 1, "status": "failed",
"error": { "errorCode": "validation_failed", "message": "A cell value failed type validation.",
"detail": { "column_id": 90, "reason": "not_a_date" } } }
],
"succeededCount": 1,
"failedCount": 1,
"requestId": "req_…"
}
- Each result carries
index(its position in your request) andstatus—succeeded(with the writtenrow) orfailed(with anerrorenvelope). - Per-row failures use the same error slugs: validation_failed, capacity_exceeded, version_conflict, not_found, forbidden.
- Read the counts. Inspect
succeededCount/failedCountand retry only the failed rows — don't assume200means everything wrote.
bulkUpdate uses version-CAS
Each update item carries the row id and the
version you last read. If the row changed since, that item
fails version_conflict with the current
version and current row, so you can diff and
retry — while the other rows still apply.
curl -X POST "https://wisegrid.co/api/v1/sheets/128/rows:bulkUpdate" \
-H "Authorization: Bearer wg_live_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: a-uuid" \
-d '{
"rows": [
{ "id": 5012, "version": 3, "cells": { "88": "Done" } },
{ "id": 5013, "version": 1, "cells": { "88": "Blocked" } }
]
}'const { data } = await wg.bulkUpdateRows({
path: { sheet_id: 128 },
headers: { 'Idempotency-Key': crypto.randomUUID() },
body: { rows: [
{ id: 5012, version: 3, cells: { '88': 'Done' } },
{ id: 5013, version: 1, cells: { '88': 'Blocked' } },
] },
});
const failed = data!.results.filter(r => r.status === 'failed');from wisegrid import BulkUpdateRequestV1
from wisegrid_public_api_client.models import RowUpdateItemV1
resp = wg.bulk_update_rows(
sheet_id=128,
body=BulkUpdateRequestV1(rows=[
RowUpdateItemV1(id=5012, version=3, cells={"88": "Done"}),
RowUpdateItemV1(id=5013, version=1, cells={"88": "Blocked"}),
]),
idempotency_key="a-uuid",
)
failed = [r for r in resp.results if r.status == "failed"]Endpoints at a glance
Nine endpoints. Base URL https://wisegrid.co/api/v1.
/meIdentify the key — owner, scopes, rate-limit window./sheetsList sheets this key can read (cursor-paged)./sheets/{id}A sheet + its columns + first page of rows./sheets/{id}/columnsThe sheet's columns./sheets/{id}/rowsThe sheet's rows (cursor-paged)./sheets/{id}/rows:bulkAddAdd up to 2000 rows (partial-success)./sheets/{id}/rows:bulkUpdateUpdate up to 2000 rows (partial-success, version-CAS)./sheets/{id}/rows/{rowId}Update one row's cells (version-CAS)./sheets/{id}/rows/{rowId}Delete a row and its subtree.Full schemas, parameters and response shapes are in the interactive reference below.
API Reference
The complete, interactive contract — rendered live from the OpenAPI spec.
This reference is generated directly from /api/v1/openapi.json — the same contract the SDKs are generated from — so it can never drift from what's deployed. Prefer raw Swagger UI? It's also at /api/v1/docs.
TypeScript SDK — @wisegrid/api
A first-class, fully-typed client with a typed error union.
Real .d.ts types on every resource, generated from the live
OpenAPI contract. Every method returns
{ data, error, response } — data is fully typed;
error carries the stable
{ errorCode, message, detail? } envelope.
npm install @wisegrid/api
import { WisegridClient } from '@wisegrid/api';
const wg = new WisegridClient({ apiKey: 'wg_live_xxx' }); // 1. authenticate
const { data: sheets } = await wg.listSheets(); // 2. list sheets
const sheetId = sheets!.data[0].id; // 3. pick one
await wg.bulkAddRows({ // 4. add a row
path: { sheet_id: sheetId },
headers: { 'Idempotency-Key': crypto.randomUUID() }, // safe retries
body: { rows: [{ cells: { '88': 'Doing' } }] },
});new WisegridClient({
apiKey: 'wg_live_xxx',
baseUrl: 'https://wisegrid.co/api/v1', // default
maxRetries: 2, // auto-retry on 429, honoring Retry-After
});
Methods: getMe(), listSheets(),
getSheet(), listColumns(),
listRows(), bulkAddRows(),
bulkUpdateRows(), patchRow(),
deleteRow().
Python SDK — wisegrid
A fully-typed client with httpx under the hood.
Each method returns the generated typed model (e.g. SheetListV1,
RowV1, BulkWriteResponseV1) — or the typed
ApiErrorV1 on a documented error.
pip install wisegrid
from wisegrid import WisegridClient, BulkAddRequestV1
from wisegrid_public_api_client.models import RowAddItemV1
from wisegrid_public_api_client.models.row_add_item_v1_cells import RowAddItemV1Cells
wg = WisegridClient(api_key="wg_live_xxx") # 1. authenticate
sheets = wg.list_sheets(limit=50) # 2. list sheets
sheet_id = sheets.data[0].id # 3. pick one
wg.bulk_add_rows( # 4. add a row
sheet_id=sheet_id,
body=BulkAddRequestV1(rows=[
RowAddItemV1(cells=RowAddItemV1Cells.from_dict({"88": "Doing"}))
]),
idempotency_key="<a-uuid>", # safe retries
)WisegridClient(
api_key="wg_live_xxx",
base_url="https://wisegrid.co/api/v1", # default
max_retries=2, # auto-retry on 429, honoring Retry-After
timeout=30.0,
)
Methods: get_me(), list_sheets(),
get_sheet(), list_columns(),
list_rows(), bulk_add_rows(),
bulk_update_rows(), patch_row(),
delete_row().