A scheduled pull is the right way to start an integration. It is not the right way to finish one. Once your reporting job is stable, the interesting question stops being how do I get the data and becomes how do I know something changed — because polling a valuation endpoint every fifteen minutes to catch a quarterly event is an expensive way to learn nothing.
Opagio answers that with outbound webhooks: an HTTPS POST from Opagio to a URL you control, sent when a qualifying action happens inside the platform. This guide is the companion to the intangible asset valuation API developer guide, which is a prerequisite — you will need an API key and the write:webhooks scope described there before anything here is actionable.
As with that guide, every claim below is verified against the shipped delivery code rather than a roadmap. Where a property does not exist, this guide says so plainly, because a webhook integration built on an assumed guarantee fails silently and at the worst possible moment.
★ Key Takeaway
Opagio webhooks are signed with HMAC-SHA256, delivered once with a ten-second timeout, and never retried. A subscription that fails ten consecutive deliveries is automatically disabled. Design your receiver around exactly-once-attempted, not at-least-once.
The delivery contract at a glance
| Property |
Value |
| Transport |
POST to your registered HTTPS URL |
| Content type |
application/json |
| Signature header |
X-Opagio-Signature — hex HMAC-SHA256 of the raw body |
| Event header |
X-Opagio-Event — the event name |
| Timeout |
10 seconds |
| Retries |
None — one attempt per event |
| Auto-disable |
After 10 consecutive failures |
Two design decisions sit behind the rest of this guide. First, dispatch is fire-and-forget: the platform action that triggers an event does not wait for your endpoint to answer, so a slow or broken receiver never blocks a user's request inside Opagio. Second, delivery is per-subscription and parallel, which means there is no ordering guarantee between subscriptions, and none between events either.
Step 1 — Register a subscription
Subscriptions are created through the Public API with a key holding the write:webhooks scope:
curl -s -X POST https://opag.io/api/v1/webhooks \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/opagio","events":["report.generated"]}'
The URL must begin with https:// — plain HTTP is rejected with a 400 and HTTPS URL is required. At least one event name is required. The submitted list is then filtered against the catalogue, and only recognised names are stored; if none survive the filter, the request fails with a 400 that names every valid event in the message.
That filtering is quiet rather than loud. Submit five event names of which two are misspelled, and you get a 201 with three events, not an error. The 201 response body echoes the events array that was actually stored, so compare it against what you sent rather than assuming your request was honoured verbatim.
⚠ Warning
The signing secret is a 64-character hex string returned once, in the creation response. It is stripped from every subsequent list response and there is no rotation endpoint. Store it in your secret manager at the moment of creation. To rotate it, delete the subscription and create a new one.
Subscriptions can also be managed inside the platform, under Settings → Webhooks, by a user with the SETTINGS_UPDATE permission. Both paths write the same underlying records, so a subscription created in the interface is visible and editable through the API and vice versa.
Step 2 — Verify the signature on every delivery
Each delivery carries a hex-encoded HMAC-SHA256 of the raw request body, keyed with your subscription secret, in the X-Opagio-Signature header. Recompute it and compare.
const crypto = require('crypto');
function verify(rawBody, headerSignature, secret) {
const expected = crypto.createHmac('sha256', secret)
.update(rawBody) // exact bytes as received
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(headerSignature || '', 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Two failure modes account for most broken verifications. The first is parsing the body before hashing it: a framework that runs a JSON body-parser ahead of your handler leaves you re-serialising an object, and the re-serialised bytes will not match the bytes that were signed. Capture the raw body. The second is a non-constant-time string comparison, which leaks timing information; use timingSafeEqual or your language's equivalent.
What the signature does and does not prove
| Property |
Status |
| The body was produced by a holder of your subscription secret |
Proven |
| The body was not modified in transit |
Proven |
| The delivery is recent |
Not proven by the signature — check the body's timestamp |
| The delivery is not a replay of an earlier one |
Not proven — no nonce or delivery ID is sent |
There is no delivery-ID header and no separate signed timestamp header. The envelope does carry an ISO 8601 timestamp field inside the signed body, so you can reject deliveries older than a window you choose — and because that field is inside the signed payload, it cannot be altered without invalidating the signature. That is the replay control available to you today. Build your own idempotency on top of the payload's own identifiers, such as reportId or companyId, rather than on a header Opagio does not send.
Step 3 — Know which events actually fire
The catalogue holds 26 subscribable event names. Only nine have an emitter wired behind them today. A subscription to any of the other seventeen is accepted, stored, and will never fire.
9
events with a live emitter
26
names accepted by a subscription
17
dispatch sites across the platform
The nine events that fire today
| Event |
Fires when |
Payload fields |
accounting.imported |
A general-ledger import completes |
companyId, valuationId, glUploadId, source |
report.generated |
A report is created in Report Studio |
reportId, type, title |
investor-report.generated |
An investor report is generated |
reportId, type, title |
portfolio.company.created |
A portfolio company is added |
company |
portfolio.company.updated |
A portfolio company is edited |
companyId, updates (changed field names) |
portfolio.company.deleted |
A portfolio company is removed |
companyId |
portfolio.mark.added |
A valuation mark is recorded |
companyId, mark |
portfolio.bulk_import |
A bulk portfolio import finishes |
imported, failed |
prediction.impairment.triggered |
An impairment test returns an impaired result |
assetId, assetName, framework, result |
Note that portfolio.company.updated never sends the changed values — only the names of the fields that changed. If you need the new values, treat the event as a signal and fetch the record from GET /portfolio/companies/:id.
ℹ Note
The shorter name impairment.triggered was removed from the catalogue on 3 September 2026 — it never had an emitter, and prediction.impairment.triggered is the only name for this event.
The remaining seventeen names — every valuation.* event, portfolio.updated, company.added, company.updated, portfolio.imported, the intelligence.* family, the rest of prediction.*, the automation.* family and benchmark.dynamic.computed — are declared for future use. They are placeholders, not capabilities, and should not appear in an integration you are relying on.
Step 4 — Handle the payload envelope
Every delivery uses the same three-field envelope, regardless of event:
{
"event": "report.generated",
"timestamp": "2026-09-03T10:00:00.000Z",
"data": { "reportId": "…", "type": "…", "title": "…" }
}
The event name appears in both the header and the body. Route on either, but verify the signature before you trust either — the header is not signed independently of the body, so a receiver that dispatches on X-Opagio-Event before verification is acting on unauthenticated input.
Only subscriptions that are active and whose stored events array includes the event name receive a delivery. Everything else is filtered out before any HTTP request is made.
ℹ Note
Return a 2xx status as quickly as you can, and do the work afterwards. Success is judged purely on your response status: any non-2xx response, and any connection error or timeout, counts as a failure. A receiver that performs a slow database write before responding is converting its own latency into Opagio-side failures.
Step 5 — Design for the failure behaviour
This is the section that determines whether your integration is reliable, because the failure model is stricter than most webhook implementations developers will have met.
| Behaviour |
Detail |
| Timeout |
10 seconds, then the attempt is abandoned |
| Retry |
None. A failed delivery is not resent |
| Failure counter |
Increments on every failed delivery |
| Reset |
A single success resets the counter to zero |
| Auto-disable |
At 10 consecutive failures the subscription is set inactive |
| Logging |
Every attempt is recorded with status, response body and outcome |
Because the counter resets on success, the ten failures must be consecutive. An endpoint that fails intermittently will stay enabled indefinitely and quietly lose the events it missed. An endpoint that goes down for a sustained period will be disabled and will then miss everything until someone re-enables it — nothing re-enables it automatically.
✔ Example
A receiver behind a certificate that expires on a Friday evening fails its next ten deliveries and is disabled by Saturday. Renewing the certificate on Monday restores the endpoint but not the subscription. The subscription must be set active again through PUT /webhooks/:id, and the ten events lost over the weekend are not recoverable from the webhook path — they have to be reconciled by reading the API.
That last sentence is the whole argument for the integration pattern recommended at the end of this guide. Webhooks tell you when; a periodic read tells you what is true. An integration with only the first has no way to heal itself.
Every attempt is written to a delivery log that records the event, the payload, the HTTP status your endpoint returned, and the first 1,000 characters of your response body. That truncated body is genuinely useful when diagnosing a failing receiver — and it is a reason to keep error responses free of anything you would not want stored, since your response is retained on the Opagio side as part of the delivery audit trail.
Step 6 — Manage subscriptions over their lifetime
| Operation |
Endpoint |
Scope required |
| List |
GET /webhooks |
read:webhooks or write:webhooks |
| Create |
POST /webhooks |
write:webhooks |
| Update |
PUT /webhooks/:id |
write:webhooks |
| Delete |
DELETE /webhooks/:id |
write:webhooks |
The list endpoint returns your organisation's subscriptions newest first, with the secret removed from every record. A subscription belonging to a different organisation returns 404 rather than 403 on update or delete, consistent with the rest of the API: another tenant's resource is reported as absent, never as forbidden.
PUT /webhooks/:id accepts url, events and active, each optional. Sending {"active": true} is how you re-enable a subscription that auto-disabled. A url supplied here is held to the same HTTPS requirement as at creation.
ℹ Note
Event names are validated identically on both paths. At creation and on update alike, names that match no event in the catalogue are filtered out, and a request whose events array survives that filter empty is rejected with 400 rather than stored — so a typo introduced during an update is dropped, never written. The in-platform Settings → Webhooks path applies the same filter. The filter is silent about what it removed, so a partly mistyped array is accepted with the valid names only: read the subscription back with GET /webhooks after any update and confirm the events you expect are the events that are stored.
What Opagio webhooks do not do
Being precise about absence is more useful than an aspirational feature list.
- No retries or backoff. One attempt per event, full stop.
- No delivery ID header and no replay cache. Idempotency is yours to build, from payload identifiers.
- No event replay or backfill endpoint. Events missed while a subscription was inactive cannot be requested again.
- No delivery history endpoint. Attempts are logged internally but there is no API to read that log.
- No ordering guarantee, either between subscriptions or between events.
- No IP allow-list to pin. The signature is the authentication mechanism, not the source address.
★ Key Takeaway
Treat a webhook as a hint that something changed, and the API as the authority on what it changed to. Integrations built that way degrade gracefully when a delivery is missed. Integrations that treat the payload as the system of record do not.
A sensible first integration
Take the scheduled pull described in the Public API guide and keep it. Then add a subscription to the two or three events that genuinely matter to your workflow — for most finance teams that is report.generated, accounting.imported and, for investors running a portfolio, portfolio.mark.added.
Have the receiver do three things and nothing more: verify the signature, record that the event arrived, and trigger the same read job the scheduler already runs. The webhook becomes a latency improvement on a mechanism that already works, rather than a new dependency. If a delivery is missed, the next scheduled run closes the gap without anyone noticing.
Keep the raw signed body alongside your parsed record. When a figure in a board pack is later questioned, the ability to show exactly what arrived and when — and that it verified against the secret — is the difference between an answer and an argument.
Related reading