Build on top of Kesefle. All endpoints are HTTPS, JSON, and live at https://kesefle.com. This page is the source of truth for request and response shapes.
JSON over HTTPSBearer auth (Google ID token)KV-backed rate limitsHMAC-signed webhooksGDPR / Israeli Privacy Law
Overview
Kesefle is a Hebrew WhatsApp-to-Google-Sheets expense tracker. The API powers our web dashboard, the WhatsApp webhook, and any third-party tool that wants to integrate. The base URL is:
https://kesefle.vercel.app
Conventions:
All requests and responses are JSON unless noted (WhatsApp and payment webhooks read raw bytes).
Successful responses include "ok": true. Failures use "ok": false with an error code.
Authenticated endpoints expect Authorization: Bearer <Google ID token>.
Many endpoints are routed by a ?action= query parameter to keep the deployed serverless-function count low.
Webhooks (WhatsApp, Coinbase) validate HMAC signatures over the raw request body; PayPal webhooks are verified via PayPal's signature-verification API.
Authentication
Most endpoints require a verified Google ID token. We verify the token's RS256 signature against Google's JWKS (https://www.googleapis.com/oauth2/v3/certs), check the iss, aud, and exp claims, and set req.user = { sub, email, name, picture } from the verified payload.
Obtaining a token
Use Google Identity Services in the browser to obtain a JWT credential. For the offline (refresh-token) flow, use the PKCE redirect to /api/auth/google-exchange instead — that endpoint stores the refresh token server-side, encrypted at rest.
Sending the token
GET /api/sheet/summary HTTP/1.1
Host: kesefle.vercel.app
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…Accept: application/json
For browser sessions, a kfl_session cookie containing the same JWT is also accepted.
A note on X-User-Sub
Older docs and examples may show an X-User-Sub request header. That header is ignored. The user identity is taken exclusively from the verified ID token payload. Sending it has no effect.
Failure modes
Status
Error code
Meaning
401
missing_auth
No Bearer token / cookie present
401
invalid_token
Signature, audience, or expiry check failed
403
admin_required
Endpoint requires admin email in ADMIN_EMAILS
Rate limiting
Each endpoint has its own token bucket implemented via Vercel KV (Upstash) INCR + EXPIRE. Authenticated requests are keyed by user sub; anonymous requests are keyed by IP (IPv6 grouped at /64 to prevent prefix bypass). When KV is unreachable we fail open — legitimate users are never blocked by an outage.
Response headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 30X-RateLimit-Remaining: 27
When exceeded
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 30Retry-After: 60Content-Type: application/json{"ok":false,"error":"rate_limit_exceeded","retry_after":60}
Per-route limits
Endpoint
Limit
Window
/api/events?action=waitlist
5 per IP, 3 per email
1 hour
/api/events?action=track
200
60 seconds
/api/events?action=nps
3
24 hours
/api/auth/google-exchange
10
1 hour
/api/sheet/provision
5
1 hour
/api/sheet/summary
30
60 seconds
/api/billing/paypal · /crypto-create · /manual
30 · 10 · 10
1 hour
/api/account?action=*
5 combined
1 hour
/api/referral?action=*
30 combined
1 hour
/api/admin?action=*
60
60 seconds
HMAC webhook signatures
Two endpoints accept HMAC-signed inbound webhooks: /api/whatsapp/webhook (Meta) and /api/billing/crypto-webhook (Coinbase Commerce). Both disable the default JSON body parser so we can read the raw request bytes — re-serializing JSON before verification will fail because key ordering and whitespace differ. Both use constant-time comparison via Node's crypto.timingSafeEqual. PayPal subscription webhooks (/api/billing/paypal?action=webhook) are verified differently — via PayPal's verify-webhook-signature API.
WhatsApp (Meta) — X-Hub-Signature-256
The header is sha256=<hex-digest>. The signing secret is META_APP_SECRET. The signed message is the entire raw request body.
Header is a hex HMAC-SHA256 of the raw request body (no timestamp envelope). Secret: COINBASE_WEBHOOK_SECRET. (PayPal subscription webhooks use a different scheme — verified server-side via PayPal's verify-webhook-signature API.)
Webhooks may be retried by the sender. We deduplicate at the application layer using KV-backed keys:
WhatsApp: the inbound message.id from Meta is checked against seen:wa:<id> with a 24-hour TTL. Duplicates return 200 { ok: true, ignored: "duplicate" } without writing to the sheet.
Payments: Coinbase events are checked against crypto_event:<id> and PayPal events against paypal_event:<id>. Duplicates return 200 { ok: true, duplicate: <id> }.
Sheet rows: each appended row carries the message_id in column I, so the sheet itself is the system of record for "we already processed this".
Provision:/api/sheet/provision is idempotent — calling it twice for the same user returns the existing spreadsheetId with reused: true.
Referral generate: calling ?action=generate twice returns the same code with reused: true unless ?force=1 is supplied.
A client-supplied Idempotency-Key header is not currently honored — submit a feature request if you need it.
Every response carries a request ID (also logged server-side). Include the reqId when reporting issues so we can pull the trace.
Common error codes
Code
HTTP
Meaning
method_not_allowed
405
Wrong HTTP verb
missing_auth
401
No Bearer / cookie
invalid_token
401
JWT signature / aud / exp failed
admin_required
403
Not in ADMIN_EMAILS
rate_limit_exceeded
429
Token bucket empty — see retry_after
invalid_action / unknown_action
400
Bad ?action= value
kv_outage
503
Vercel KV is unreachable
signature_invalid
401
Webhook HMAC check failed
reauth_needed
403
Refresh token expired / revoked — relink Google
Privacy notes
Kesefle is built around the principle that your data lives in your Google Sheet, not ours. Our KV holds the minimum needed to route inbound WhatsApp messages to the right sheet.
Takes a Google Identity Services credential (JWT), verifies the RS256 signature against Google's JWKS, validates issuer / audience / expiry, and upserts the user record in KV. Used by the browser's one-tap signin flow.
Server side of the OAuth 2.0 authorization-code-with-PKCE flow. The browser redirects to Google with access_type=offline&prompt=consent and a code_challenge, then POSTs the resulting code here. We exchange it with Google's token endpoint, verify the returned ID token, and persist the refresh token encrypted at rest (AES-256-GCM, AAD = userSub). The refresh token is never returned to the browser.
Copies the Kesefle template (KESEFLE_TEMPLATE_SHEET_ID) into the user's Drive using their OAuth access token (must include drive.file + spreadsheets scopes). The token is verified server-side via Google's tokeninfo endpoint — the user sub in the request body is ignored; only the sub from the verified token is trusted. Idempotent: a second call returns the existing sheet.
Rate limit: 5 / hour.
Request
{"accessToken":"ya29.a0Ae…"}
Response
// 200 OK (new){"ok":true,"reused":false,"spreadsheetId":"1aBcD…","spreadsheetUrl":"https://docs.google.com/spreadsheets/d/1aBcD…"}// 200 OK (idempotent replay){"ok":true,"reused":true,"spreadsheetId":"…","spreadsheetUrl":"…"}// 400 — missing accessToken// 401 — invalid_access_token (missing_drive_file_scope | tokeninfo_aud_mismatch | etc.)// 500 — server misconfigured: KESEFLE_TEMPLATE_SHEET_ID missing// 502 — drive api unreachable
Reads the user's תנועות tab via the stored refresh token (server-side OAuth) and returns aggregates for the current month plus the 10 most recent transactions and the top 5 spending categories.
Rate limit: 30 / minute.
Request
GET /api/sheet/summary HTTP/1.1
Authorization: Bearer <Google ID token>
Response
// 200 OK{"ok":true,"month_expenses":4385.5,"month_income":8500,"month_count":42,"month_expenses_delta_pct":-12,"recent": [
{"date":"2026-05-15","amount":245,"is_income":false,"category":"אוכל לבית","subcategory":"סופר","description":"סופר","raw":"245 סופר"}
],"top_categories": [
{"name":"אוכל לבית","total":1240}
],"refreshed_at":"2026-05-16T08:00:00.000Z"}// 404 — user not found | no sheet provisioned// 403 — reauth_needed | refresh_token_decrypt_failed// 502 — sheets_api_unreachable// 5xx — sheets_read_failed (passthrough)
Meta calls this endpoint. GET is the one-time verification handshake; POST delivers messages, signed with X-Hub-Signature-256: sha256=<hex> over the raw body using META_APP_SECRET. We disable the default body parser so we can verify the bytes Meta actually sent. Duplicate message.ids are deduplicated via KV (seen:wa:<id>, 24h TTL).
Verification (GET)
GET /api/whatsapp/webhook
?hub.mode=subscribe
&hub.verify_token=<META_VERIFY_TOKEN>
&hub.challenge=1234567890
// 200 OK — body: the challenge string (echoed back)// 403 — verify token mismatch
Message delivery (POST)
POST /api/whatsapp/webhook
X-Hub-Signature-256: sha256=ab12…Content-Type: application/json{"object":"whatsapp_business_account","entry": [{"changes": [{"value":{"messaging_product":"whatsapp","messages": [{"from":"972541234567","id":"wamid.HBgM…","type":"text","text":{"body":"245 סופר"}}]
}}]
}]
}// 200 OK — always (per Meta best practice; details surfaced inside)// 401 — invalid signature | 400 — body read failed | invalid json
Special inbound commands handled: STOP / עצור / הסר / ביטול / בטל opts the user out (Meta + Israeli direct-marketing law); START re-subscribes.
Three create endpoints, one per channel. In all cases the userSub comes from the verified ID token — never the request body. Plans: pro (₪19/mo) · family (₪39/mo). Optional period: month (default) or year.
// POST /api/billing/manual?action=request{"plan":"pro","period":"month","method":"bit"}// → 200 { ok: true, code: "KFL-7K3Q9F", amountILS: 19, instructions: "…" }// Owner is alerted on WhatsApp; premium activates once they confirm.
All channels converge on one activation path (lib/billing.activatePremium), which writes the canonical user record with a single accessUntil clock. Coinbase webhooks are HMAC-verified; PayPal webhooks are verified via PayPal's API. Both deduplicate by event id.
// POST /api/billing/manual?action=confirm (admin only — ADMIN_EMAILS){"code":"KFL-7K3Q9F"}// → 200 { ok: true, status: "confirmed", plan: "pro", accessUntil: "…" }// GET ?action=list → open pending · POST ?action=reject → drop one
Two actions: delete (GDPR Art.17 + Israeli Privacy Law §14) and export (GDPR Art.20). Combined limit: 5 / hour.
POST ?action=delete
// Request{"confirmation":"DELETE-MY-ACCOUNT"}// 200 OK{"ok":true,"deleted": ["user:…","sheet:…","referral:code:…"],"note":"… The Google Sheet itself remains in your Drive …","note_he":"…"}// 400 — missing or invalid confirmation// 404 — user not found
The Google OAuth refresh token is revoked at https://oauth2.googleapis.com/revoke as part of deletion. The provisioned spreadsheet stays in the user's Drive — Kesefle never had the permission to delete it.
Both referrer and redeemer get one month of Pro free. Combined rate limit: 30 / hour. Codes are 6 characters drawn from A–Z2–9 excluding visually-ambiguous 0/O/1/I/L.
POST ?action=generate
// 200 OK{"ok":true,"code":"AB3CDE","url":"https://kesefle.com/referral?ref=AB3CDE","reused":true// or "regenerated": true if ?force=1}
Restricted to verified IDs whose email appears in ADMIN_EMAILS. Rate limit: 60 / minute. All actions auto-redact sensitive fields (refreshToken, refreshTokenEnvelope, accessToken) before returning user records.
Available actions
Method
action
Description
GET
users
Paginated user list (?q, ?page, ?limit)
GET
user
Single user (?sub)
GET
jobs
Failed + retrying background jobs
GET
metrics
Total users, byPlan, MRR, DAU/MAU lower bound
GET
analytics
Daily counters + unique sessions (?days=30, max 90)
// 200 OK{"ok":true,"metrics":{"totalUsers":421,"byPlan":{"free":380,"pro":35,"family":6},"paidUsers":41,"mrr":899,"dau":112,"mau":112},"notes": ["mau is a 25h-lower-bound based on last_inbound TTL"]
}
Liveness + dependency reachability. Returns 200 if all critical dependencies (KV + Google OAuth) are up, else 503. Always sets Cache-Control: no-store. Safe to point an external uptime monitor at this URL.
Response
// 200 OK{"ok":true,"version":"1bbd640","deployed_at":"2026-05-16T07:55:00Z","region":"fra1","response_ms":183,"deps":{"kv":{"ok":true,"status":200},"google_oauth":{"ok":true,"status":200},"sheets_api":{"ok":true,"status":200}},"env_configured":{"google_client_id":true,"google_client_secret":true,"kv":true,"meta_verify_token":true,"meta_app_secret":true,"paypal_client_id":true,"coinbase_commerce_api_key":true,"bit_payee_phone":true,"bank_transfer_details":true}}// 503 — same body, ok: false, when KV or Google OAuth is down
env_configured flags only report presence of env vars — never values.
SDK / clients
Coming soon — we'll publish a TypeScript SDK once the API stabilizes. Until then, every endpoint is fetch()-friendly with no special headers beyond Authorization: Bearer and Content-Type: application/json.
// Minimal client example (browser or Node 18+)async function kesefle(path, init = {}, idToken) {const r = await fetch(`https://kesefle.com${path}`, {
...init,
headers: {'Content-Type': 'application/json',
'Authorization': `Bearer ${idToken}`,
...(init.headers || {}),
},
});const body = await r.json();if (!body.ok) throw Object.assign(new Error(body.error), { status: r.status, body });return body;}const summary = await kesefle('/api/sheet/summary', { method: 'GET'}, token);
Changelog
No API-breaking changes yet — this is v1.
Product-level updates are posted on the blog. Once the API hits 1.1 we'll keep a dedicated record here.