View as Markdown

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:

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:

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"

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

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).