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.

AttemptAfter
230 seconds
32 minutes
410 minutes
51 hour
66 hours
712 hours
824 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.

TypeKeys
origin.receivedoriginId, externalReferenceA document arrived without postings. This is where it waits.
origin.postedoriginId, externalReference, entryId, version, entryNumber, actorFires again with a higher version on correction
origin.settledoriginId, externalReference, paidAt, paid, paidBy[]Fully paid or applied
origin.rejectedoriginId, externalReference, reasonA reviewer decided it is not a document
origin.reversedoriginId, externalReference, reversalEntryId, reversesEntryId, reasonUndone; any invoice it had paid is open again
payment.acceptedpaymentId, originId, amount, bankReferenceThe bank took the transfer
payment.rejectedpaymentId, originId, reason
payment.executedpaymentId, originId, bankTransactionOriginId, amountMoney moved; the bill's origin.settled follows
company.lockedthroughEntries on or before this date are final
fiscal_year.closedfiscalYearId, startDate, endDate, result, closingOriginId, openingOriginIdResult moved to equity; the year is locked
invoice.delivered · connection.needs_auth · reconciliation.broken · vat_return.filedDefined 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.