Technical comparison and one-time setup guide for deploying this app.
How the three integrations differ at the implementation level.
Webhook— the app acts as a thin proxy. The client sends the expense payload plus the user's webhook URL to /api/webhook. The route validates the body and forwards it to the URL. The automation platform handles the rest — no Google or Notion credentials are ever needed on the server. Works with Zapier, Make, Pipedream, n8n, or any service that accepts a POST request.
Sheets API— the app implements the full OAuth 2.0 Authorization Code Flow. The user authorises once via the Google consent screen; the server exchanges the auth code for a refresh token and stores it in the client's localStorage. Every subsequent API call (write or read) calls refreshAccessToken() server-side first — the client never sees an access token. The refresh token persists indefinitely until the user revokes access from their Google account settings.
The OAuth callback route (/api/auth/google/callback) exchanges the code for tokens, fetches the user's email, then redirects to /settings/connect with the refresh token and email as URL params. ConfigForm reads and saves these on mount, then immediately calls history.replaceState to remove them from the URL.
Notion — no OAuth flow required. The user creates a Notion Internal Integration, copies the token (ntn_…), and pastes it along with the database ID in /settings/connect. ConfigForm POSTs these to /api/notion/connect, which validates database access and encrypts the token with ENCRYPTION_KEY before returning it to the client. The plaintext token is never stored in the browser.
Bidirectional sync — both useSheetsSync and useNotionSync work identically: fetch all remote records, diff by id against IndexedDB, pull missing records locally, and push local-only expenses to the remote. The idfield is the dedup key — no record is ever duplicated on either side. The “Sync now” button in SyncBanner triggers both syncs in parallel when both integrations are active.
What you need to do once before deploying, per integration.
The webhook URL is provided by the user at runtime — it's passed in the request body and never stored server-side. There are no environment variables to set and no third-party configuration needed. Deploy the app and users can connect any automation platform immediately.
This setup is done once by the developer. After it's complete, any user can connect their Google account without any further configuration.
http://localhost:3000/api/auth/google/callbackhttps://your-production-domain.com/api/auth/google/callback.env.local (local) and your hosting platform (production):ENCRYPTION_KEY with: openssl rand -base64 32Restart the dev server after saving.Apps in Testing mode can only be authorised by listed test users. Publishing removes this restriction but triggers a Google review for sensitive scopes.
No OAuth credentials are needed. The only server-side requirement is ENCRYPTION_KEY (shared with Sheets API). If you already set it up for Sheets, Notion works with no additional configuration.
ENCRYPTION_KEY is set in .env.local and your hosting platform:openssl rand -base64 32The integration token (ntn_…) is validated and encrypted server-side when the user saves their credentials. The plaintext token never persists in the browser.
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and ENCRYPTION_KEY live in environment variables and are only used server-side. They are never sent to the client.
After the Google OAuth flow completes, the server encrypts the refresh token with AES-256-GCM using ENCRYPTION_KEY before sending it to the client. The encrypted blob is stored in localStorage. When the client calls /api/sheets, it sends the blob; the server decrypts it, exchanges it for a short-lived access token, makes the Google Sheets call, and discards both. The access token and plaintext refresh token never reach the client.
The Notion integration token follows the same encryption model. The user's plaintext token is sent once to /api/notion/connect for validation; the server encrypts it and returns the blob to the client. All subsequent calls send the encrypted blob, which the server decrypts on the fly.
The webhook URL is stored in localStorage and sent in the request body. It is a shared secret — anyone who obtains it can POST to the webhook. The app never logs any credential server-side.
Source code: github.com/sanudin-dev/money-tracker