Skip to content

Setting up a webhook subscription

Before you begin
  • A public HTTPS endpoint your code controls (the receiver)
  • Permissions to create webhook subscriptions in the tenant
  • A secret manager to store the signing secret

The simplest viable receiver returns 2xx as soon as it's received the event and processes asynchronously. This pattern is cheap and dependable.

// Node + Express
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'
const app = express()
app.post(
'/webhooks/intelliauth',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.header('X-IntelliAuth-Signature') ?? ''
const body = req.body as Buffer
const expected = createHmac('sha256', process.env.INTELLIAUTH_WEBHOOK_SECRET!)
.update(body)
.digest('hex')
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).json({ error: 'invalid_signature' })
}
// 2xx fast, do the work async.
res.status(202).end()
const event = JSON.parse(body.toString('utf8'))
queue.enqueue(event) // your background worker handles it
},
)
app.listen(3000)

A few non-negotiables:

  • Receive the body as raw bytes — JSON parsing must happen AFTER signature verification, because parsing canonicalises whitespace and the HMAC won't match.
  • Use timingSafeEqual — comparing strings character by character leaks timing information.
  • Return 2xx fast — the platform retries on >10 second responses.

See the signature verification topic for languages other than Node.

The endpoint must be reachable over HTTPS from the public internet. The platform refuses to deliver to:

  • HTTP (non-TLS) URLs.
  • RFC 1918 / link-local addresses.
  • localhost / 127.0.0.1.
  • Self-signed certificates (real CA certificates only — Let's Encrypt is free if you don't have one).

For local development, use a tunnel: ngrok, Cloudflare Tunnel, or a similar service that exposes your local server over HTTPS.

In the tenant admin console: Authentication → Webhooks → New subscription.

Fill in:

  • Name — for your own bookkeeping ("Production CRM sync", "Dev tunnel").
  • URL — your receiver, e.g. https://api.cymmetri.com/webhooks/intelliauth.
  • Events — pick the events you care about. You can subscribe to all events (*), specific event types (user.signed_up), or whole categories (user.*).
  • Description — optional, but useful when six teammates create six subscriptions and someone has to clean up.

Save. The console shows the signing secret — copy it into your secret manager IMMEDIATELY. The platform shows it once.

If you prefer to script this, use the webhooks API:

POST /api/v1/webhooks/subscriptions
Content-Type: application/json
Authorization: Bearer <token>
{
"name": "Production CRM sync",
"url": "https://api.cymmetri.com/webhooks/intelliauth",
"events": ["user.signed_up", "user.deleted"],
"description": "Push new users to Salesforce."
}

The response carries signing_secret exactly once.

Inject the signing secret from your secret manager as an environment variable. NEVER:

  • Commit it to source control.
  • Log it.
  • Display it in any UI accessible to less-trusted operators.

Rotating the secret is a config change on the subscription (the next topic covers rotation).

The console's "Test event" button sends a synthetic delivery to your receiver — useful for verifying connectivity and signature validation before real traffic.

The test event looks like a normal event but has data.test: true and event_type: "test.ping". Your receiver should accept and respond 2xx; if it fails, the console shows the response body so you can debug.

For a real-traffic smoke test, sign up a test user in the tenant. You should see the user.signed_up event arrive within a few seconds.

The platform sends every event you subscribed to. Your receiver should still validate:

  • That the tenant.id matches the one you expect (defends against cross-tenant misconfig).
  • That the event_type is one you actually handle (defends against future event types arriving and breaking your downstream).
  • That the event_id hasn't been processed (idempotency for retries).

These three checks are short, cheap, and catch a lot of real incidents.

The console exposes a per-subscription delivery feed: every recent attempt, success or failure, with the HTTP status the receiver returned. If you see a spike in non-2xx, your receiver is unhealthy — investigate before the events expire from the queue.

For long-running production use, also tail this feed from your monitoring system (alerts on >X% failures in 5m). The dashboard is a starting point, not a substitute for alerting.

  • Subscribing to * "to be safe". The platform will deliver every event, including the ones you don't care about, which is bandwidth + risk for no gain. Subscribe to what you'll actually act on.
  • Pointing a dev subscription at a production receiver. Dev signups arriving at production CRM is messy. Use distinct receivers per environment.
  • Forgetting to set the secret. The receiver accepts the first event, signature check fails, the event goes to retry, and you don't notice until the dead-letter queue alarms hours later.
  • Returning 5xx for application-level rejection. If your receiver decides "I don't want this event", return 2xx and drop it — 5xx triggers retries, which you don't want for a permanent rejection.