Serverless webhook handlers deployed to Vercel, written in Python with FastAPI.
Five webhook receivers, all triggered by Moco webhooks:
| Endpoint | Trigger | Sink |
|---|---|---|
POST /api/moco-sync |
Activity:create / update / delete |
Target Moco account |
POST /api/bexio-expense-sync |
Purchase:create / update |
Bexio supplier bill |
POST /api/bexio-invoice-sync |
Invoice:update (status=sent) |
Bexio customer invoice |
POST /api/brevo-contact-sync |
Contact:create / update |
Brevo contact (+ list) |
POST /api/supplier-invoice-ocr |
Purchase::Draft:create |
Real Moco purchase with OCR'd fields (auto-released or held for review; already-paid receipts also settled) |
Receives Activity webhooks (create, update, delete) from a source Moco account and replicates them into a target Moco account.
The flow:
Source Moco Vercel Function Target Moco
───────────── ─────────────── ─────────────
Activity created ──webhook──▶ ┌─────────────────────────┐
updated, or deleted │ 1. verify HMAC │
│ 2. check timestamp │
│ 3. check account │
│ 4. accept event in │
│ {create,update, │
│ delete} │
│ 5. filter by user │
└────────────┬────────────┘
│
create ────────────┤ GET /projects ──▶
│ ◀── project list
│ POST /activity ─▶
│
update ────────────┤ GET /activities ▶ (for date)
│ ◀── activities
│ GET /projects ──▶
│ ◀── project list
│ PUT /activity/{id} or POST
│ (upsert if not found)
│
delete ────────────┤ GET /activities ▶ (14-day window)
│ ◀── activities
▼ DELETE /activity/{id}
(200 ok=false + Telegram
alert if not found)
Source project and task are mapped onto the target account by name. If no match is found, configured defaults are used. The link between source and target activity is tracked statelessly by writing a namespaced remote_id ({source-account}:{source-id}) on the target activity — so updates and deletes can be located without any external database.
Useful when two related companies share an employee and time entries logged on one side need to mirror to the other.
Receives Moco Purchase webhooks and creates a matching supplier bill in Bexio. Replaces the n8n workflow Sync expenses from Moco to Bexio.json.
- Looks up (or creates) the Bexio contact by company name.
- Looks up the Bexio booking account by the Moco category's
credit_account. - Downloads the Moco-signed
file_urland uploads it to Bexio as an attachment. - Idempotent: searches
/4.0/purchase/bills?vendor=…&vendor_ref=…— updates DRAFT bills in-place, creates new ones otherwise, and skips bills that are no longer DRAFT. - Branches on
iban: present → QR or IBAN payment block on the default bank account; absent → MANUAL payment routed to the per-user bank account fromBEXIO_MANUAL_BANK_MAP. - The payment remark (
payment.note) is the Moco purchase title — for an OCR'd purchase that's the business purpose ("Mittagessen 20.8. — Ligu Lehm, Bern") — falling back to supplier / Belegnummer / Zahlungszweck, capped at 80 chars. Same rule for QR/IBAN and MANUAL payments. - Posts a comment back to the Moco Purchase with the Bexio bill URL.
- After create/update the bill is auto-booked (
/4.0/purchase/bills/{id}/bookings/BOOKED) and an outgoing payment is created (/4.0/payment/outgoing-payments), then a second Moco comment is posted noting the booking + payment date. Sender (own-company) details come fromBEXIO_OUTGOING_PAYMENT_SENDER. MANUAL bills (no IBAN) skip this step silently since Bexio rejects MANUAL outgoing-payment payloads. Failures are soft — log + Telegram alert with both URLs, the sync still returnsok=true. - Skip branches (no company, no booking account, bill no longer DRAFT) send a Telegram notification with entity context, mirroring the n8n
…Notification to Telegramnodes.
Receives Moco Invoice webhooks and creates a matching customer invoice in Bexio. Replaces the n8n workflow Sync invoices from Moco to Bexio.json.
- Gates on
status == "sent"— drafts are ignored so Moco edit-loops don't churn Bexio. - Fetches the Moco project to read its labels and customer.
- Resolves the revenue account from project labels (e.g.
Stromproduktion → 3010,Wartung → 3450); seeINVOICE_REVENUE_ACCOUNT_BY_LABELinapi/bexio_config.pyfor the full mapping. - Creates the invoice with
api_referenceset to the Moco identifier, then calls/issueto transition the invoice to Open (awaiting payment). - Cross-comments: a Bexio comment with the Moco URL, and a Moco comment with the Bexio URL.
- The
no_customerskip (a sent invoice whose project has no resolvable customer) sends a Telegram notification with the Moco invoice link. Thestatus != "sent"gate stays silent (it fires on every draft edit).
Receives Moco Contact webhooks (create + update) and mirrors the contact into Brevo. Replaces the n8n workflow Add Moco contacts to Brevo.json.
- Skips when the Moco contact has no
work_email(Brevo identifies contacts by email). - Lookup
GET /v3/contacts/{email}. Branches on result:- Not found:
POST /v3/contactswith VORNAME, NACHNAME, ADDITIONAL_INFO (today + Moco URL). Posts a comment back to the Moco contact with the new Brevo URL. - Found:
PUT /v3/contacts/{email}updating VORNAME, NACHNAME, RESPONSIBLE_PERSON (Moco owner's full name), JOB_TITLE.
- Not found:
- Both branches converge to: normalize and set the SMS attribute from
mobile_phone(matches the n8n JS: keeps+/00prefixes, drops a single leading0, strips whitespace), and add the contact to the configured Brevo list (idempotent). - Failures on the SMS update or list-add are logged but do not fail the sync — the contact mutation is the authoritative outcome.
Receives Moco Purchase::Draft:create webhooks (drafts from Moco's email-import) and turns each one into a fully-populated real Moco purchase pre-filled with fields OCR'd from the attached PDF via Claude Sonnet 4.6 Vision. Moco drafts can't be PATCHed via the API, so the flow creates a fresh purchase and auto-deletes the original draft.
- Downloads the PDF from the draft's signed
file_urland runs OCR viaAnthropicOcrClient→ typedInvoiceData(date, due_date, supplier name/address, IBAN, QR-Referenz, totals, VAT rate, payment_purpose, description, commission, Lieferadresse, Gutschrift flag, confidence). - Looks up the supplier against the full
GET /companies?type=supplierlist viaMocoSupplierMatcher(three tiers: exact → substring → normalized token-set) and linkscompany_idonly on a unique match at the winning tier. Ambiguity is left for the reviewer. On a match the full company record is fetched once viaGET /companies/{id}and shared by the VAT and category chains. - Drafts arrive either by email import (
email_fromset) or by manual upload (a staff member picks a PDF and types a subject).email_fromis the discriminator — notuser, since a forwarded email carries both. A manual subject is passed to the OCR call as context and comes back merged intoposition_title, which becomes the purchase and line-item title; a file-name subject is dropped by the model. Either way the subject and its uploader are recorded in a provenance comment on the created purchase, because the draft is deleted afterwards. Seespecs/SPEC_manual_upload_subject.md. - VAT-code resolved per invoice by
VatCodeResolverfromGET /vat_code_purchases(active codes only). Priority: OCRvat_ratematch → supplier company's default → account-wide default → payment-method floor (0% for an already-paid card/POS slip, 8.1% otherwise). A guessed rate holds the purchase for review. Only an account with no active code at the wanted rate omits the field and eats a Moco 422. Special-scheme codes (reverse_charge/intra_eu) are only picked when they're the sole match for a rate. - IBAN safety: Moco's email-import populates
iban/referencefields on the draft from its own QR-bill parser. The service prefers those over OCR (vision-OCR has been observed to mangle alphanumeric Swiss QR-IBANs likeCH22 3000 00DE …). OCR results are mod-97 checksum-validated and nulled on failure. QR-IBAN check (IID30000–31999) gates thebank_transfer_swiss_qr_esrpayment method — regular IBANs with a QR-reference fall back tobank_transferand drop the reference (avoids Moco's"ist keine QR-IBAN"422). - Resolves the OCR'd
commission("Kommission" / "Objekt" / "Bauvorhaben") to a Moco project viaMocoProjectResolver— three matching tiers over aggressive[\W_]+-stripped + case-folded keys: exact → substring (either side contains the other) → token-overlap (shared token of length ≥ 6). Projects are indexed by theirKommissioncustom-property, falling back toproject.namewhen the field is empty. Ambiguous matches (more than one project at the winning tier) are left unresolved. - Picks the per-line-item
category_id(Buchhaltungs-Konto) viaMocoCategoryResolver: resolved project with anAufwandkontocustom-property → that project's category; otherwise a matched supplier company with anAufwandkontocustom-property → that category (either override omits on a catalog miss — silent fallback would mis-route); otherwise card-paid receipts → omit (operator decides per receipt); otherwise the SKR default4000(Wareneinkauf).POST /purchasesis called withcategory_idbaked into each line item. POST /purchaseswith the OCR'd fields, the PDF base64-embedded asfile: {filename, base64}, and tags decided byPurchaseReviewGate—["OCR", "Auto"]or["OCR", "Review pending"](plus"Gutschrift"for credit notes — the line-item total is also negated).due_datedefaults toinvoice_date + 30 daysand weekend dates roll back to Friday.- Auto-release gate (
PurchaseReviewGate): the purchase skips human review — noReview pendingtag, so it flows straight to/api/bexio-expense-sync— only when a supplier company matched and the category is trusted and confidence ≥ 90% and it isn't a credit note. A category is trusted by its source: supplierAufwandkontoalways; projectAufwandkontoonly on an exact/substring project match (not the loose token-overlap tier); the4000default yes — which card receipts can never reach, since the resolver omits the category for them instead.ReviewDecision.reasonsnames the failing conditions in the log, the Telegram message, and the scripts'REVIEWcolumn. Note the 90% bar is a separate constant from the 85% that picks the Telegram emoji. - Already-paid receipts (card / TWINT / POS): a Moco purchase payment is registered right after the create (
POST /purchases/payments), so the purchase doesn't show an open balance on money that already left the account.totalcomes from the create response's server-computedgross_total(not the OCR figure — a rounding cent would leave a residual),datefrom the purchase date (not today, so a late import lands in the right period). A 💳Zahlung automatisch erfasstcomment is posted on the purchase so its timeline explains the settled balance. Soft-failed like the project assign. Skipped for credit notes and non-positive amounts. Runs regardless of the review decision, so a held receipt is settled too — at the cost that a purchase with a payment can no longer be deleted. - When a project was resolved:
POST /purchases/{id}/assign_to_projectis called per line item withnotify_project_leader=false,billable=true,budget_relevant=true,surcharge=true. Per-item failures are soft (logged + appended to the Telegram OCR-outcome alert) — the purchase exists, so the operator finishes the assignment manually. - Posts two Moco comments on the new purchase: 📧
Email-Quelle(sender + body when the draft carriesemail_from/email_body; whitespace-normalized; HTML emails sanitized to Moco's allowed tag subset) and 🤖OCR-Extraktion(extracted fields + confidence). HTML-formatted, html-escaped values, capped at Moco's allowed tag subset (div, strong, em, u, pre, ul, ol, li, br). - Deletes the original draft after the create succeeds (
DELETE /purchases/drafts/{id}). 404 is treated as idempotent; other failures alert via Telegram but don't roll the create back. - Confidence-routed Telegram alert: ✅ when ≥ 85%,
⚠️ when below threshold,⚠️ Gutschrift warning that overrides the confidence path (the reviewer must check the sign). - Bexio-sync interlock: while the
Review pendingtag is on the purchase,/api/bexio-expense-syncsilently skips it. Once the operator strips the tag in Moco's UI, the nextPurchase:updatewebhook syncs to Bexio normally. Auto-released purchases never carry the tag, so they sync on thePurchase:createwebhook without waiting for a human. - Moco 4xx is converted to a silent skip + Telegram alert (e.g.
POST /purchases 422 receipt_identifier: ist bereits vergebenon a webhook replay) — the response stays 200 ok=true so Moco's delivery log doesn't go red on unrecoverable rejections.
Operator scripts for validating the OCR pipeline against real Moco drafts live under scripts/ — see Scripts below.
Operator CLIs under scripts/ drive real Moco/Bexio/Telegram calls directly, so you can validate behaviour without going through a live webhook. All load env from .env.local (vercel env pull .env.local first) and follow the same dry-run-by-default / --apply convention where relevant.
| Script | Purpose | Single draft? | Default mode |
|---|---|---|---|
batch_ocr_drafts.py |
The one that answers "how would the webhook handle this draft?" — runs the supplier-invoice-ocr dispatch (generic purchase / Gutschrift / energy-credit-note routing; smart-me detection is not wired in here, see below) against one or many drafts, prints a per-draft log + summary table. |
--draft-id ID |
dry-run |
batch_smartme_drafts.py |
Same idea, scoped to the smart-me Energiekostenabrechnung branch specifically (is_smartme_draft → SmartmeEnergyExpenseService). |
--draft-id ID |
dry-run |
test_ocr_create_purchase.py |
Deepest single-draft trace of the generic purchase pipeline — every resolution step (supplier, VAT, Kommission/project, category) printed individually. Best when iterating on the OCR prompt or debugging one weird invoice. Not branch-aware: doesn't preview/apply the smart-me or energy-credit-note paths. | positional draft_id (required) |
dry-run |
test_ocr_real.py |
OCR only, no Moco writes at all — just AnthropicOcrClient.extract() against one draft's PDF, pretty-printed. For eyeballing raw model output. |
positional draft_id (required) |
read-only |
send_test_telegram.py |
Sends one Telegram message via TelegramNotifier to verify a bot token / chat id pair. |
n/a | sends for real |
bexio_oauth_bootstrap.py |
One-time (or recovery-time) local OAuth2 Authorization Code flow that seeds bexio:oauth in KV. Not part of the OCR pipeline. |
n/a | writes to KV |
For "did the webhook route draft X the way I expect" — including whether it gets detected as a Gutschrift or an EVU energy credit note — use batch_ocr_drafts.py --draft-id ID (dry-run by default). Use batch_smartme_drafts.py --draft-id ID instead for a smart-me Energiekostenabrechnung draft. test_ocr_create_purchase.py gives more granular step-by-step output but only ever exercises the plain-purchase code path.
The two batch_* scripts need MOCO_SUBDOMAIN, MOCO_API_KEY, ANTHROPIC_API_KEY; test_ocr_create_purchase.py also needs ANTHROPIC_API_KEY and, with --notify, TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID.
Runs the full pipeline against one specific draft id and prints a detailed step-by-step view: the draft fields, the OCR'd InvoiceData, the supplier-lookup outcome, the resolved VAT code, the Kommission → project resolution (status + tier + candidate count), the chosen category (with reasoning), the exact POST /purchases payload (with the PDF base64 elided) and rendered comment bodies, plus a preview of the POST /purchases/{id}/assign_to_project body. Useful when iterating on the prompt or chasing a single weird invoice.
.venv/bin/python scripts/test_ocr_create_purchase.py 3001069 # dry-run
.venv/bin/python scripts/test_ocr_create_purchase.py 3001069 --apply # create the real purchase
.venv/bin/python scripts/test_ocr_create_purchase.py 3001069 --apply --notify # + Telegram alert
.venv/bin/python scripts/test_ocr_create_purchase.py 3001069 --model claude-sonnet-4-6 # model overrideFlags: --apply (POST + comments + delete draft), --notify (Telegram on confidence/Gutschrift), --model (override the Claude model), --env-file (alternative dotenv path).
Exit codes: 0 ok, 1 OCR error, 2 missing env / bad args, 3 Moco fetch error, 4 POST /purchases error.
Note: this script's dry-run preview and its --apply path only exercise the generic OCR→purchase pipeline — it does not wire up SmartmeEnergyExpenseService or EnergyCreditNoteService, so a smart-me or energy-credit-note draft run through here will be (mis)handled as a plain purchase instead of being routed the way the real webhook routes it. Use batch_ocr_drafts.py --draft-id ID (or batch_smartme_drafts.py --draft-id ID) for a webhook-accurate single-draft run.
Lists GET /purchases/drafts (newest first) — or fetches exactly one draft via --draft-id — runs the same in-process pipeline the webhook uses (including Gutschrift and energy-credit-note detection/routing), and prints a per-draft live log followed by a summary table.
.venv/bin/python scripts/batch_ocr_drafts.py --max 5 # dry-run, 5 newest
.venv/bin/python scripts/batch_ocr_drafts.py --max 5 --apply # actually create
.venv/bin/python scripts/batch_ocr_drafts.py --max 20 # larger sweep
.venv/bin/python scripts/batch_ocr_drafts.py --draft-id 3143995 # one specific draft, dry-runPer-draft live log (one block per draft) shows PDF size + OCR latency, confidence + Gutschrift flag, supplier lookup outcome (id + matched/ambiguous/no-match), VAT-code resolution tier (matched OCR rate / supplier default / account default / unresolved), the Kommission → project resolution (project id + tier or no_match / ambiguous), the chosen category_id with reasoning, and chosen payment method + IBAN tail (with (QR-IBAN) marker).
Summary table columns: DRAFT ID | PURCHASE ID | LIEFERANT | BETRAG | KOMMISSION | KATEGORIE | RESULT. The LIEFERANT column carries a leading ✓ when the supplier was uniquely matched in Moco's company list; the KOMMISSION column shows the raw OCR'd value plus a ✓ when it resolved to exactly one project (or ✗ ambiguous (N) when it didn't); the KATEGORIE column shows the resolved account and its source — ✓ 4500 (project) / ✓ 6510 (supplier) / ✓ 4000 (default) on a hit, ✗ 4999 (project) when an Aufwandkonto override names an account missing from the catalog (field omitted), - paid for card receipts without an override. The footer counts created / dry-run / skipped / failed / supplier-matched.
Flags: --max N (cap at N newest drafts, default 10; ignored when --draft-id is given), --draft-id ID (process exactly this draft, bypassing the listing), --apply, --model, --env-file.
Telegram is intentionally not wired into the batch script (one alert per row would spam the chat); the table is the audit surface. Production webhook traffic still notifies as usual.
api/index.py— FastAPI entrypoint. Parses the request, runs the auth pipeline, dispatches to the appropriate service. The three external-sink endpoints (two Bexio, one Brevo) share_handle_moco_dispatch_webhookso the auth/parse/error plumbing isn't duplicated.api/moco_webhook_validator.py—MocoWebhookValidator: HMAC-SHA256 signature check, ±300s timestamp window, account allowlist. Pure, no I/O.api/moco_api.py/api/moco_sync_service.py— target-Moco transport and the Activity replication logic.api/moco_client.py— read-only/comment-only client for the attached Moco account (companies, projects, comments, signed file downloads).api/bexio_api.py— Bearer-auth Bexio REST wrapper covering contacts, accounts, bills, invoices (incl. state transitions), files (multipart upload), document templates. The bearer token is a short-lived OAuth2 access token fromBexioTokenProvider, resolved lazily per request.api/bexio_token_provider.py/api/kv_client.py— OAuth2 token management. Bexio rotates the refresh token on every refresh, so the token state lives in a Redis blob (bexio:oauth, via the nativeREDIS_URL—KVClientspeaks RESP over a socket, noredis-pydep); the provider caches the access token, refreshes under a KV lock, and persists the rotated refresh token. Seed the initial token once withscripts/bexio_oauth_bootstrap.py.api/bexio_config.py— non-secret Bexio numeric IDs (user, owner, bank, defaults) and the label → revenue-account mapping. Hardcoded since they change rarely and aren't credentials.api/bexio_expense_sync_service.py/api/bexio_invoice_sync_service.py— pure business logic; all HTTP transport delegated to the two collaborators above.api/brevo_api.py—api-key-auth Brevo (ex-Sendinblue) REST wrapper covering contact lookup/create/update and list-add. Maps 404 from the contact lookup toNoneso the service can branch without exceptions.api/brevo_contact_sync_service.py— pure business logic for the Brevo flow (lookup → create-or-update → SMS → list add → optional cross-comment to Moco).api/telegram_notifier.py—TelegramNotifier: best-effortsendMessageto a Telegram chat. Used for error/skip notifications; never raises (a Telegram outage won't change the HTTP response).api/anthropic_ocr_client.py—AnthropicOcrClient: thin wrapper around Anthropic'sPOST /v1/messages(Claude Sonnet 4.6 Vision). Sends a PDF as a base64documentcontent block, parses the JSON response into anInvoiceDatadataclass. Robust parser tolerates<think>-style preamble (Sonnet sometimes reasons out loud when length-checked prompts fire); IBAN mod-97 validated; QR-Referenz strict 27-digit check.extract(pdf, subject=…)adds a hand-uploaded draft's operator subject to the user turn as context forposition_title(the merged booking title) —descriptionstays a pure document reading.api/moco_purchase_client.py—MocoPurchaseClient: draft read + create real purchase + delete draft + list vat-code-purchases + list purchases categories + assign-item-to-project + comment on the Moco account. Note thedrafts/URL space — drafts live atGET /purchases/drafts/{id}, distinct from confirmedGET /purchases/{id}.api/moco_project_resolver.py—MocoProjectResolver: indexes Moco projects by theirKommissioncustom-property (falling back toproject.name) and resolves an OCR'd commission string in three tiers (exact → substring → token-overlap), reportingmatched/ambiguous/no_match/emptywith the winning tier. Pure, no I/O.api/vat_code_resolver.py—VatCodeResolver: maps an(invoice, supplier company)pair to avat_code_idfrom theGET /vat_code_purchasescatalog. OCR'd rate → supplier default → account default → a 0%/8.1% floor chosen by payment method. Never auto-picks areverse_charge/intra_eucode when a plain one matches the same rate. Pure, no I/O.api/moco_category_resolver.py—MocoCategoryResolver: maps a(project, supplier, already_paid)triple to acategory_idfrom theGET /purchases/categoriescatalog viacredit_account. Prefers the project'sAufwandkontocustom-property, then the supplier company's, omits on card-paid receipts without an override, falls back to SKR4000(Wareneinkauf). An override naming an unmapped account omits instead of falling through. Pure, no I/O.api/supplier_invoice_ocr_service.py— pure business logic for the OCR flow; orchestrates download → OCR → supplier lookup → VAT resolve → project resolve → category resolve → create → two comments → assign-to-project per item → delete draft → confidence-routed Telegram.
Everything uses urllib — no external HTTP client dependency.
Errors are split by a single question: can a webhook retry fix this? (ports the n8n Error Trigger → Send Error to Telegram handler).
- Application errors — a retry can't help: the upstream rejected our request (a 4xx), an unexpected internal error, or a source↔target mismatch. These fire a Telegram alert (endpoint, event, source ID, error detail) and return HTTP 200
{"ok": false, "error": …}so Moco stops retrying. Telegram carries the visibility. - Infrastructure failures — transient and retry-worthy: the upstream is unreachable or returns a 5xx. These return HTTP 502 so Moco retries, and are not notified (a flapping upstream would otherwise spam the chat on every retry).
- Auth/validation rejections (bad signature, stale timestamp, wrong target, user-filter) are rejected up front with 401/422 and never notified — they're webhook noise.
The Telegram send is best-effort: it never masks or changes the HTTP response Moco receives.
- Python 3.12+, FastAPI
- Vercel Fluid Compute (Python runtime)
- Pytest with patched
urlopen— tests touch no network - GitHub Actions CI on push to
mainand on pull requests
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest -vDeployments are managed with the Vercel CLI. The main branch auto-deploys to production.
vercel deploy # preview deployment
vercel --prod # production deployment from the current branch
vercel logs <url> # tail logs for a given deploymentRequired environment variables (configure in the Vercel project, then vercel env pull for local use):
Shared:
| Variable | Purpose |
|---|---|
MOCO_WEBHOOK_SECRET |
Shared secret used by the Moco account to sign webhook bodies |
MOCO_SUBDOMAIN |
Subdomain of the attached Moco account ({subdomain}.mocoapp.com, e.g. solar); also the expected x-moco-account-url header value |
TELEGRAM_BOT_TOKEN |
Telegram bot token (the …/bot<token>/sendMessage path) for error/skip notifications |
TELEGRAM_CHAT_ID |
Target Telegram chat/group ID (e.g. -1002342319319); per-environment so dev/staging/prod can notify different chats |
Used by /api/moco-sync:
| Variable | Purpose |
|---|---|
MOCO_USER_ID_FILTER |
Only sync activities for this Moco user ID |
MOCO_TARGET_SUBDOMAIN |
{subdomain}.mocoapp.com of the target account |
MOCO_TARGET_API_KEY |
API token for the target Moco account |
MOCO_TARGET_COMPANY_ID |
Target company ID used to scope the /projects lookup |
MOCO_TARGET_DEFAULT_PROJECT_ID |
Fallback project ID when no project name matches |
MOCO_TARGET_DEFAULT_TASK_ID |
Fallback task ID when no task name matches |
Used by /api/bexio-expense-sync and /api/bexio-invoice-sync:
| Variable | Purpose |
|---|---|
MOCO_API_KEY |
API token for the Moco account (used to fetch companies/projects and post comments back) |
BEXIO_CLIENT_ID |
Bexio OAuth2 app client id (Authorization Code Flow with offline_access). |
BEXIO_CLIENT_SECRET |
Bexio OAuth2 app client secret. |
REDIS_URL |
Native Redis connection string (rediss://default:<token>@host:6379) from the Vercel Marketplace Redis integration. Durable store for the rotating Bexio OAuth token (bexio:oauth). |
BEXIO_MANUAL_BANK_MAP |
Optional. JSON map of Moco user first name → Bexio bank_account_id for non-IBAN bills. Example: {"default": 3, "Alice": 5, "Bob": 4}. Falls back to bexio_config.BANK_ACCOUNT_ID when missing. |
BEXIO_OUTGOING_PAYMENT_SENDER |
JSON object with the own-company sender fields embedded in every Bexio outgoing payment created by the expense flow. Expected keys: name, iban, bank_name, bc_no, street, house_no, postcode, city, country_code, bank_account_id (int). IBAN must be contiguous (no spaces). When missing/malformed, the book+pay step is skipped and a Telegram alert fires — the bill itself is still created. Not used by the invoice flow. |
Used by /api/brevo-contact-sync:
| Variable | Purpose |
|---|---|
MOCO_API_KEY |
API token for the Moco account (used to post the cross-link comment back) |
BREVO_API_KEY |
Brevo API v3 key (sent as api-key: …) |
BREVO_LIST_ID |
Numeric ID of the Brevo list every synced contact is added to |
Used by /api/supplier-invoice-ocr:
| Variable | Purpose |
|---|---|
MOCO_API_KEY |
API token for the Moco account (read draft, list vat codes + suppliers, create the real purchase, post comments, delete draft) |
ANTHROPIC_API_KEY |
Anthropic API key for the Claude Sonnet 4.6 Vision OCR call (x-api-key header) |
The VAT code is resolved dynamically per invoice (OCR vat_rate → supplier company default → account-wide default: true flag in /vat_code_purchases → a 0%/8.1% payment-method floor), so there's no env-var default to configure.