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
| Action | Permission |
|---|---|
| List | outgoing_webhooks.read |
| Create | outgoing_webhooks.create |
| Update | outgoing_webhooks.update |
| Delete | outgoing_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, andpro: disabled by defaultenterprise: 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/webhooksredirects to/org-admin/webhookswhen 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 ornullfor organization-wide delivery. Set to a workspace UUID (workspace_branding.id) for a workspace-scoped endpoint (see HOWTO for delivery rules).event_types:nullor[]means all built-in event types; otherwise each entry must be inApp\Support\OutgoingWebhookEvent::all()or"*".secret: if omitted, server generateswhsec_…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:
| Event | When dispatched (kernel) |
|---|---|
organization.registered | After public self-serve registration (RegisterController) |
invitation.accepted | After a user accepts a team invite (InvitationController) |
subscription.updated | After 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_idis present only for workspace-tagged dispatches.datais the arbitrary payload passed from PHP.
HTTP headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-TenantX-Event | Same as event in body |
X-TenantX-Delivery | Same as id in body (use for idempotency on your side) |
X-TenantX-Signature | Optional 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_idnullable →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.