Webhooks
Register an HTTPS endpoint and we tell your server the moment something changes, so you can stop polling on a timer. Events say which account and when, never what.
What you get
A webhook is a signed HTTPS request we send to a URL you own. You get one when a sync brings in new transactions for an account, when an account's balance changes, and when a connection changes status. Your handler verifies the signature, acknowledges, and then reads the detail from the API exactly as it does today.
Events carry no financial data
So the shape of an integration is: receive the event, verify it, return 200, then call the API for the transactions or balance that changed. Webhooks replace the timer, not the read.
Registering an endpoint
Endpoints are managed in the portal, under Webhooks. Paste your HTTPS URL, give it a description if you run more than one, and save. You can have up to three endpoints, which is enough for production, staging and a spare.
The signing secret is shown once
whsec_. We store only a hash of it, so that reveal is the single moment it exists in the interface. Copy it into your secrets manager before closing the dialog. If you lose it, rotate the secret in the portal and deploy the replacement: the old secret stops verifying the instant the new one is issued.Press Test on an endpoint to send a webhook.test event straight away and see what your receiver answered, including the status code and how long it took. It is the quickest way to prove a deployment before you wait on real traffic.
Verify your endpoint
A new endpoint receives nothing until you have verified it. In the portal, press Send verification code: we deliver a webhook.verify event whose data carries an eight-character code. Read that code from your receiver's logs and type it into the box on the endpoint. The row turns Active and real events start flowing.
Why a code rather than a handshake
- The code expires one hour after it is sent. Send a fresh one if you are too late.
- You get five attempts per code. After that the code is spent and you send a new one.
- Verification and test sends both reach an unverified endpoint, so you can prove a deployment before you turn it on.
The event envelope
Every event is a JSON object with the same five top-level fields. The body is serialised canonically, with compact separators and sorted keys, which is why the examples below read alphabetically.
idis a UUID unique to the event. Retries resend the same id, so it is your de-duplication key.typeis one of the five types below.created_atis an ISO 8601 UTC timestamp for when the event was created, not when this attempt was sent.api_versionisv1, matching the API the ids indatabelong to.dataholds the identifiers for that type.
| Type | Sent when |
|---|---|
| account.transactions.available | A sync brought in new transactions for an account. |
| account.balance.updated | An account’s balance changed at the bank. |
| connection.status.changed | A connection moved to a new status, for example to expired. |
| webhook.verify | You asked the portal for a verification code. Carries the code. |
| webhook.test | You pressed Test in the portal. Never sent on its own. |
account.transactions.available
New transactions landed for an account. new_count is how many arrived in that sync and fetched_at is when the sync ran. Fetch the account's transactions with a small date window that overlaps your last read.
{
"api_version": "v1",
"created_at": "2026-09-17T09:14:22Z",
"data": {
"account_id": "6f2b6c0e-7d1a-4a2e-9f0b-2c9a1e5d4b3a",
"fetched_at": "2026-09-17T09:14:20Z",
"new_count": 3
},
"id": "0a0f0a53-1e2b-4c8d-9a7f-5b3c2d1e0f9a",
"type": "account.transactions.available"
}account.balance.updated
The account's balance moved. The new figure is not in the event; read it from the balance endpoint, where the fetched_at will match the one here.
{
"api_version": "v1",
"created_at": "2026-09-17T09:14:22Z",
"data": {
"account_id": "6f2b6c0e-7d1a-4a2e-9f0b-2c9a1e5d4b3a",
"fetched_at": "2026-09-17T09:14:20Z"
},
"id": "1b2c3d4e-5f60-4718-8293-a4b5c6d7e8f9",
"type": "account.balance.updated"
}connection.status.changed
A connection changed status. status uses the same vocabulary as the connections endpoint: linked, awaiting_auth, rejected, suspended, expired, error and initiated. account_ids lists every account that connection carries, so you can mark them all as stale in one pass. See Consent & reconnection for what each status means for the data you already hold.
{
"api_version": "v1",
"created_at": "2026-09-17T09:14:22Z",
"data": {
"account_ids": [
"6f2b6c0e-7d1a-4a2e-9f0b-2c9a1e5d4b3a",
"b8e4d2a1-1c3f-4e5a-8b6d-0f2a7c9e1d4b"
],
"connection_id": "3c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f",
"status": "expired"
},
"id": "2c3d4e5f-6071-4829-93a4-b5c6d7e8f9a0",
"type": "connection.status.changed"
}webhook.verify
Sent when you ask the portal for a verification code, and the only event that carries a value you have to read. Log data.code somewhere you can see it, or surface it in your own admin; it is valid for an hour.
{
"api_version": "v1",
"created_at": "2026-09-17T09:14:22Z",
"data": {
"code": "K7QB2XM4",
"endpoint_id": "8d7c6b5a-4e3f-2d1c-0b9a-8f7e6d5c4b3a"
},
"id": "4e5f6071-8293-4a4b-b5c6-d7e8f9a0b1c2",
"type": "webhook.verify"
}webhook.test
Sent only when you press Test in the portal. Handle it the same way as any other event and return 200; there is nothing to fetch afterwards.
{
"api_version": "v1",
"created_at": "2026-09-17T09:14:22Z",
"data": {
"endpoint_id": "8d7c6b5a-4e3f-2d1c-0b9a-8f7e6d5c4b3a"
},
"id": "3d4e5f60-7182-493a-a4b5-c6d7e8f9a0b1",
"type": "webhook.test"
}Headers
Deliveries are POST requests carrying five headers that matter.
POST /hooks/endute HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Endute-Webhooks/1
Endute-Webhook-Id: 0a0f0a53-1e2b-4c8d-9a7f-5b3c2d1e0f9a
Endute-Webhook-Timestamp: 1789636462
Endute-Webhook-Signature: v1=8a41c0b7d2e9f3a15c6b8d0e2f4a6c8e1b3d5f7092a4c6e8b0d2f4a6c8e0b2d4Endute-Webhook-Idis the event UUID, the same value asidin the body. Stable across retries.Endute-Webhook-Timestampis unix seconds for this attempt, so a retry carries a fresh one.Endute-Webhook-Signatureisv1=followed by the lowercase hex HMAC-SHA256 of{timestamp}.{body}, keyed with your endpoint secret. A retry carries a fresh signature because the timestamp changed.User-Agentis alwaysEndute-Webhooks/1andContent-Typeis alwaysapplication/json.
Verifying the signature
Anyone can POST JSON at a public URL, so verify every request before you act on it. Three rules, all of which the examples below follow:
- Sign the raw request bytes. Parsing the JSON and re-serialising it changes the whitespace and the signature will never match.
- Compare in constant time. A byte-by-byte
==that returns early leaks your secret to anyone patient enough to measure it. - Reject timestamps older than five minutes, so a captured delivery cannot be replayed at you later.
import hashlib
import hmac
import os
import time
SECRET = os.environ["ENDUTE_WEBHOOK_SECRET"] # whsec_...
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, headers) -> bool:
"""Return True if this request really came from Endute and is recent."""
timestamp = headers.get("Endute-Webhook-Timestamp", "")
signature = headers.get("Endute-Webhook-Signature", "")
# Reject anything outside the five-minute window: a captured request must
# not be replayable against you tomorrow.
if not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
SECRET.encode(),
timestamp.encode() + b"." + raw_body,
hashlib.sha256,
).hexdigest()
# Constant time: a plain == leaks the secret one byte at a time.
return hmac.compare_digest(f"v1={expected}", signature)
# In your handler, AFTER verify() passes:
# event = json.loads(raw_body)
# if already_processed(headers["Endute-Webhook-Id"]):
# return 200 # a retry of something you already did
# record(headers["Endute-Webhook-Id"])
# enqueue(event) # do the slow work off the request
# return 200import crypto from 'node:crypto'
const SECRET = process.env.ENDUTE_WEBHOOK_SECRET // whsec_...
const TOLERANCE_SECONDS = 300
export function verify(rawBody, headers) {
const timestamp = headers['endute-webhook-timestamp'] ?? ''
const signature = headers['endute-webhook-signature'] ?? ''
// Five-minute replay window.
if (!/^\d+$/.test(timestamp)) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return false
}
const expected =
'v1=' +
crypto
.createHmac('sha256', SECRET)
.update(timestamp + '.')
.update(rawBody) // the RAW bytes, never a re-serialised object
.digest('hex')
// timingSafeEqual throws on a length mismatch, so check that first.
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// Express: mount express.raw({ type: 'application/json' }) on this route so
// req.body is a Buffer. A JSON body parser re-serialises the payload and the
// signature will never match.De-duplicate on the event id
Endute-Webhook-Id, and a receiver that answers slowly can be delivered twice. Record each id you have processed and return 200 without acting when one repeats. The same rule applies to the transactions you then fetch: de-duplicate those on their own id as well.Retries and failures
A delivery counts as failed if we cannot reach your endpoint, if it does not answer within ten seconds, or if it answers with anything other than a 2xx. A redirect counts as a failure too: we do not follow 3xx. Failed deliveries are retried on a widening schedule, each wait measured from the previous attempt.
| Attempt | Sent |
|---|---|
| First attempt | Immediately, as the event is created |
| Retry 1 | 1 minute later |
| Retry 2 | 5 minutes later |
| Retry 3 | 30 minutes later |
| Retry 4 | 2 hours later |
| Retry 5 | 12 hours later |
| Retry 6 | 24 hours later |
| After that | The event is dropped |
The last retry therefore lands a little over a day and a half after the first attempt. An event that has still not been accepted by then is dropped and never resent, which is another reason to treat the API, not the event stream, as your source of truth: a fresh read always catches up.
Twenty consecutive failures disable the endpoint
To bring a disabled endpoint back, fix the receiver and press Enable in the portal, which clears the disabled state and the failure count. Press Test first if you want to confirm the fix before real events start flowing again.
Nothing is replayed on re-enabling
/v1 to catch up. The same applies to an endpoint that has not been verified yet.Requirements on your endpoint
- HTTPS on port 443. Plain HTTP and non-standard ports are rejected when you register the URL.
- Publicly reachable. Private and loopback addresses are rejected. If your receiver sits behind an allowlist, allow
91.99.147.11, the address every delivery comes from. - No redirects. A
3xxcounts as a failed delivery, so register the final URL rather than one that forwards to it. - 2xx within ten seconds. Verify, record the event id, queue the work and answer. Fetching from the API inside the request is the usual cause of a timeout.
- Verify every request. Constant-time signature check, five-minute timestamp window, de-duplicate on the event id.
- A valid certificate. Self-signed and expired certificates fail the connection, and that counts towards the twenty.
- Verified before anything arrives. Only endpoints that are both active and verified receive account events.
Two limits apply to the portal actions rather than to deliveries: ten test or verification sends a minute and ten endpoint registrations an hour, both counted across your whole account. Going past either returns 429, and the portal shows the message it came back with.
Why events carry no data
It would be easy to put the transaction in the event, and we have deliberately not. A webhook is delivered to a URL, over a connection we do not control, to a server whose logs and error trackers we cannot see. Anything we put in the body may be written to disk somewhere neither of us intended. Sending only an account id and a timestamp means a leaked or misdirected event reveals that something happened, never what.
It also keeps one copy of the truth. The API applies your account permissions on every read; a body cached in a queue does not. By keeping the figures behind the authenticated read, a connection you revoke stops being readable straight away, and what you fetch is always current rather than whatever was true when the event was queued.
