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

  1. In the app, go to Settings → Developer.
  2. Under Webhooks, enter your endpoint URL and choose Add webhook.
  3. 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

EventFires when
snapshot.upsertedA 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.0

Verifying 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 3xx is 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.