Integrations and webhooks
Evenbead connects to the services your money moves through, and sends events from your books to the tools you already use. Both are off until a person turns them on: nothing is connected and no endpoint exists for a company until someone with Manage integrations adds one.
Integrations
Settings, Integrations lists what is Built (connectable on this deployment) and what is Coming, with its roadmap wave. GET /v1/integrations/providers and the list_integration_providers tool return the same list.
Connecting
Connecting is OAuth 2.0 with PKCE: POST /v1/integrations/connections with the provider returns its consent URL (valid 15 minutes). You allow access at the provider, it sends you back to Evenbead, and the connection is made. Only people with admin access do this (a firm member needs "Manage integrations" in the client); agents and AI apps are refused. The provider's tokens are encrypted at rest with a key of their own, and never appear in a response, a log or the audit trail.
Each connection gets:
- its own agent, "<Provider> sync" (read and propose). Everything the connection writes is that agent's, under the Agent Ledger Policy: its posting limit, the five triggers, proposals, never approving its own work. The audit log names the agent, the model
evenbead-connector/<provider>@<version>and the sync run. The agent has no API key. - a clearing account ("Shopify Clearing"): sales post gross into it (income, discounts, shipping, sales tax, tips), with the provider's fees, refunds and standalone fees paid out of it. Each payout moves the money from clearing to the bank account you choose and is matched to the deposit in your bank feed. When everything is in, the clearing balance equals what the provider still holds; the connection page shows both.
- a mapping: sales categories, products, fees, tax codes, discounts, shipping, tips, refunds and payouts to accounts (with a class and a project if you like).
*is each kind's default. Keys a sync sees without a mapping of their own are listed.
Until you approve the mapping, everything waits
A new connection's syncs land in the approval queue (integration_review) until a person approves the mapping (POST /v1/integrations/connections/{id}/mappings/approve). After that, routine postings at or under the agent's limit post on their own; bigger ones wait as proposals. Changing the mapping withdraws the approval. Corrections always wait for a person.
Syncing, exactly once
Syncs run every minute or two when a provider's webhook says something changed, when you click Sync now, and at least hourly. Every object the provider sends (an order, a refund, a fee, a payout) is keyed by the provider, the account and the provider's own id, so a re-sync, or connecting the same account again later, never posts anything twice. When an object changes after it was booked (a late refund, a payout that failed), Evenbead proposes a correcting entry for the difference; posted entries are never edited. Objects in another currency than the company's wait for review (same currency only for now).
Health
Each connection shows its health: healthy, syncing, Approve the mapping, slowed down (the provider asked us to wait), error (with the message and the next try), Reconnect <Provider> (access expired or was removed at the provider), paused, or disconnected. Tokens are refreshed before they expire; providers that rotate refresh tokens are handled.
Disconnecting
POST /v1/integrations/connections/{id}/disconnect revokes access at the provider, deletes the tokens, stops syncing and revokes the connection's agent. Everything already in the books stays.
Outbound webhooks
Settings, Webhooks (or POST /v1/webhooks) adds an https endpoint and the events it wants. The response carries the endpoint's signing secret (ebwhsec_...) once; store it in your receiver.
| Event | When |
|---|---|
entry.posted, entry.reversed | A journal entry was posted or reversed |
invoice.created, invoice.paid, invoice.voided | An invoice was created, paid in full, voided |
bill.created, bill.paid, bill.voided | The same for bills |
payment.received, payment.made | A customer payment or a vendor payment was recorded |
proposal.created, proposal.decided | An agent's write waits for approval; a person approved or rejected it (data.decision) |
reconciliation.finished | A bank reconciliation was finished |
period.status_changed | A month was soft-locked, locked or reopened |
integration.error | A connection needs attention (reconnect, repeated sync errors) |
* | Every event |
Events are written in the same database transaction as the change, so a rolled-back change sends nothing. They are thin: the object, its id and a few fields (number, dates, amounts, status, who acted). Read the rest through the API with your own key.
{ "id": "4f0c…", "type": "invoice.paid", "created_at": "2026-10-14T19:37:10.434Z", "company_id": "9b2e…",
"data": { "object": "document", "id": "a1d3…", "kind": "invoice", "number": "1042", "total_cents": 12000, "payment_id": "77c1…" } }
Verifying a delivery
Every delivery is a POST with Evenbead-Event-Id, Evenbead-Event-Type, Evenbead-Delivery-Id and Evenbead-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body> with your secret. While a rotated secret's overlap lasts, the header carries one v1 per live secret. Check the time (5 minutes of tolerance) and compare in constant time:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, now = Math.floor(Date.now() / 1000)) {
const parts = (header ?? "").split(",");
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
if (!Number.isFinite(t) || Math.abs(now - t) > 300) return false;
const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return parts.filter((p) => p.startsWith("v1=")).some((p) => { const got = Buffer.from(p.slice(3), "hex"); return got.length === want.length && timingSafeEqual(got, want); });
}
Deliveries are at least once: dedupe on the event id.
Retries, the log, testing, rotation
- Answer with any 2xx within 10 seconds. Anything else is retried after 1, 5 and 30 minutes, 2, 6 and 12 hours, then daily twice; then it is given up and stays in the delivery log (
GET /v1/webhooks/{id}/deliveries), where you can send it again (POST /v1/webhook-deliveries/{id}/redeliver). - An endpoint whose deliveries all failed for 3 days is turned off, with the reason; turn it on again when it works.
- Send test (
POST /v1/webhooks/{id}/test) sends a signedwebhook.testevent at once and shows what your endpoint answered. - Rotate secret (
POST /v1/webhooks/{id}/rotate-secret) returns a new secret once; the old one keeps signing too for the overlap you choose (up to 7 days), so you can switch without missing a delivery. - Redirects are not followed. Endpoints must be public https addresses: private, local and cloud-metadata addresses are refused, also when a name resolves to one later.
GET /v1/webhook-eventsis the event feed of the last 30 days.
Adding an endpoint, changing where it sends and rotating its secret are done by people with admin access (and ask a browser session to confirm it's you if you have not in the last 10 minutes). Agents and AI apps can read endpoints and the log, send a test and redeliver.