Skip to main content

Webhooks

Webhooks live in Plugins → Webhooks and work in two directions.

Inbound: upload from other tools​

An inbound endpoint is a secret URL that accepts files without a login. Create one, copy its token (it is shown once) and send files to:

  • POST /api/v1/webhooks/<token>/statements — a bank statement (PDF, CSV, XLSX, OFX/QFX, QIF, camt.053, MT940)
  • POST /api/v1/webhooks/<token>/receipts — a receipt (photo, PDF or e-invoice XML)

Send the file as multipart field file, or as JSON { "fileBase64": "…", "fileName": "march.pdf" }. Statements may name a walletId and branchId; the endpoint's defaults apply otherwise. Files land in the workspace as if its owner had uploaded them.

Outbound: events​

A subscription sends a POST with a JSON body to your URL whenever one of its events happens. The URL must be public (no private or loopback addresses). Every body has event, workspaceId and timestamp (ISO 8601) next to the event's own fields.

EventWhenFields
transaction.createdOne transaction was added: a manual expense, a receipt scan, an approved receipt booked as new, a bill paid in cash, a row recovered from a statement's warningstransactionId, amount, currency, transactionDate (YYYY-MM-DD), counterpartyName, transactionType, source, statementId, categoryId
transactions.importedA batch arrived: a statement or import was committed, a custom table became a statement, an entity import ran, a crypto wallet synced or its CSV was importedsource, statementId, count, transactionIds (at most 500), truncated
statement.uploadedA statement file was uploadedstatementId, statementName, bankName
statement.processedIts transactions were committedstatementId, transactionCount
statement.failedParsing or the import failedstatementId, statementName, stage (parsing or import), errorMessage
transactions.uncategorizedA processed statement left rows without a categorystatementId, count
receipt.needs_reviewA receipt was read but needs a personreceiptId, source, reasons (missing_amount, possible_duplicate)
receipt.approvedA receipt was approvedreceiptId, transactionId, mode (created, attached_to_bank_row, confirmed_scan)
invoice.paid / bill.paidMoney owed to the workspace (an invoice) or by it (a bill) was paid in fullpayableId, direction, source, vendor, amount, currency, paidAt
invoice.overdue / bill.overdueIt passed its due date unpaidpayableId, direction, source, vendor, outstanding, currency, dueDate
budget.exceededA budget's spending reached its limit this period (once per period)budgetId, name, categoryId, limitAmount, spentAmount, percentUsed, currency
tax.deadline.upcomingA VAT or income-tax return is due soonkind, returnName, periodStart, periodEnd, dueDate, daysLeft
tax.threshold.reachedTaxable turnover crossed a registration-threshold steplevel, turnover, threshold, currency, percentUsed, periodStart, periodEnd

A large batch lists the first 500 ids and sets truncated; read the rest with GET /api/v1/transactions?statementId=….

Headers and the signature​

HeaderValue
X-Seneca-EventThe event name
X-Seneca-DeliveryThe delivery id, the same on every retry: use it to ignore repeats
X-Seneca-Signaturesha256= + hex HMAC-SHA256 of the raw body with the subscription's secret

Check the signature over the raw body before parsing it:

import { createHmac, timingSafeEqual } from 'node:crypto';

function isFromSeneca(rawBody, header, secret) {
const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

Retries​

A delivery that does not get a 2xx answer within 10 seconds is tried again after 30 seconds and then 5 minutes; after the third attempt it is given up. Redirects are not followed. Each subscription keeps its delivery log (GET /api/v1/webhook-subscriptions/<id>/deliveries), and a failed delivery can be sent again with POST /api/v1/webhook-deliveries/<id>/retry. Test in the panel queues a statement.processed body with "test": true.