Full CRUD on Google Sheets via REST API: a complete guide
How to read, create, update, and delete rows in a Google Sheet using a REST API - with real GET, POST, PUT, and DELETE examples in JavaScript, Python, and curl.
What CRUD means for a spreadsheet
In a traditional database, you have tables, rows, and primary keys. In a Google Sheet, the equivalent is simple: the sheet tab is the table, each data row is a record, and the row number is the ID.
The first row is always the header row - those cell values become the field names in every JSON response. A sheet with columns name, email, status, created_at returns objects with exactly those keys.
That mapping makes the four CRUD operations straightforward:
| Operation | HTTP method | What it does |
|---|---|---|
| Read | GET | Fetch one row or many rows |
| Create | POST | Append a new row at the end |
| Update | PUT | Overwrite a specific row by number |
| Delete | DELETE | Remove a specific row by number |
GKit SheetsAPI exposes all four over a standard REST interface - no backend to write, no Apps Script to maintain.
Setup: connect your sheet and get your endpoint
Sign in at GKit with your Google account. Paste a Google Sheets URL. GKit reads the first row of each tab as field names and gives you a base URL in this pattern:
https://sheetsapi.gkit.mreshank.com/api/spreadsheets/{userKey}/{sheetName}
{userKey}- the key shown in your dashboard after connecting a sheet{sheetName}- the tab name exactly as it appears in the spreadsheet (e.g.Contacts,Orders,Products)
All examples below use a sheet called Contacts with columns: name, email, status, created_at.
Authentication
GET requests on public sheets work without auth. POST, PUT, and DELETE always require an API key.
Create a key in the dashboard under API Keys, then pass it as a Bearer token:
Authorization: Bearer sk_live_...
Every code example below includes the header. See the auth guide for key rotation and scoping.
READ - GET requests
All rows
JavaScript
const BASE = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets";
const KEY = process.env.GKIT_API_KEY;
const res = await fetch(`${BASE}/uk_abc123/Contacts`, {
headers: { Authorization: `Bearer ${KEY}` },
});
const { data, meta } = await res.json();
// data: [{ name: "Ada", email: "ada@example.com", status: "active", ... }, ...]
// meta: { total: 84, limit: 100, offset: 0 }Python
import os, requests
BASE = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets"
KEY = os.environ["GKIT_API_KEY"]
resp = requests.get(
f"{BASE}/uk_abc123/Contacts",
headers={"Authorization": f"Bearer {KEY}"},
)
payload = resp.json()
rows = payload["data"]
meta = payload["meta"]curl
curl https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts \
-H "Authorization: Bearer sk_live_..."Filter with search
Use ?search=field:value for a contains match (case-insensitive):
curl "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts?search=status:active" \
-H "Authorization: Bearer sk_live_..."const url = new URL(`${BASE}/uk_abc123/Contacts`);
url.searchParams.set("search", "status:active");
const { data } = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}` },
}).then((r) => r.json());Exact match
?search_exact=field:value matches the full cell value only:
curl "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts?search_exact=email:grace@example.com" \
-H "Authorization: Bearer sk_live_..."Use search_exact when you need to look up a user by email or match a status code precisely.
Pagination
async function getPage(offset = 0, limit = 20) {
const url = new URL(`${BASE}/uk_abc123/Contacts`);
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", String(offset));
const { data, meta } = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}` },
}).then((r) => r.json());
return { rows: data, total: meta.total };
}
// Page 1
const page1 = await getPage(0, 20);
// Page 2
const page2 = await getPage(20, 20);params = {"limit": 20, "offset": 0}
resp = requests.get(
f"{BASE}/uk_abc123/Contacts",
headers={"Authorization": f"Bearer {KEY}"},
params=params,
)Sort
Prefix the field name with - for descending order:
# Newest first
curl "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts?sort=-created_at" \
-H "Authorization: Bearer sk_live_..."
# A-Z by name
curl "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts?sort=name" \
-H "Authorization: Bearer sk_live_..."Return specific fields
?fields= accepts a comma-separated list. Only those columns come back in each object:
curl "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts?fields=name,email,status" \
-H "Authorization: Bearer sk_live_..."Good for bandwidth-sensitive clients - a sheet with 20 columns becomes a tight 3-field payload.
Combine parameters
All query parameters compose cleanly:
const url = new URL(`${BASE}/uk_abc123/Contacts`);
url.searchParams.set("search", "status:active");
url.searchParams.set("sort", "-created_at");
url.searchParams.set("limit", "20");
url.searchParams.set("offset", "0");
url.searchParams.set("fields", "name,email,status");
const { data, meta } = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}` },
}).then((r) => r.json());curl "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts?search=status:active&sort=-created_at&limit=20&offset=0&fields=name,email,status" \
-H "Authorization: Bearer sk_live_..."Single row by number
Row numbers are 1-based. Row 1 is the first data row - the header row is never counted.
const res = await fetch(`${BASE}/uk_abc123/Contacts/3`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`Row not found: ${res.status}`);
const row = await res.json();
// { name: "Grace Hopper", email: "grace@example.com", status: "active", created_at: "2026-01-15" }curl https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts/3 \
-H "Authorization: Bearer sk_live_..."CREATE - POST requests
POST a JSON object whose keys match the column headers. GKit appends a new row at the bottom of the sheet.
Single row
JavaScript
const res = await fetch(`${BASE}/uk_abc123/Contacts`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({
name: "Margaret Hamilton",
email: "margaret@example.com",
status: "active",
created_at: new Date().toISOString().slice(0, 10),
}),
});
const created = await res.json();
// { row: 5, name: "Margaret Hamilton", email: "margaret@example.com", ... }Keys not in your headers are ignored. Missing keys write an empty cell.
Bulk insert
POST an array to append multiple rows in a single request:
const rows = [
{ name: "Alan Turing", email: "alan@example.com", status: "active" },
{ name: "Linus Torvalds", email: "linus@example.com", status: "active" },
{ name: "Guido van Rossum", email: "guido@example.com", status: "inactive" },
];
const res = await fetch(`${BASE}/uk_abc123/Contacts`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify(rows),
});
const result = await res.json();
// { inserted: 3, rows: [...] }Python
import json, requests, os
BASE = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets"
KEY = os.environ["GKIT_API_KEY"]
new_contact = {
"name": "Barbara Liskov",
"email": "barbara@example.com",
"status": "active",
"created_at": "2026-06-30",
}
resp = requests.post(
f"{BASE}/uk_abc123/Contacts",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {KEY}",
},
json=new_contact,
)
print(resp.json())curl
curl -X POST https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk_live_..." \
-d '{"name":"Bjarne Stroustrup","email":"bjarne@example.com","status":"active","created_at":"2026-06-30"}'UPDATE - PUT requests
PUT targets a specific row number and overwrites it. Supply the full row object - any field you omit will be written as empty.
Overwrite a row
JavaScript
// Row 3 gets a full replacement
const res = await fetch(`${BASE}/uk_abc123/Contacts/3`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({
name: "Grace Hopper",
email: "grace@example.com",
status: "inactive",
created_at: "2026-01-15",
}),
});
const updated = await res.json();Python
resp = requests.put(
f"{BASE}/uk_abc123/Contacts/3",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {KEY}",
},
json={
"name": "Grace Hopper",
"email": "grace@example.com",
"status": "inactive",
"created_at": "2026-01-15",
},
)curl
curl -X PUT https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts/3 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk_live_..." \
-d '{"name":"Grace Hopper","email":"grace@example.com","status":"inactive","created_at":"2026-01-15"}'Partial update pattern
PUT is a full overwrite. To update one field without losing others, read the row first, merge, then write:
async function patchRow(userKey, sheet, row, patch) {
const current = await fetch(`${BASE}/${userKey}/${sheet}/${row}`, {
headers: { Authorization: `Bearer ${KEY}` },
}).then((r) => r.json());
const merged = { ...current, ...patch };
return fetch(`${BASE}/${userKey}/${sheet}/${row}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify(merged),
}).then((r) => r.json());
}
// Only change the status field
await patchRow("uk_abc123", "Contacts", 3, { status: "inactive" });DELETE - remove a row
DELETE removes the row at the given number. Every row below it shifts up by one - so row 5 becomes row 4 after deleting row 4. If you're iterating and deleting, work from the bottom up or re-fetch the index each time.
JavaScript
const res = await fetch(`${BASE}/uk_abc123/Contacts/3`, {
method: "DELETE",
headers: { Authorization: `Bearer ${KEY}` },
});
if (res.status === 204) {
console.log("Row deleted");
} else {
const err = await res.json();
throw new Error(err.message);
}curl
curl -X DELETE https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts/3 \
-H "Authorization: Bearer sk_live_..."A successful delete returns 204 No Content with no body. Any 4xx response returns JSON with a message field explaining the error.
Output formats
Every GET endpoint accepts a ?format= parameter. The default is json.
| Format | Parameter | Content-Type |
|---|---|---|
| JSON | ?format=json | application/json |
| CSV | ?format=csv | text/csv |
| TSV | ?format=tsv | text/tab-separated-values |
| XML | ?format=xml | application/xml |
| JSONP | ?format=jsonp&callback=myFn | application/javascript |
All filtering, sorting, and pagination parameters work alongside ?format=:
# CSV export of active contacts sorted by name
curl "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/uk_abc123/Contacts?search=status:active&sort=name&format=csv" \
-H "Authorization: Bearer sk_live_..."// Download as CSV in the browser
const url = new URL(`${BASE}/uk_abc123/Contacts`);
url.searchParams.set("format", "csv");
url.searchParams.set("search", "status:active");
const csv = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}` },
}).then((r) => r.text());The full format breakdown is in the output formats guide.
Response shape
Every successful GET returns this envelope:
{
"data": [
{
"name": "Ada Lovelace",
"email": "ada@example.com",
"status": "active",
"created_at": "2026-01-01"
},
{
"name": "Grace Hopper",
"email": "grace@example.com",
"status": "inactive",
"created_at": "2026-01-15"
}
],
"meta": {
"total": 84,
"limit": 20,
"offset": 0
}
}meta.total is the count of all rows matching your filters - not just the page you got back. Use it with limit and offset to build pagination controls.
Single-row GET (/Contacts/3) returns the object directly without the envelope:
{
"name": "Grace Hopper",
"email": "grace@example.com",
"status": "inactive",
"created_at": "2026-01-15"
}POST and PUT return the written row. DELETE returns 204 No Content.
Rate limits and scaling
SheetsAPI sits in front of the Google Sheets API, which has its own read/write quotas. The current limits:
- Read: 60 requests per minute per sheet
- Write: 30 requests per minute per sheet
For read-heavy workloads, enable response caching in your dashboard - SheetsAPI caches GET responses for a configurable TTL and serves them without hitting Google's quota. For write-heavy pipelines (bulk imports, webhooks writing many rows), use the bulk POST endpoint rather than looping single-row inserts.
If your use case is genuinely database-scale, read Google Sheets as a database for a realistic ceiling and when to migrate.
Putting it all together
Here is a minimal CRUD client in JavaScript that covers every operation:
const BASE = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets";
export function sheetsClient(userKey, sheet, apiKey) {
const headers = () => ({
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
});
return {
// READ - all rows with optional params
list(params = {}) {
const url = new URL(`${BASE}/${userKey}/${sheet}`);
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
return fetch(url, { headers: headers() }).then((r) => r.json());
},
// READ - single row
get(row) {
return fetch(`${BASE}/${userKey}/${sheet}/${row}`, {
headers: headers(),
}).then((r) => r.json());
},
// CREATE - single or array
create(data) {
return fetch(`${BASE}/${userKey}/${sheet}`, {
method: "POST",
headers: headers(),
body: JSON.stringify(data),
}).then((r) => r.json());
},
// UPDATE - full overwrite
update(row, data) {
return fetch(`${BASE}/${userKey}/${sheet}/${row}`, {
method: "PUT",
headers: headers(),
body: JSON.stringify(data),
}).then((r) => r.json());
},
// DELETE
async delete(row) {
const r = await fetch(`${BASE}/${userKey}/${sheet}/${row}`, {
method: "DELETE",
headers: headers(),
});
return r.status === 204;
},
};
}
// Usage
const contacts = sheetsClient("uk_abc123", "Contacts", process.env.GKIT_API_KEY);
const { data } = await contacts.list({
search: "status:active",
sort: "-created_at",
limit: "20",
});
const row = await contacts.get(3);
const created = await contacts.create({
name: "Vint Cerf",
email: "vint@example.com",
status: "active",
});
const updated = await contacts.update(3, { ...row, status: "inactive" });
const deleted = await contacts.delete(7);Where to go next
- SheetsAPI product page - full feature list and supported operations
- Use cases - form backends, no-code dashboards, CMS, and more
- Pricing - free while in beta, no row limits
- Dashboard - connect your first sheet
- Docs - full API reference
- Drive Cleaner - find and remove duplicate files in your Google Drive
- About GKit - what we're building and why
- Free tools - utilities that work without an account