Skip to main content

Webhooks

A webhook delivers the event stream to your endpoint within a few minutes of each event, so you don't have to poll GET/event. Each delivery is one event, exactly as GET /event returns it, signed so you can check it came from TreasurySpring.

Registering an endpoint​

PUT/webhook creates your webhook, or replaces its URL and types:

{
"url": "https://example.com/webhooks/treasuryspring",
"types": ["EVENT"]
}
  • The first call returns 201 with a secret. It's returned only this once, so store it securely: you need it to verify deliveries. Later calls return 200 and no secret.
  • The URL must be HTTPS on a public hostname, not an IP address. An international domain name must be given in its ASCII form, starting xn--. We also check where the hostname resolves on every delivery, and never send to private or internal addresses.
  • Don't put credentials in the URL. To check a delivery came from us, verify its signature (see below).
  • GET/webhook shows your webhook and whether each type is being delivered; DELETE/webhook removes it and discards the secret. To change the secret, rotate it.
  • One webhook for each identity that calls the API: one per client_id with client credentials, and one per authorising user with code authorisation. Accounts that can see every client's data can't register one.

Delivery starts from the moment you register: earlier events aren't sent. Use GET /event for anything before that.

What we send​

Each event is a POST with a JSON body:

{
"type": "event.cash_moved",
"data": {
"uid": "0190f7a2c3b44e1a9d2e5f6a7b8c9d0e",
"cursor": "ev1.abc123.xyz789",
"entityCode": "TEST001",
"eventType": "CASH_MOVED",
"currency": "GBP",
"holdingUid": "5f2b9c1e8a7d4e3f9b6a1c0d2e4f6a8b",
"amount": "-5000.00"
}
}

type is event. followed by the lower-cased eventType. data is the event exactly as GET /event returns it, so the same code can handle both.

The request carries these headers, following the Standard Webhooks specification:

HeaderValue
webhook-idThe event's uid. It is the same on every retry, so use it to ignore duplicates.
webhook-timestampWhen this delivery attempt was sent, in Unix seconds. A retry has a later timestamp; it is not when the event happened.
webhook-signaturev1, followed by the signature. While a secret rotation is in progress it holds two, space-separated, one from each secret: accept the request if either matches.

Verifying a delivery​

Check the signature against the raw request body, before parsing it, and reject the request if it doesn't match. The signature is an HMAC-SHA256, keyed with your secret (base64-decoded, after the whsec_ prefix), of {webhook-id}.{webhook-timestamp}.{body}.

import base64
import hashlib
import hmac
import time


def verify(secret: str, headers: dict, body: bytes, tolerance_seconds: int = 300) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
# Rejecting old timestamps stops a captured request being replayed later.
if abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
hmac.compare_digest(expected, candidate.removeprefix("v1,"))
for candidate in headers["webhook-signature"].split(" ")
if candidate.startswith("v1,")
)

The Standard Webhooks libraries do the same in most languages.

Rotating the secret​

POST/webhook/secret/rotate replaces your secret without missing an event or failing a delivery:

  • It returns the new secret, once. Store it securely.
  • For the next 24 hours, each delivery is signed with both secrets, so it verifies with either, and you can switch at your own pace. GET /webhook shows when that ends, as previousSecretExpiresAt; after that, only the new secret signs.
  • Rotating again within those 24 hours stops the oldest secret being used straight away: only the one just replaced still signs.

Responding​

  • Any 2xx within 10 seconds counts as delivered. Respond quickly and process the event afterwards.
  • Anything else is a failure, including redirects (we don't follow them) and timeouts.
  • 410 Gone stops delivery straight away.

Ordering, retries and duplicates​

  • Events arrive in order, one at a time. The next event is only sent once the previous one was delivered.
  • A failure pauses delivery and retries it, after 5 seconds, 30 seconds, 2 minutes, 10 minutes, 30 minutes, then hourly.
  • After 24 hours of failures, delivery is disabled. GET /webhook shows it as DISABLED with reason FAILING. Once your endpoint is fixed, send PUT /webhook again: delivery resumes from where it stopped, so no event is skipped.
  • An event can arrive more than once, for example if your response was lost. Ignore any webhook-id you have already processed.

Catching up​

Every delivered event has a cursor. To fill a gap, or to read what happened before you registered, call GET /event with start_cursor set to the last cursor you processed.