Tenant Admin
Webhook Configuration
Self-serve webhook setup for tenant admins — configure callback URL, signing secret, subscribed events, and retention policy from the admin console or REST API.
Tenants control their own webhook endpoint and signing secret. This page covers the tenant-admin UI flow at /admin/tenants and the equivalent REST endpoints for programmatic management.
Prerequisites
| Requirement | Detail |
|---|---|
| Permission | tenant:read (view) / tenant:update (create, modify, delete) |
| Receiver | Public HTTPS endpoint that returns 200–299 within ~5 seconds |
| Authentication on receiver | Verify X-Webhook-Signature using HMAC-SHA256 |
UI walkthrough
Configure your webhook
| Step | Action |
|---|---|
| 1 | Sign in at /login → tenant admin tab |
| 2 | Open Tenants in the sidebar (admin zone) |
| 3 | Click the webhook icon on your tenant row |
| 4 | Fill the callbackUrl — must be public HTTPS |
| 5 | Leave Signing Secret blank to auto-generate, or paste your own (min 32 chars) |
| 6 | Choose subscribed events (deselect all = receive everything) |
| 7 | Toggle Auto-approve Plans if you want to skip the human review state |
| 8 | Adjust retention windows (PII / assessment / recording) |
| 9 | Save — secret hint (****<last4>) shows in the confirmation banner |
Verify delivery
After saving, click Send Test Webhook. The dialog fires a synthetic interview.plan_generated event signed with your secret and shows the delivery result inline. Use this to confirm your receiver verifies signatures correctly before going live.
Rotate the secret
Open the dialog → enter a new value in Signing Secret → Update. Leave blank on subsequent saves to keep the current secret. Always update your receiver in the same change window.
API reference
Endpoints — self-serve (no :id)
OAuth tenant tokens and tenant Admin login JWTs already carry a single tenant scope. Use the /me/webhook-config routes — the tenant is resolved from the authenticated identity, and no :id is required or accepted.
| Method | Endpoint | Permission / scope |
|---|---|---|
| GET | /api/v1/me/webhook-config | tenant:read OR webhook:read (OAuth) |
| PUT | /api/v1/me/webhook-config | tenant:update OR webhook:update (OAuth) |
| DELETE | /api/v1/me/webhook-config | tenant:update OR webhook:update (OAuth) |
webhook:read / webhook:update scope manage their own tenant's config via these routes without ever templating their tenantId into the URL. Use these in preference to /tenants/:id/webhook-config.Endpoints — cross-tenant (super-admin / platform-admin)
Callers that operate across tenants (super-admin globally, platform-admin within their platform) need to name the target tenant, so they use the :id-bearing route. Tenant Admins and OAuth tenant tokens are accepted here too for backwards compatibility, but the backend forces URL :id to equal the caller's own tenantId — making it identical in effect to /me/webhook-config.
| Method | Endpoint | Permission / scope |
|---|---|---|
| GET | /api/v1/tenants/:id/webhook-config | tenant:read OR webhook:read (OAuth) |
| PUT | /api/v1/tenants/:id/webhook-config | tenant:update OR webhook:update (OAuth) |
| DELETE | /api/v1/tenants/:id/webhook-config | tenant:update OR webhook:update (OAuth) |
Why two routes (and which to use)
An OAuth tenant token already binds the caller to a single tenant, so the URL :id on /tenants/:id/webhook-config is redundant for tenant callers. That's why the dedicated /me/webhook-config route exists — OAuth and tenant Admin clients use it without any path parameter. The :id-bearing route is kept for callers that are not single-tenant principals: super-admin operates across all platforms, and platform-admin operates across the tenants of one platform. The backend applies the scoping rule that matches the caller's identity:
| Caller | Rule applied to URL :id |
|---|---|
| super-admin | Any tenant id is accepted |
| platform-admin | Tenant must belong to the caller's platform (else 403) |
| Tenant Admin login JWT | URL :id must equal the user's tenantId (else 403) |
| API key + X-Tenant-ID | URL :id must equal X-Tenant-ID (else 403) |
| OAuth tenant token / client_credentials with tenant_id claim | URL :id must equal the token tenant_id claim (else 403) |
tenant:read or tenant:update permission alone is not enough — the URL :id is also checked against the caller's identity. A tenant token cannot read another tenant's webhook config even if both tenants belong to the same platform.Why the URL still carries :id
An OAuth tenant token already binds the caller to a single tenant, so the URL :id looks redundant. It is kept because the same route is also used by callers that are not single-tenant principals — super-admin operates across all platforms, and platform-admin operates across the tenants of one platform. Rather than splitting into multiple routes, one route serves all callers and the backend applies the scoping rule that matches the caller's identity:
| Caller | Rule applied to URL :id |
|---|---|
| super-admin | Any tenant id is accepted |
| platform-admin | Tenant must belong to the caller's platform (else 403) |
| Tenant Admin login JWT | URL :id must equal the user's tenantId (else 403) |
| API key + X-Tenant-ID | URL :id must equal X-Tenant-ID (else 403) |
| OAuth tenant token / client_credentials with tenant_id claim | URL :id must equal the token tenant_id claim (else 403) |
tenant:read or tenant:update permission alone is not enough — the URL :id is also checked against the caller's identity. A tenant token cannot read another tenant's webhook config even if both tenants belong to the same platform.Read current config
curl -X GET https://mayaapi.teamcast.ai/api/v1/me/webhook-config \
-H "Authorization: Bearer <TENANT_JWT_OR_OAUTH_TOKEN>"{
"id": "4dd294e5-4fed-4c35-a3cd-2b89cb70fb81",
"tenantId": "c2650bb0-49b5-438a-b4d0-f9049ccb9f8a",
"callbackUrl": "https://api.your-system.com/webhooks/teamcast",
"secretHint": "****ed0e",
"isActive": true,
"events": [],
"autoApprovePlans": false,
"candidatePiiRetentionDays": 90,
"assessmentRetentionDays": 365,
"recordingRetentionDays": 30,
"createdAt": "2026-04-09T15:12:24.369Z",
"updatedAt": "2026-05-13T09:56:14.449Z"
}Create or update
curl -X PUT https://mayaapi.teamcast.ai/api/v1/me/webhook-config \
-H "Authorization: Bearer <TENANT_JWT_OR_OAUTH_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"callbackUrl": "https://api.your-system.com/webhooks/teamcast",
"events": [
"interview.plan_generated",
"interview.approved",
"interview.rejected",
"interview.assessment_pending"
],
"autoApprovePlans": false,
"candidatePiiRetentionDays": 90,
"assessmentRetentionDays": 365,
"recordingRetentionDays": 30
}'Omit secret to keep the current value (or auto-generate one on first create). Provide a value (min 32 chars) to rotate.
Delete
curl -X DELETE https://mayaapi.teamcast.ai/api/v1/me/webhook-config \
-H "Authorization: Bearer <TENANT_JWT_OR_OAUTH_TOKEN>"Returns 204 No Content. The tenant will then fall through to the platform-level webhook (if configured) — or no delivery at all.
A2A integration pattern
When an external system calls POST /api/v1/integration/interviews it can either:
| Approach | Use case | How |
|---|---|---|
| Pre-register one webhook URL | Recommended for production. Receive every event for every interview at one place. | Configure once via PUT /me/webhook-config |
| Per-interview callbackUrl | One-off interviews routed to a different system. | Pass callbackUrl in the create-interview request body |
| Both | Per-request override wins; falls back to tenant config when missing. | Set tenant config + optionally pass callbackUrl per request |
WEBHOOK_SECRET environment variable for HMAC signing — not the tenant's secret. If you mix the two approaches your receiver must accept both signatures or always use the tenant config.Receiver checklist
| Check | Why |
|---|---|
| Validate X-Webhook-Signature with HMAC-SHA256 | Reject forged events |
| Reject timestamps older than 5 minutes | Prevent replay attacks |
| Respond 200 within 5s | Long delays trigger retries; process async on a queue |
| Be idempotent on runId + event | Maya retries with exponential backoff on 5xx |
| Log unknown event types instead of 500ing | New events may be added; keep deliveries flowing |
// Reference verifier — Express + raw body
import crypto from 'crypto';
import express from 'express';
const app = express();
app.use(express.raw({ type: 'application/json' }));
app.post('/webhooks/teamcast', (req, res) => {
const sig = req.header('X-Webhook-Signature') ?? '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.TEAMCAST_WEBHOOK_SECRET!)
.update(req.body)
.digest('hex');
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).json({ error: 'invalid signature' });
}
const payload = JSON.parse(req.body.toString());
// ... enqueue payload for async processing ...
res.json({ ok: true });
});Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
| 401 in Maya logs after retries exhausted | Receiver rejected signature | Compare your verifier secret with the tenant config; rotate if drifted |
| No webhook ever fires | No config registered, or platform-level fallback missing | GET /tenants/:id/webhook-config — 404 means none; use the test endpoint to confirm |
| Some events delivered, others missing | events[] array filters out some types | Set events to [] (all) or add the missing types |
| Receiver gets 5xx in Maya retries | callbackUrl rejected by SSRF guard | URL must be public HTTPS — no private/loopback/metadata addresses |
| Events arrive but unauthorized in receiver | Per-interview callbackUrl uses global WEBHOOK_SECRET, not tenant secret | Either drop the per-request override or verify both possible secrets |