Webhooks would be useless without state. The whole point of receiving an event is doing something — and "doing" usually means updating state somewhere. Today we close week 1 by writing a parsed event to a Google Sheet.
import json
payload_str = '{"event": {"type": "item.created", "id": "e_100", "data": {"name": "row-100", "value": 42}}}'
payload = json.loads(payload_str)
event = payload.get("event", {})
# Discover a spreadsheet (no hardcoded ID)
ss_search = toolset.execute_action(Action.GOOGLESHEETS_SEARCH_SPREADSHEETS, {"query": ""})
files = ss_search.get("files", []) or ss_search.get("response_data", {}).get("files", [])
spreadsheet_id = files[0]["id"]
# Append the event data
data = event.get("data", {})
row = [event.get("id"), data.get("name"), str(data.get("value"))]
append = toolset.execute_action(Action.GOOGLESHEETS_SHEET_APPEND_GOOGLE_SHEET_ROW, {
"spreadsheet_id": spreadsheet_id,
"sheet_name": "Sheet1",
"values": [row],
})
print(append.get("updates", {}).get("updatedRange")
or append.get("response_data", {}).get("updates", {}).get("updatedRange"))What if no spreadsheet exists in the user's drive?
The lesson assumes you have at least one Sheet. In production you'd either provision one at first run or fail with a clear error. The discovery pattern (SEARCH_SPREADSHEETS → take the first file) avoids hardcoded IDs but does need some sheet to exist.
And the verification?
The append response includes updatedRange (e.g., "Sheet1!A5:C5"). Asserting it's non-empty confirms the write committed. Sheets is strongly consistent for append-then-read on the same range, so we don't need a retry loop here.
Every production webhook handler combines four pieces:
State updates are what makes the webhook do something — write to a Sheet, create a row in a database, send a follow-up email, ping another service.
Production scripts find their target resource at runtime, not via a hardcoded ID. For Sheets:
ss = toolset.execute_action(Action.GOOGLESHEETS_SEARCH_SPREADSHEETS, {"query": ""})
files = ss.get("files", []) or ss.get("response_data", {}).get("files", [])
spreadsheet_id = files[0]["id"]Refinements you'd add in production: filter files by name ("events" or "webhook-log"), create the sheet if missing.
result = toolset.execute_action(Action.GOOGLESHEETS_SHEET_APPEND_GOOGLE_SHEET_ROW, {
"spreadsheet_id": spreadsheet_id,
"sheet_name": "Sheet1",
"values": [["col_a", "col_b", "col_c"]],
})values is a list of rows. Each row is a list of cells. For a single row, wrap it in another list: [["a", "b"]]. Forgetting the outer list is the most common mistake.
The response shape varies — sometimes result["updates"]["updatedRange"], sometimes nested under response_data. Read both paths:
updated = (
result.get("updates", {}).get("updatedRange")
or result.get("response_data", {}).get("updates", {}).get("updatedRange")
)updatedRange is a string like "Sheet1!A5:C5" — confirms the row landed and tells you where.
Unlike Gmail (eventually consistent index), Sheets append-then-read on the same range is strongly consistent. The updatedRange from the response IS the row you just wrote. No retry loop needed.
| Tool | Discovery | Write | Strong consistency? |
|---|---|---|---|
| Sheets | SEARCH_SPREADSHEETS | SHEET_APPEND_GOOGLE_SHEET_ROW | yes |
| Tasks | LIST_TASK_LISTS | CREATE_TASK | yes |
| Calendar | (primary is canonical) | CREATE_EVENT | yes |
| Docs | search via Drive | INSERT_TEXT | yes |
These four cover most state-update patterns. For the lessons in week 3-4 we'll lean on Sheets as the canonical state store.
Webhooks would be useless without state. The whole point of receiving an event is doing something — and "doing" usually means updating state somewhere. Today we close week 1 by writing a parsed event to a Google Sheet.
import json
payload_str = '{"event": {"type": "item.created", "id": "e_100", "data": {"name": "row-100", "value": 42}}}'
payload = json.loads(payload_str)
event = payload.get("event", {})
# Discover a spreadsheet (no hardcoded ID)
ss_search = toolset.execute_action(Action.GOOGLESHEETS_SEARCH_SPREADSHEETS, {"query": ""})
files = ss_search.get("files", []) or ss_search.get("response_data", {}).get("files", [])
spreadsheet_id = files[0]["id"]
# Append the event data
data = event.get("data", {})
row = [event.get("id"), data.get("name"), str(data.get("value"))]
append = toolset.execute_action(Action.GOOGLESHEETS_SHEET_APPEND_GOOGLE_SHEET_ROW, {
"spreadsheet_id": spreadsheet_id,
"sheet_name": "Sheet1",
"values": [row],
})
print(append.get("updates", {}).get("updatedRange")
or append.get("response_data", {}).get("updates", {}).get("updatedRange"))What if no spreadsheet exists in the user's drive?
The lesson assumes you have at least one Sheet. In production you'd either provision one at first run or fail with a clear error. The discovery pattern (SEARCH_SPREADSHEETS → take the first file) avoids hardcoded IDs but does need some sheet to exist.
And the verification?
The append response includes updatedRange (e.g., "Sheet1!A5:C5"). Asserting it's non-empty confirms the write committed. Sheets is strongly consistent for append-then-read on the same range, so we don't need a retry loop here.
Every production webhook handler combines four pieces:
State updates are what makes the webhook do something — write to a Sheet, create a row in a database, send a follow-up email, ping another service.
Production scripts find their target resource at runtime, not via a hardcoded ID. For Sheets:
ss = toolset.execute_action(Action.GOOGLESHEETS_SEARCH_SPREADSHEETS, {"query": ""})
files = ss.get("files", []) or ss.get("response_data", {}).get("files", [])
spreadsheet_id = files[0]["id"]Refinements you'd add in production: filter files by name ("events" or "webhook-log"), create the sheet if missing.
result = toolset.execute_action(Action.GOOGLESHEETS_SHEET_APPEND_GOOGLE_SHEET_ROW, {
"spreadsheet_id": spreadsheet_id,
"sheet_name": "Sheet1",
"values": [["col_a", "col_b", "col_c"]],
})values is a list of rows. Each row is a list of cells. For a single row, wrap it in another list: [["a", "b"]]. Forgetting the outer list is the most common mistake.
The response shape varies — sometimes result["updates"]["updatedRange"], sometimes nested under response_data. Read both paths:
updated = (
result.get("updates", {}).get("updatedRange")
or result.get("response_data", {}).get("updates", {}).get("updatedRange")
)updatedRange is a string like "Sheet1!A5:C5" — confirms the row landed and tells you where.
Unlike Gmail (eventually consistent index), Sheets append-then-read on the same range is strongly consistent. The updatedRange from the response IS the row you just wrote. No retry loop needed.
| Tool | Discovery | Write | Strong consistency? |
|---|---|---|---|
| Sheets | SEARCH_SPREADSHEETS | SHEET_APPEND_GOOGLE_SHEET_ROW | yes |
| Tasks | LIST_TASK_LISTS | CREATE_TASK | yes |
| Calendar | (primary is canonical) | CREATE_EVENT | yes |
| Docs | search via Drive | INSERT_TEXT | yes |
These four cover most state-update patterns. For the lessons in week 3-4 we'll lean on Sheets as the canonical state store.
Create a free account to get started. Paid plans unlock all tracks.