Webhooks
Instead of polling, let Helm Core OS call you. Subscribe an HTTPS endpoint to the events you care about and each one arrives signed, with automatic retries.
Set up a subscription
Webhook subscriptions are managed in Settings → API Suite → Webhooks by a user who manages the API Suite. Give it:
- an
https://URL — internal and private addresses are refused; - the event types to receive;
- optionally, custom headers, and an OAuth2 client if your endpoint wants a bearer token too (below).
You get a signing secret (whsec_…) when you create it — keep it to verify deliveries. Send test delivers a test.ping so you can check your endpoint before real events flow.
Events
| Event | When it fires | Payload |
|---|---|---|
user.created | A user is created. | data.user |
user.updated | A user's name, username, status, organisation or contact changes. | data.user |
user.deactivated | A user is deleted or deactivated. | data.user |
form.submitted | A form assigned to a user is completed. | data.submission |
onboarding.completed | A user finishes an onboarding flow (test runs excluded). | data.flow, data.user, data.completed_at |
deal.created | A deal is created. | data.deal |
deal.updated | A deal's name, status, currency, minimum investment or availability dates change. | data.deal |
document.uploaded | A document is added to a user's or a deal's folders. | data.document |
Payload objects are rendered by the same resources as the API — data.deal matches a deal from GET /deals/{dealId}, and data.user matches a user as the investor and internal-user lists return it — so your existing parsing code works on both.
The delivery
Each event is POSTed to your URL as JSON:
{
"id": "0b7e4a2c-5d1f-4c3b-9e8a-7f6d5c4b3a21",
"type": "deal.updated",
"created_at": "2026-06-12T10:00:00+00:00",
"data": {
"deal": {
"id": "8f2c…41ad",
"name": "Riverside Credit Fund",
"status": "published"
}
}
}
with these headers:
| Header | Value |
|---|---|
X-Delio-Event | The event type, e.g. deal.updated. |
X-Delio-Delivery | A unique id for this delivery attempt. |
X-Delio-Signature | sha256= + the HMAC-SHA256 of the raw body, keyed with your signing secret. |
The webhook headers keep their original X-Delio-* names, so receivers that
already verify them carry on working.
Respond with any 2xx within 10 seconds. Do the real work asynchronously — acknowledge first, then process — and use the event id to ignore duplicates.
Verify the signature
Always check the signature before trusting a delivery. Compute the HMAC over the raw request body — before any JSON parsing — and compare in constant time:
import { createHmac, timingSafeEqual } from 'node:crypto'
// app.post('/webhooks/helm', express.raw({ type: 'application/json' }), handler)
export function isValidSignature(rawBody: Buffer, header: string | undefined, secret: string) {
if (!header) return false
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(header)
return a.length === b.length && timingSafeEqual(a, b)
}Retries and failures
- A delivery that times out or gets a non-
2xxis retried up to 5 attempts, after 1 minute, 10 minutes, 1 hour and 6 hours. - If 20 events in a row fail after all their retries, the subscription is switched off automatically. Any
2xxresets the count. - Re-enable a switched-off subscription in Settings; that clears the failure count.
- Redeliver any past delivery from the delivery log. It starts a fresh set of attempts with the original event id and payload, so your duplicate check still works.
Deliveries can arrive out of order and, rarely, more than once. Use created_at to order and the event id to de-duplicate.
Authenticate to your endpoint
The signature is always sent and is how you verify Helm. If your endpoint also sits behind OAuth2, give the subscription a client id, client secret, token URL and optional scope. Helm then fetches a token with the client-credentials grant, caches it until shortly before it expires, and sends it as Authorization: Bearer … alongside the signature. The secret is encrypted at rest and never shown again.
If the token request fails, the delivery fails permanently rather than retrying, so check the token URL with Send test.
Good to know
- Events fire for changes made through the platform and this API. A handful of older platform screens write directly to the database, and changes made there may not raise an event — for a complete mirror, pair webhooks with a periodic sync using
filter[updated_at]. user.updatedcovers the user record itself; edits to a contact's first name, last name or email don't fire it.