v1 · public

Kesefle API — for developers

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 HTTPS Bearer auth (Google ID token) KV-backed rate limits HMAC-signed webhooks GDPR / 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

StatusError codeMeaning
401missing_authNo Bearer token / cookie present
401invalid_tokenSignature, audience, or expiry check failed
403admin_requiredEndpoint 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: 30
X-RateLimit-Remaining: 27

When exceeded

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 30
Retry-After: 60
Content-Type: application/json

{
  "ok": false,
  "error": "rate_limit_exceeded",
  "retry_after": 60
}

Per-route limits

EndpointLimitWindow
/api/events?action=waitlist5 per IP, 3 per email1 hour
/api/events?action=track20060 seconds
/api/events?action=nps324 hours
/api/auth/google-exchange101 hour
/api/sheet/provision51 hour
/api/sheet/summary3060 seconds
/api/billing/paypal · /crypto-create · /manual30 · 10 · 101 hour
/api/account?action=*5 combined1 hour
/api/referral?action=*30 combined1 hour
/api/admin?action=*6060 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.

import crypto from 'node:crypto';

function verifyMetaSignature(rawBody, header, appSecret) {
  if (!header?.startsWith('sha256=')) return false;
  const expected = header.slice(7);
  const computed = crypto.createHmac('sha256', appSecret)
    .update(rawBody).digest('hex');
  if (expected.length !== computed.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(computed, 'hex'),
  );
}

Coinbase Commerce — X-CC-Webhook-Signature

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.)

import crypto from 'node:crypto';

function verifyCoinbase(rawBody, sigHeader, secret) {
  const expected = crypto.createHmac('sha256', secret)
    .update(rawBody).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(sigHeader, 'hex'),
  );
}

Idempotency

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.

Errors

All failure responses share this shape:

{
  "ok": false,
  "error": "machine_readable_code",
  "detail": "optional human-readable explanation",
  "reqId": "abc123…"
}

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

CodeHTTPMeaning
method_not_allowed405Wrong HTTP verb
missing_auth401No Bearer / cookie
invalid_token401JWT signature / aud / exp failed
admin_required403Not in ADMIN_EMAILS
rate_limit_exceeded429Token bucket empty — see retry_after
invalid_action / unknown_action400Bad ?action= value
kv_outage503Vercel KV is unreachable
signature_invalid401Webhook HMAC check failed
reauth_needed403Refresh 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.

What we log

  • Structured request logs (method, path, status, latency, request ID) without bodies.
  • User sub + email on authenticated requests.
  • Anonymous event counters (page views, signup funnel, NPS scores) bucketed by day.
  • Audit entries for account deletion and admin user-actions.
  • Webhook signature failures (for abuse detection).

What we never log

  • Raw WhatsApp message bodies (only the parsed amount + category are persisted, into your sheet).
  • Bank / card numbers — we never receive them.
  • Google refresh tokens in plaintext — stored AES-256-GCM encrypted with AAD bound to the user sub.
  • The IP of any authenticated request beyond the rate-limit window (ephemeral).
  • The contents of your Google Drive outside the single Kesefle-provisioned sheet.

For GDPR Article 17 (deletion) and Article 20 (portability), use /api/account?action=delete and ?action=export. Israeli Privacy Law Amendment 13 complaints: Privacy Protection Authority.

Endpoints

Twelve endpoints, grouped by surface. Use the search box (top-left) to filter this list as you type.

POST /api/events?action=waitlist|track|nps public

Anonymous events

Consolidated router for waitlist signups, privacy-friendly analytics, and NPS feedback. No auth, no cookies, no fingerprinting.

action=waitlist

Rate limit: 5 / hour / IP, 3 / hour / email.

// Request
{ "email": "user@example.com", "source": "landing-hero" }

// 200 OK
{ "ok": true }

// 400 — invalid_email | 429 — rate_limit_ip | rate_limit_email

action=track

Rate limit: 200 / minute. Returns 204 No Content.

// Request
{
  "event": "signup_complete",
  "session": "a1b2c3d4e5f6",
  "path": "/",
  "meta": { "plan": "pro", "utm_source": "twitter" }
}

// 204 — accepted (no body)
// 400 — invalid_event | invalid_session

Allowed events: page_view, cta_click, signup_start, signup_complete, sheet_provisioned, first_message_received, subscribe_clicked, export_downloaded, feature_used, help_search, install_pwa, referral_share, referral_redeem.

action=nps

Rate limit: 3 / 24h / IP.

// Request
{
  "score": 9,
  "comment": "Saves me 10 min a day.",
  "session": "a1b2c3d4e5f6",
  "path": "/dashboard"
}

// 200 OK
{ "ok": true, "bucket": "promoter" }
POST /api/auth/google public

Verify a Google ID token

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.

Request

{
  "credential": "eyJhbGciOiJSUzI1NiIs…"
}

// or, equivalently:
{ "id_token": "eyJ…" }

Response

// 200 OK
{
  "ok": true,
  "user": {
    "sub": "110023456789012345678",
    "email": "user@example.com",
    "emailVerified": true,
    "name": "Alice",
    "picture": "https://lh3.googleusercontent.com/…",
    "locale": "he",
    "provider": "google",
    "firstSeen": "2026-05-16T08:00:00.000Z"
  }
}

// 400 — missing credential
// 401 — verification failed: signature invalid | bad issuer | audience mismatch | token expired
// 500 — server misconfigured: GOOGLE_CLIENT_ID required
POST /api/auth/google-exchange public

Exchange OAuth code for tokens (PKCE)

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.

Rate limit: 10 / hour / IP.

Request

{
  "code": "4/0AeaY…",
  "codeVerifier": "S256_random_string_43_to_128_chars",
  "redirectUri": "https://kesefle.com/account"
}

Response

// 200 OK
{
  "ok": true,
  "user": {
    "sub": "110023456789012345678",
    "email": "user@example.com",
    "name": "Alice",
    "picture": "https://lh3.googleusercontent.com/…"
  },
  "accessToken": "ya29.a0Ae…",
  "expiresIn": 3599,
  "hasRefreshToken": true
}

// 400 — missing code | missing codeVerifier | missing redirectUri
//       | id_token_verification_failed | id_token_missing_sub
// 4xx/5xx — token_exchange_failed (passthrough of Google's status)
// 500 — token_encryption_unavailable (server misconfigured — fail-closed)
// 502 — google_token_endpoint_unreachable
POST /api/sheet/provision public (access-token verified)

Provision a user's Google Sheet

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
GET /api/sheet/summary auth required

Dashboard summary

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)
GET POST /api/whatsapp/webhook HMAC (Meta)

WhatsApp Cloud API webhook

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.

POST /api/billing/paypal · /crypto-create · /manual auth required

Start a payment (PayPal, crypto, Bit, bank)

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.

PayPal — recurring subscription

// POST /api/billing/paypal?action=subscribe
{ "plan": "pro" }
// → 200 { ok: true, url: "https://www.paypal.com/…", subscriptionId: "I-…" }

Crypto — Coinbase Commerce (prepaid)

// POST /api/billing/crypto-create
{ "plan": "pro", "period": "month" }
// → 200 { ok: true, url: "https://commerce.coinbase.com/charges/…" }

Bit / bank — manual confirm (prepaid)

// 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.

Rate limits: PayPal 30/h · crypto 10/h · manual 10/h.

POST /api/billing/crypto-webhook · /paypal?action=webhook signed

Payment webhooks & activation

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.

Handled events

Source · eventEffect
Coinbase charge:confirmed | resolvedActivate prepaid period (extends accessUntil)
PayPal BILLING.SUBSCRIPTION.ACTIVATEDActivate recurring; accessUntil = next_billing_time + grace
PayPal PAYMENT.SALE.COMPLETEDRenewal — refresh accessUntil
PayPal …CANCELLED | EXPIRED | SUSPENDEDStop renewing; access rides out the paid period

Manual confirm (admin)

// 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
POST GET /api/account?action=delete|export auth required

Account self-service (GDPR / Privacy Law)

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.

GET ?action=export

Returns Content-Disposition: attachment; filename="kesefle-export-YYYY-MM-DD.json".

// 200 OK — application/json (download)
{
  "export_meta": { "exported_at": "…", "format_version": "1.1" },
  "profile": { "sub": "…", "email": "…", "plan": "pro", "…": "…" },
  "transactions": { "count": 42, "data": [/* … */] },
  "referral": { "code": "AB3CDE" },
  "not_stored_by_kesefle": ["…"],
  "your_rights": { "delete": "POST /api/account?action=delete" }
}
POST GET /api/referral?action=generate|mine|redeem auth required

Referral program

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
}

GET ?action=mine

// 200 OK
{
  "ok": true,
  "code": "AB3CDE",
  "url": "https://kesefle.com/referral?ref=AB3CDE",
  "count": 3,
  "redeemers": [
    { "email_initial": "a***", "at": "2026-05-12T10:00:00.000Z" }
  ],
  "referral_credit_until": "2026-06-15T08:00:00.000Z"
}

POST ?action=redeem

// Request
{ "code": "AB3CDE" }

// 200 OK
{
  "ok": true,
  "granted_until": "2026-06-15T08:00:00.000Z",
  "referrer_first_name": "Alice",
  "note_he": "קיבלת חודש Pro חינם. גם החבר שהביא אותך קיבל."
}

// 400 — invalid_code_format | cannot_redeem_own_code
// 404 — code_not_found
// 409 — already_redeemed (returns { prior: { code, referrerSub, at } })
GET POST /api/admin?action=… admin only

Admin operations

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

MethodactionDescription
GETusersPaginated user list (?q, ?page, ?limit)
GETuserSingle user (?sub)
GETjobsFailed + retrying background jobs
GETmetricsTotal users, byPlan, MRR, DAU/MAU lower bound
GETanalyticsDaily counters + unique sessions (?days=30, max 90)
GETauditAudit log entries (?action_filter, ?limit)
GETfeature-flagsAll flag:* KV entries
POSTfeature-flag-setBody: { key, value }
POSTuser-actionBody: { action, targetUserSub }; actions: resend_welcome, force_resync, reset_plan_to_free, pause_account, unpause_account

Example: GET ?action=metrics

// 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"]
}
GET /api/health public

Health probe

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.

Contact