Webhooks
A webhook endpoint gets an HTTP POST from WorthSync when something happens in
your ledger. Every delivery is signed so you can prove it came from WorthSync
and hasn’t been tampered with.
Register an endpoint
- In the app, go to Settings → Developer.
- Under Webhooks, enter your endpoint URL and choose Add webhook.
- Copy the signing secret shown at creation — like an API token, it is displayed once. The list afterwards shows only a short prefix.
Secrets look like whsec_…. Disable stops delivery to an endpoint.
Endpoint URLs must be http or https and must be publicly reachable.
Loopback, private-network, and internal-hostname targets are rejected when you
register them and again at delivery time.
Events
| Event | Fires when |
|---|---|
snapshot.upserted | A snapshot is created or updated through the public API — the single write, the snapshot PATCH, or a bulk write. |
Snapshot deletions do not fire an event, and neither do balances you enter by hand in the app.
Payload
{
"id": "0d4e9f2a-...",
"type": "snapshot.upserted",
"created_at": "2026-07-14T09:32:11.204Z",
"data": {
"snapshots": [
{
"index": 0,
"account_id": "1b6e...",
"snapshot_date": "2026-07-14",
"balance": 12500.25,
"action": "inserted"
}
]
}
}data.snapshots is always an array. A bulk write sends one event carrying
every row that was written, with index matching the position in your original
request. action is inserted or updated.
Treat id as the idempotency key — a retried delivery reuses it.
Headers
WorthSync-Event: snapshot.upserted
WorthSync-Timestamp: 1784070000
WorthSync-Signature: v1=<hex hmac sha256>
User-Agent: WorthSync-Webhooks/1.0Verifying the signature
The signature is an HMAC-SHA256 over the string:
<WorthSync-Timestamp>.<raw request body>keyed with your endpoint’s signing secret, hex-encoded, and prefixed with
v1=.
Verify against the raw body bytes, before any JSON parsing — re-serializing the payload will change the bytes and break the comparison.
import crypto from 'node:crypto'
function verify(rawBody, headers, secret) {
const timestamp = headers['worthsync-timestamp']
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const received = (headers['worthsync-signature'] || '').replace(/^v1=/, '')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))
}Compare signatures with a constant-time function, and reject deliveries whose timestamp is far from your own clock so an old, valid-looking request can’t be replayed at you.
Delivery and retries
- Your endpoint should answer with a 2xx status. Anything else counts as a failure.
- Redirects are not followed — a
3xxis a failure. Register the final URL. - Each attempt times out after about 10 seconds.
- Failed deliveries are retried with exponential backoff — about a minute after the first failure, then doubling — for a maximum of 5 attempts, after which the delivery is abandoned.
- Retries are dispatched by a scheduled job that runs every 10 minutes, so a retry lands on the next run after its backoff expires rather than to the second.
Because retries exist, your handler must be idempotent: the same event id
can arrive more than once. Answer 2xx quickly and do the real work
asynchronously — a slow handler will time out and be retried.