Reference · v1
Tudo API
REST endpoints for items + outbound webhooks for events. Authenticate with a personal access token; everything below works from cURL, any HTTP client, or our future SDK.
Authentication
Every request authenticates with a personal access token. Mint one in Settings → API tokens. Send it on every request:
Authorization: Bearer tudo_•••••••••••••••••••••••••••••••••
Tokens inherit the minting user's tenant + role. They show their full value once at creation; lost tokens must be revoked and re-issued. Tokens optionally expire (30d / 90d / 1y).
Auth check
curl https://app.usetudo.com/api/v1/me \ -H "Authorization: Bearer $TUDO_TOKEN"
{
"auth": { "source": "token", "scopes": ["*"] },
"user": { "id": "...", "email": "you@example.com", "full_name": "Felipe" },
"tenant": { "id": "...", "name": "GreenBoost", "slug": "greenboost" }
}Items
Items are rows on a board. Use these endpoints to read or mutate them from outside the UI. Every endpoint is tenant-scoped via the token.
/api/v1/items?board_id={uuid}&limit=&cursor=Lists items in a single board. Returns up to limit rows (default 50, max 200) with a keyset cursor. Pass cursor from pagination.next_cursor to walk the next page.
curl "https://app.usetudo.com/api/v1/items?board_id=$BOARD&limit=20" \ -H "Authorization: Bearer $TUDO_TOKEN"
/api/v1/items/{id}Returns the row plus every cell value keyed by column_id in data.column_values.
curl https://app.usetudo.com/api/v1/items/$ITEM_ID \ -H "Authorization: Bearer $TUDO_TOKEN"
/api/v1/itemsCreates a row at the bottom of group_id (or the board's first group if omitted). Optional initial cell values go through the same path the UI uses, so any board automation fires.
curl https://app.usetudo.com/api/v1/items \
-H "Authorization: Bearer $TUDO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"board_id": "...",
"name": "Order #1042",
"column_values": [
{ "column_id": "...", "value": { "text": "Acme Co" } },
{ "column_id": "...", "value": { "option_id": "in-progress" } }
]
}'/api/v1/items/{id}Renames the item and / or upserts cell values. Invalid column_ids are silently dropped; the response echoes applied_column_ids so you can tell.
curl -X PATCH https://app.usetudo.com/api/v1/items/$ITEM_ID \
-H "Authorization: Bearer $TUDO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Order #1042 (rush)",
"column_values": [
{ "column_id": "...", "value": { "option_id": "urgent" } }
]
}'/api/v1/items/{id}Soft-deletes the row (lands in trash, restorable for 30 days). Fires the item.deleted webhook.
curl -X DELETE https://app.usetudo.com/api/v1/items/$ITEM_ID \ -H "Authorization: Bearer $TUDO_TOKEN"
Webhooks
Tudo POSTs to a URL you register in Settings → Webhooks whenever a subscribed event fires. Payloads are signed; reject any request whose signature doesn't match.
Envelope
{
"id": "evt_4f3a8e2c…",
"event": "approval.requested",
"timestamp": 1714234567890,
"tenant_id": "5b2…",
"data": { /* event-specific payload */ }
}Headers
X-Tudo-Event— same aseventin the body.X-Tudo-Tenant-Id— issuing tenant.X-Tudo-Signature—t=<ms>,v1=<hex>wherev1isHMAC_SHA256(secret, "{t}.{rawBody}").User-Agent—Tudo-Webhooks/1.
Verification (Node)
import { createHmac } from "node:crypto";
export function verifyTudoWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
toleranceSeconds = 300,
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=") as [string, string]),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
// Reject replays older than the tolerance window.
if (Math.abs(Date.now() - t) > toleranceSeconds * 1000) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return timingSafeEqual(v1, expected);
}
function timingSafeEqual(a: string, b: string) {
if (a.length !== b.length) return false;
let r = 0;
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
return r === 0;
}Verification (Python)
import hmac, hashlib, time
def verify_tudo_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t, v1 = int(parts.get("t", 0)), parts.get("v1", "")
if not t or not v1: return False
if abs(time.time() * 1000 - t) > tolerance * 1000: return False
mac = hmac.new(secret.encode(), f"{t}.{raw_body.decode()}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, v1)Event types
item.created—{ item_id, board_id, group_id, name, created_by, created_at }item.deleted—{ item_id, board_id, group_id, name, deleted_at }approval.requested—{ request_id, item_id, item_name, item_url, requested_by, requester_name, reason, mode, approver_ids }approval.decided—{ request_id, step_id, item_id, item_name, item_url, requested_by, decided_by, decision, final_status, comment }
Subscribe an endpoint to * in the settings UI to opt into every event. New types roll out monthly; subscribing to * means new events deliver automatically.
Errors
Errors return JSON of the form { "error": "<CODE>", "message": "<human readable>" } with the matching HTTP status:
401 UNAUTHENTICATED— missing / revoked / expired token.400 BAD_REQUEST— payload missing a required field or referencing a row outside the token's tenant.404 NOT_FOUND— id valid in shape, no row in scope.500 INTERNAL— database error; the message is safe to log.