Docs
Outgoing webhooks (reference)

Outgoing webhooks (reference)

Tenant organizations can register **HTTPS endpoints** that receive **signed JSON POST** requests when certain things happen in Tenantx. Delivery runs through the queue (`DeliverOrganizationOutgoingWebhookJob`).

Outgoing webhooks (reference)

Tenant organizations can register HTTPS endpoints that receive signed JSON POST requests when certain things happen in Tenantx. Delivery runs through the queue (DeliverOrganizationOutgoingWebhookJob).

How-to guide (integrations, workspace events, extending the kernel): OUTGOING_WEBHOOKS_HOWTO.md


Permissions

ActionPermission
Listoutgoing_webhooks.read
Createoutgoing_webhooks.create
Updateoutgoing_webhooks.update
Deleteoutgoing_webhooks.delete

These permissions are organization-scoped (Spatie team = organization_id). The workspace-scoped admin role does not receive them by default (see PermissionSeeder::isSchoolAdminRestrictedPermission).

Subscription feature gate

Outgoing webhooks are also gated by the outgoing_webhooks subscription feature.

  • trial, starter, and pro: disabled by default
  • enterprise: enabled

Tenant routes are protected with feature:outgoing_webhooks, so a plan without the feature receives 402 Payment Required with feature_key: outgoing_webhooks even if the user has the permission row.

When subscription plan or feature addons change so the org loses outgoing_webhooks, existing rows are set to is_active: false automatically. The dispatcher and DeliverOrganizationOutgoingWebhookJob also no-op if the feature is off (including jobs already queued).


Tenant UI

  • Organization admin: /org-admin/webhooks (primary).
  • Legacy URL: /settings/webhooks redirects to /org-admin/webhooks when permitted.

API (authenticated tenant)

Base path: /api/outgoing-webhooks (same middleware stack as other org routes, plus feature:outgoing_webhooks; create/update also require subscription:write per api.php).

GET /api/outgoing-webhooks

Returns { "data": [ ... ] } — each row includes id, organization_id (implicit from context), workspace_id (null = organization-wide endpoint), url, event_types, description, is_active, timestamps. Secrets are never listed.

POST /api/outgoing-webhooks

{
  "url": "https://your-server.com/tenantx-hook",
  "description": "Optional label",
  "is_active": true,
  "workspace_id": null,
  "event_types": ["organization.registered", "invitation.accepted"],
  "secret": "optional-min-16-chars-if-you-set-it"
}
  • workspace_id: omit or null for organization-wide delivery. Set to a workspace UUID (workspace_branding.id) for a workspace-scoped endpoint (see HOWTO for delivery rules).
  • event_types: null or [] means all built-in event types; otherwise each entry must be in App\Support\OutgoingWebhookEvent::all() or "*".
  • secret: if omitted, server generates whsec_… and returns it once in the response.

PUT /api/outgoing-webhooks/{id}

Partial update. rotate_secret: true generates a new secret (returned once). You may set workspace_id to null to make an endpoint organization-wide again.

DELETE /api/outgoing-webhooks/{id}


Built-in event types

Defined in App\Support\OutgoingWebhookEvent:

EventWhen dispatched (kernel)
organization.registeredAfter public self-serve registration (RegisterController)
invitation.acceptedAfter a user accepts a team invite (InvitationController)
subscription.updatedAfter Stripe-driven subscription sync (StripeSubscriptionSyncService)

Workspace context: today’s kernel dispatches these with no workspace id (organization-level only). The JSON envelope includes workspace_id only when OutgoingWebhookDispatcher::dispatch(..., $eventWorkspaceId) is called with a non-null fourth argument (for your custom or future kernel events).


Delivery payload (JSON body)

{
  "id": "delivery-uuid",
  "occurred_at": "2026-03-25T12:00:00+00:00",
  "event": "invitation.accepted",
  "organization_id": "org-uuid",
  "workspace_id": "workspace-uuid",
  "data": {}
}
  • workspace_id is present only for workspace-tagged dispatches.
  • data is the arbitrary payload passed from PHP.

HTTP headers

HeaderValue
Content-Typeapplication/json
X-TenantX-EventSame as event in body
X-TenantX-DeliverySame as id in body (use for idempotency on your side)
X-TenantX-SignatureOptional if the stored secret is empty. Otherwise hex-encoded HMAC-SHA256 of the raw JSON body bytes (UTF-8), keyed with the webhook secret. Not prefixed with sha256=.

Verifying the signature (PHP)

$payload = file_get_contents('php://input');
$secret = 'whsec_...'; // from your store
$expected = hash_hmac('sha256', $payload, $secret);
$received = $_SERVER['HTTP_X_TENANTX_SIGNATURE'] ?? '';

if (! hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

Verifying the signature (Python)

import hmac
import hashlib

expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-TenantX-Signature", "")):
    return ("Unauthorized", 401)

Implementation notes

  • Dispatcher: App\Services\Webhooks\OutgoingWebhookDispatcher
  • Job: App\Jobs\DeliverOrganizationOutgoingWebhookJob
  • Model: App\Models\OrganizationOutgoingWebhook (workspace_id nullable → workspace_branding)

Run a queue worker in every environment that should deliver webhooks (php artisan queue:work or your supervisor config).


Development databases

If you previously created organization_outgoing_webhooks without workspace_id, either run php artisan migrate:fresh (destructive) or add the column manually to match the current create migration before running migrations again.