Developer essentials
Webhooks
Everything that happens to a company's books is an event. Subscribe a URL and Vexless delivers them, signed, at least once.
Subscribe
POST /v1/companies/{companyId}/webhooks
{ "url": "https://your.app/vexless", "events": ["origin.posted", "origin.settled", "payment.executed"], "secret": "optional, at least 16 characters" }
{ "id": "whk_…", "secret": "…" } # the only time the secret is shown
Omit secret and Vexless generates one. Webhooks are per company; subscribe each company you create. Needs ledger:read.
GET /v1/companies/{companyId}/webhooks # { items: [{ id, url, events, status, createdAt }], next }
DELETE /v1/companies/{companyId}/webhooks/{webhookId} # status becomes DISABLED; no further deliveries
Delivery
Each event is POSTed as JSON to every active webhook subscribed to its type. Answer with any 2xx within 10 seconds and do the work afterwards; a non-2xx answer, a redirect or a timeout schedules a retry.
Each subscription retries on its own schedule. If you run two webhooks on a company and one is down, the healthy one still receives every event exactly once — a failure on one endpoint never redelivers to another.
| Attempt | After |
|---|---|
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours |
Delays carry a small random offset, so a batch that failed together does not retry together. After the eighth attempt the delivery is marked FAILED and is not tried again. A subscription that has failed continuously for 72 hours, with no successful delivery in between, is set to DISABLED with a disabledReason; subscribe again once the endpoint is back.
Delivery is at least once and order is not guaranteed. A settlement's origin.settled can arrive before the origin.posted of the transaction that caused it. Design the handler to read current state from the API rather than to replay history from events.
What a url must be
The url must use https, must not carry credentials, and must resolve to a public address — loopback, link-local and private ranges are refused at subscription time and re-checked before every attempt. Redirects are not followed: a 3xx counts as a failed attempt, because the destination behind it was never checked.
See what happened
GET /v1/companies/{companyId}/webhooks/{webhookId}/deliveries
{ "items": [
{ "id": "dlv_…", "eventId": "evt_…", "eventType": "origin.posted", "status": "FAILED",
"attempts": 8, "lastStatus": 502, "lastError": "https://your.app/vexless responded 502",
"nextAttemptAt": null, "deliveredAt": null, "failedAt": "2026-09-18T09:12:03.120Z",
"createdAt": "2026-09-15T09:12:03.120Z" }
], "next": null }
One row per event this subscription was sent, newest first. status is PENDING, DELIVERED or FAILED. Listing webhooks also reports lastSuccessAt, lastFailureAt and consecutiveFailures, which is usually enough to tell a broken endpoint from a quiet one. Needs ledger:read.
Verify the signature
POST https://your.app/vexless
Content-Type: application/json
X-Vexless-Event: origin.settled
X-Vexless-Event-Id: evt_…
X-Vexless-Delivery-Id: dlv_…
X-Vexless-Signature: t=1757000000,v1=<hex hmac-sha256(secret, t + "." + rawBody)>
{ "id": "evt_…", "type": "origin.settled", "companyId": "cmp_…", "occurredAt": "2026-09-15T09:12:03.120Z",
"originId": "org_…", "externalReference": "2026-000117", "paidAt": "2026-09-15",
"paid": { "amount": 125500, "currency": "EUR" }, "paidBy": ["org_banktx…"] }
Recompute the HMAC over the timestamp, a dot, and the raw request body, and compare with v1 in constant time. Use the body bytes as received, not a re-serialisation.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, signatureHeader, rawBody) {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.split("=")));
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Reject a timestamp older than a few minutes if replay matters to you; Vexless signs each attempt with the time of that attempt.
Handle every event once
Retries mean duplicates. X-Vexless-Event-Id, also id in the body, is stable across attempts; store it and skip what you have seen. X-Vexless-Delivery-Id identifies the attempt series for one subscription and is what the delivery log is keyed on. Origin events can also legitimately repeat with new content: a correction re-fires origin.posted with a higher version. Key your handling on originId plus version, and treat the latest as current.
Events
Every body carries id, type, companyId and occurredAt, then the keys below.
| Type | Keys | |
|---|---|---|
| origin.received | originId, externalReference | A document arrived without postings. This is where it waits. |
| origin.posted | originId, externalReference, entryId, version, entryNumber, actor | Fires again with a higher version on correction |
| origin.settled | originId, externalReference, paidAt, paid, paidBy[] | Fully paid or applied |
| origin.rejected | originId, externalReference, reason | A reviewer decided it is not a document |
| origin.reversed | originId, externalReference, reversalEntryId, reversesEntryId, reason | Undone; any invoice it had paid is open again |
| payment.accepted | paymentId, originId, amount, bankReference | The bank took the transfer |
| payment.rejected | paymentId, originId, reason | |
| payment.executed | paymentId, originId, bankTransactionOriginId, amount | Money moved; the bill's origin.settled follows |
| company.locked | through | Entries on or before this date are final |
| fiscal_year.closed | fiscalYearId, startDate, endDate, result, closingOriginId, openingOriginId | Result moved to equity; the year is locked |
| invoice.delivered · connection.needs_auth · reconciliation.broken · vat_return.filed | Defined and subscribable; not emitted in this version |
Testing a handler
Every event fires on a sandbox company exactly as on a live one. Point a webhook at a tunnel to your development machine, run the quickstart, and you have seen the whole set. Testing has the rest.