FerrisKeyDocs

Use cases

These are patterns you can build today with the events FerrisKey raises. Two facts from Payload format shape every handler below. The object an event is about is in data, and resource_id is the realm for nearly every trigger. And each handler should check the signature before it acts, which the snippets leave out to stay short.

Slack notification on user registration

Notify your team in Slack when a new user signs up. Create a webhook subscribed to user.created, and point its endpoint at your backend, which formats the message and forwards it to Slack.

AdonisJS
// app/controllers/webhooks_controller.ts
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'

import { Slack } from '#services/slack'

@inject()
export default class WebhooksController {
  constructor(protected slack: Slack) {}

  async handle({ request, response }: HttpContext) {
    const { event, data, timestamp } = request.body()

    if (event === 'user.created') {
      await this.slack.postMessage('#new-users', {
        text: `New user registered: ${data.username} (${data.email}) at ${timestamp}`,
      })
    }

    return response.ok({ received: true })
  }
}
Axum
use axum::{Json, http::StatusCode};
use serde::Deserialize;

#[derive(Deserialize)]
struct WebhookPayload {
    event: String,
    timestamp: String,
    resource_id: String,
    data: Option<serde_json::Value>,
}

async fn handle_webhook(
    Json(payload): Json<WebhookPayload>,
) -> StatusCode {
    if payload.event == "user.created" {
        if let Some(data) = &payload.data {
            let username = data["username"].as_str().unwrap_or("unknown");
            let email = data["email"].as_str().unwrap_or("unknown");

            tracing::info!(
                "New user registered: {} ({}) at {}",
                username, email, payload.timestamp
            );

            // Send to Slack via reqwest
        }
    }

    StatusCode::OK
}

CRM sync

Keep your CRM (HubSpot, Salesforce, Pipedrive) in step with FerrisKey users. Subscribe to user.created, user.updated and user.deleted. For all three, data is the user, so data.id is the stable key to store in the CRM.

EventHandler logic
user.createdCreate a contact with the email, the name and the signup date
user.updatedUpdate the contact’s fields
user.deletedMark the contact as churned or archive it. data still holds the user as it was before deletion
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'

import { CrmService } from '#services/crm'

@inject()
export default class CrmSyncController {
  constructor(protected crmService: CrmService) {}

  async handle({ request, response }: HttpContext) {
    const { event, data } = request.body()

    switch (event) {
      case 'user.created':
        await this.crmService.createContact({
          externalId: data.id,
          email: data.email,
          name: `${data.firstname} ${data.lastname}`,
          signupDate: new Date(data.created_at),
        })
        break

      case 'user.updated':
        await this.crmService.updateContact(data.id, {
          email: data.email,
          name: `${data.firstname} ${data.lastname}`,
        })
        break

      case 'user.deleted':
        await this.crmService.archiveContact(data.id)
        break
    }

    return response.ok({ received: true })
  }
}

Batch updates

If your CRM has rate limits, queue webhook payloads and process them in batches rather than sending one API call per event. AdonisJS Jobs or Tokio tasks are good fits for this.

Remember that user.updated is also raised when a role is removed from a user. In that case data is the role, not the user, so check that data.username exists before you treat it as a profile change.

Alert on client and realm changes

Alert your security team when a client or the realm settings change outside scheduled maintenance. Subscribe to client.updated and realm.settings.updated. For client.updated, data is the client, and data.client_id is the readable name.

AdonisJS
// app/controllers/security_alert_controller.ts
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'

import { PagerDuty } from '#services/pagerduty'
import { isMaintenanceWindow } from '#utils/maintenance'

@inject()
export default class SecurityAlertController {
  constructor(protected pagerDuty: PagerDuty) {}

  async handle({ request, response }: HttpContext) {
    const { event, data } = request.body()

    if (event === 'client.updated' && !isMaintenanceWindow()) {
      await this.pagerDuty.triggerIncident({
        summary: `Client modified outside maintenance: ${data.client_id}`,
        severity: 'warning',
        source: 'ferriskey-webhooks',
      })
    }

    return response.ok({ received: true })
  }
}
Axum
use axum::{Json, http::StatusCode};

async fn security_alert(
    Json(payload): Json<WebhookPayload>,
) -> StatusCode {
    if payload.event == "client.updated" && !is_maintenance_window() {
        let client_id = payload
            .data
            .as_ref()
            .and_then(|data| data["client_id"].as_str())
            .unwrap_or("unknown");

        tracing::warn!("Client modified outside maintenance: {}", client_id);
        // Trigger PagerDuty incident via reqwest
        pagerduty::trigger_incident(
            &format!("Client modified outside maintenance: {}", client_id),
            "warning",
        ).await.ok();
    }

    StatusCode::OK
}

User provisioning in external systems

When a user is created in FerrisKey, provision them in other systems. Subscribe to user.created.

Receive the event

FerrisKey sends a user.created payload whose data holds the new user: username, email, names and id.

Create accounts in downstream systems

Your handler creates the accounts:

  • In the email system, a mailbox or an alias.
  • In project management, a seat in the default workspace.
  • In the cloud platform, an IAM identity.

Notify the user

Send a welcome email with links to everything that was provisioned.

import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'

import { MailService } from '#services/mail'
import { WorkspaceService } from '#services/workspace'

@inject()
export default class ProvisioningController {
  constructor(
    protected workspaceService: WorkspaceService,
    protected mailService: MailService
  ) {}

  async handle({ request, response }: HttpContext) {
    const { event, data } = request.body()

    if (event === 'user.created') {
      await Promise.all([
        this.workspaceService.addMember({
          email: data.email,
          name: `${data.firstname} ${data.lastname}`,
        }),
        this.mailService.createAlias(data.email),
      ])

      await this.mailService.sendWelcome(data.email, {
        username: data.username,
        firstname: data.firstname,
      })
    }

    return response.ok({ received: true })
  }
}

Role assignment audit

Keep a record of which roles are handed out. Subscribe to user.role.assigned. Its data is the role (id, name, permissions), and it does not say which user received it. To learn who did, look at the matching role_assigned event in SeaWatch, which names the user as its target.

import type { HttpContext } from '@adonisjs/core/http'

import { auditLog } from '#services/audit_log'

export default class RoleAuditController {
  async handle({ request, response }: HttpContext) {
    const { event, data, resource_id: realmId } = request.body()

    if (event === 'user.role.assigned') {
      await auditLog.record({
        realmId,
        roleId: data.id,
        roleName: data.name,
        permissions: data.permissions,
        receivedAt: new Date(),
      })
    }

    return response.ok({ received: true })
  }
}

Removal is not symmetrical. user.role.unassigned is never raised: taking a role away from a user raises user.updated, with the role as data.

Realm configuration audit

Track every change to realm settings for compliance. Subscribe to realm.settings.updated and realm.updated. Here resource_id is useful: it is the realm that changed.

use axum::{Json, http::StatusCode};
use chrono::Utc;

async fn audit_handler(
    Json(payload): Json<WebhookPayload>,
) -> StatusCode {
    let events = ["realm.settings.updated", "realm.updated"];

    if events.contains(&payload.event.as_str()) {
        let audit_entry = serde_json::json!({
            "event": payload.event,
            "realm_id": payload.resource_id,
            "timestamp": payload.timestamp,
            "received_at": Utc::now().to_rfc3339(),
            "data": payload.data,
        });

        // Persist to audit storage (S3, database, etc.)
        audit_store.append(audit_entry).await.ok();
    }

    StatusCode::OK
}

Several webhooks in one realm

For complex integrations, register several webhooks in the realm, each with its own endpoint and triggers:

WebhookTriggersEndpoint
slack-notificationsuser.created, user.deletedSlack proxy
crm-syncuser.created, user.updated, user.deletedCRM sync service
security-alertsclient.updated, realm.settings.updated, auth.reset_passwordPagerDuty proxy
audit-logEvery trigger you want keptS3 or GCS audit bucket

Each webhook is independent: a failure in the Slack webhook does not affect CRM sync or security alerts. Each has its own signing secret and its own retry policy, too.

In AdonisJS, register each handler as a separate route:

import router from '@adonisjs/core/services/router'

router.post('/webhooks/slack', '#controllers/webhooks/slack_controller.handle')
router.post('/webhooks/crm', '#controllers/webhooks/crm_sync_controller.handle')
router.post('/webhooks/security', '#controllers/webhooks/security_alert_controller.handle')
router.post('/webhooks/audit', '#controllers/webhooks/audit_controller.handle')