Subscribe to webhooks
1. Register an endpoint
curl -X POST https://api.exayard.com/v1/webhook_endpoints \
-H "Authorization: Bearer $EXAYARD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/hooks/exayard",
"events": ["assessment.completed", "estimate.generated", "bid.generated"]
}'
Or on the Developer page: Webhooks → New webhook. The URL must be public; https:// is best, and http:// is
accepted. Pick event types from the event catalog, or * for every event.
The response includes the signing secret (whsec_...). It is shown once, so store it. If you lose it, Rotate
secret on the Developer page makes a new one, and the old one stops signing at once.
Two kinds of caller can manage a company's endpoints: an admin of the company, or a key set to work in This
company with write:webhooks. Everyone else is refused. That includes members who are not admins, keys set to work
in every company that installed the app, and apps signed in for a person. An app working in a company that installed
it has its own endpoint instead.
2. Read a delivery
Each delivery is a POST with a JSON body:
{
"id": "evt_8fk2q9x4mw1p8c3n6d5rs0tuv2bjh4ye",
"type": "assessment.completed",
"created": 1759651200000,
"organizationId": "js79m7b3kfqd3vqzwx0kpcnryx7h2k4e",
"data": { "assessmentId": "as2k7m4p9x1d6rs3tuvw8abc", "projectId": "kn7d9x2p4m1q8rs6tuvw3xyz" }
}
organizationId is the company the event came from. It is also sent in the Exayard-Organization-Id header, so you
can route a delivery before you parse it. One receiver address can serve many companies: register the same URL in each
company and route on organizationId. See Webhook events for every type and its fields.
3. Verify signatures
Every delivery carries these headers:
Exayard-Signature: t=<unix seconds>,v1=<base64 HMAC-SHA256>Exayard-Event-IdandExayard-Event-TypeExayard-Organization-IdUser-Agent: Exayard-Webhooks/1.0
Compute HMAC-SHA256 over ${t}.${rawBody} with the endpoint's secret, base64-encode it and compare it with v1.
Reject a delivery whose t is more than 5 minutes from now. Each endpoint has its own secret, so when one address
serves many companies, pick the secret by Exayard-Organization-Id before you verify. The signature covers the whole
body, organizationId included.
The SDK does this for you. constructWebhookEvent is async, so await it, and pass the raw body exactly as received:
import express from 'express'
import { constructWebhookEvent, WebhookSignatureError } from '@exayard/sdk'
const app = express()
app.post('/hooks/exayard', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.header('exayard-signature') ?? ''
try {
const event = await constructWebhookEvent(
req.body.toString('utf8'),
signature,
process.env.EXAYARD_WEBHOOK_SECRET!
)
switch (event.type) {
case 'assessment.completed':
// ...
break
}
res.sendStatus(200)
} catch (err) {
if (err instanceof WebhookSignatureError) {
res.status(400).send(err.code)
return
}
throw err
}
})
err.code is invalid_signature, replay_window_exceeded or malformed_header. exa.webhooks.constructEvent is
the same function. A fourth argument, { toleranceSeconds }, changes the 5-minute window.
4. Handle retries
Answer with any 2xx. Anything else, a redirect, a timeout or a network error counts as a failure. Redirects are not followed.
A failed delivery is tried again. There are 10 attempts in all: at once, then after 15 seconds, 1 minute, 5
minutes, 30 minutes, 2 hours, 6 hours, 24 hours, 24 hours and 24 hours, each with some jitter. That is about 80 hours.
After the 10th attempt the delivery is marked failed.
Every attempt sends the same body and the same event id. Use Exayard-Event-Id to ignore a delivery you already
handled.
Send a test event
To try your receiver, send an event of any type with sample values:
curl -X POST https://api.exayard.com/v1/webhook_endpoints/<endpoint id>/test \
-H "Authorization: Bearer $EXAYARD_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "assessment.completed" }'
The answer is { "deliveryId": "...", "eventId": "evt_test_..." }. A test event is signed like any other, has
"test": true at the top level of its body, and is sent once with no retries. The endpoint must be active. On the
Developer page, use Send test event in the endpoint's menu.
Deliveries
GET /v1/webhook_endpoints/{id}/deliveries lists an endpoint's deliveries, newest first, 50 by default (limit 1 to
200). On the Developer page, use Deliveries in the endpoint's menu. Each delivery has:
status:pending,retrying,successorfailed.attempts,responseStatus, andresponseBody(cut to 2,048 characters).failureReasonwhen it failed:max_attempts_exhausted,endpoint_missing_or_paused,sent_once(a test or a resend that failed), and for an app's endpointinstall_removed,app_inactiveorscope_not_granted.test: true for a test event.resendOf: the delivery it resends, for a resend.
Resend a delivery
curl -X POST https://api.exayard.com/v1/webhook_deliveries/<delivery id>/resend \
-H "Authorization: Bearer $EXAYARD_API_KEY"
A resend is a new delivery with the same event id and body. It is sent once, with no retries, and names the earlier
delivery in resendOf. The endpoint must be active. On the Developer page, use Resend on a delivery.
Test, deliveries' resend and the listener calls below are on the REST API only, not the MCP server.
Forward events to your laptop: exayard listen
Your laptop has no public address, so the CLI (0.5.0 or later) fetches your company's events and forwards them:
exayard listen --forward-to http://localhost:3000/hooks/exayard --events "bid.won,project.created"
- It prints a
whsec_...secret at the start. Deliveries it forwards are signed with it, so your verification code runs unchanged. - Without
--forward-toit prints each event instead. --eventspicks event types; leave it out for every event.--namelabels the session.- Ctrl-C stops it.
The same callers can listen: an admin of the company, or a key set to work in This company with
write:webhooks. A key set to work in every company that installed the app cannot.
A company can have 10 listeners at once. A listener that nobody reads for an hour is removed.
The SDK can do the same: exa.webhooks.listeners.start({ events }), then for await over
exa.webhooks.listeners.events(id), and exa.webhooks.listeners.stop(id) at the end.
Webhooks for an app
An app can have one endpoint of its own that serves every company that installed it. You don't register an endpoint in each company.
Set it up
An admin of your company opens Settings → Developer → Apps, then the app's More actions → Webhook, and enters
a public address. The signing secret (whsec_...) is shown once. The same dialog pauses and resumes the endpoint,
changes its address, rotates the secret (the old one stops signing at once) and removes it.
What it receives
- Events from each company that installed the app, but only those the install's scopes can read. See Events for an app.
- Events about your app's installs:
app.installed: a company installed the app, or installed it again.app.scopes_approved: a company's admin approved new access.app.uninstalled: a company removed the app, or it was removed for another reason (reason:admin_removed,company_deletedorpartner_released).
organizationId and the Exayard-Organization-Id header name the company the event is about, and the signature covers
them, so route on organizationId after you verify. One secret signs deliveries for every company.
An install event's data:
{
"installationId": "k57abc...",
"app": { "id": "app_k3k3k3k3k3k3k3k3k3k3", "name": "Nightly Sync" },
"grantedScopes": ["read:projects"],
"scopesVersion": 2,
"origin": "consent"
}
origin is consent (an admin installed it), owner_company (your own company) or partner_owned (an
account for your customer). app.uninstalled sends grantedScopes: [] and adds
reason.
When deliveries stop
Before every attempt, including retries, Exayard checks again. A delivery is marked failed without being sent when
the company removed the app (install_removed), the install no longer grants the event's scope
(scope_not_granted), or the app is suspended or deleted (app_inactive).