Webhooks
A webhook sends a signed event to an HTTPS endpoint of yours when something happens in Retorio, so your system does not have to poll. The event available is coaching.result.available: a coaching user’s result is ready. It carries the user, the cohort, the program, the scenario, the session times and the overall score. Its exact shape is in the callbacks of POST /organization_webhooks.
Set one up
Section titled “Set one up”Webhooks are set up on the Integrations page of the admin app, or through the webhook endpoints, by an Owner or an admin whose role allows managing webhooks. The Admin role does not include it. The Integrations page shows the exact body and headers your endpoint receives.
| Rule | Value |
|---|---|
| Webhooks per organization | 5, disabled ones included |
| URL | https, at most 2048 characters, with no credentials in it |
| Custom headers | Up to 10, for your own credential, for example a header named Authorization. Values are stored encrypted and never returned once saved. Names are returned |
| Header names | Unique, ignoring case. Not Content-Type, Content-Length, Content-Encoding, User-Agent, Host, Expect, a hop-by-hop name such as Connection or Proxy-Authorization, and not starting with X-Retorio- |
| Header values | At most 4096 characters of printable ASCII or tab each, and at most 8192 bytes for all custom headers together, counting each as <name>: <value> plus a line break |
These are checked when the webhook is saved. A host that resolves to a private or otherwise non-routable address is accepted when saved and fails at delivery: the host is checked before every send.
What your endpoint must do
Section titled “What your endpoint must do”- Accept a
POSTwith a JSON body over HTTPS, at a URL that does not redirect. Redirects are not followed. - Answer with a
2xxwithin 10 seconds, then do the work. A slower answer counts as a failure. - Check the signature (below).
- Skip duplicates. Delivery is at least once. Store
X-Retorio-Delivery-Idand ignore one you have already processed: a retry carries the identical body and id. Rarely, one result arrives as two events with different delivery ids. If a result must be processed only once, also skip adata.session.extIdyou have already seen.
| Your answer | What Retorio does |
|---|---|
2xx |
Delivered |
5xx, 408, 429, or no answer |
Tries again, up to 8 attempts in total spread over about a day, then gives up |
Any other 4xx, or a redirect |
Gives up at once. Answer 4xx only for a request you will never accept |
Each retry uses the webhook’s current URL, custom headers and signing secret.
Signature
Section titled “Signature”Every delivery carries two headers:
| Header | Value |
|---|---|
X-Retorio-Timestamp |
Unix seconds at send time, fresh on every attempt |
X-Retorio-Signature |
v1, followed by a base64 HMAC-SHA256 over <timestamp>.<raw body> |
To verify:
- Take the signing secret exactly as Retorio returned it,
whsec_prefix included, as UTF-8 bytes. Do not strip the prefix or base64-decode it. - Compute the HMAC-SHA256 over the
X-Retorio-Timestampvalue, a dot and the raw request body, exactly as received, before any JSON parsing. - Compare it with the signature using a constant-time comparison.
- Reject a timestamp more than a few minutes old.
The format is Retorio’s own, so verify it with your own HMAC code rather than a generic webhook verification library.
const crypto = require('crypto')
// rawBody must be the unparsed request body, exactly as it arrived.const timestamp = request.headers['x-retorio-timestamp']const signature = request.headers['x-retorio-signature']const [version, digest] = typeof signature === 'string' ? signature.split(',', 2) : []
// Reject anything older than five minutes, so a captured request cannot be replayed later.const sentAt = Number(timestamp)const isFresh = Number.isSafeInteger(sentAt) && Math.abs(Math.floor(Date.now() / 1000) - sentAt) <= 300
const expected = crypto .createHmac('sha256', signingSecret) .update(`${timestamp}.${rawBody}`) .digest('base64')
const received = version === 'v1' && digest ? Buffer.from(digest, 'base64') : Buffer.alloc(0)const computed = Buffer.from(expected, 'base64')
const isValid = isFresh && received.length === computed.length && crypto.timingSafeEqual(received, computed)The signing secret is shown once, when the webhook is created. Replacing it shows the new one once, and the old one stops working at that moment. Through the API, the secret is returned only by the create call and by an update that rotates it.
Timing
Section titled “Timing”A result is first sent at least 5.5 minutes after it is ready, so that its session times are final, then at the next delivery run.
Disable, delete and test
Section titled “Disable, delete and test”| Action | Effect |
|---|---|
| Disable | Events that happen while the webhook is disabled are never sent, not even after you enable it again. Events still waiting, retries included, are called off when they come due, unless you enable the webhook before then. A called-off delivery is never resent |
| Delete | Calls off whatever has not gone out. A request already on its way may still arrive |
| Send test event | Sends a webhook.test event at once, even while the webhook is disabled. It is never retried |
Source addresses
Section titled “Source addresses”Deliveries come from no fixed IP range. Authenticate them by their signature or by a header of your own, not by source address.
Retorio is GDPR and DSGVO compliant, EU AI Act aligned, ISO 27001 certified, and hosted on Google Cloud with EU data residency. For anything specific to your rollout, ask your Retorio contact.