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.

For payload structure, HMAC verification, and retry policy see the Integration API → Webhooks reference. Platform Admins can also manage tenant webhooks on behalf of customers — see Platform Admin → Webhook Configuration.

Prerequisites

RequirementDetail
Permissiontenant:read (view) / tenant:update (create, modify, delete)
ReceiverPublic HTTPS endpoint that returns 200–299 within ~5 seconds
Authentication on receiverVerify X-Webhook-Signature using HMAC-SHA256
Private, loopback, and cloud-metadata addresses are rejected (SSRF protection). The callback URL must resolve to a public IP. Use a public tunnel during local development.

UI walkthrough

Configure your webhook

StepAction
1Sign in at /login → tenant admin tab
2Open Tenants in the sidebar (admin zone)
3Click the webhook icon on your tenant row
4Fill the callbackUrl — must be public HTTPS
5Leave Signing Secret blank to auto-generate, or paste your own (min 32 chars)
6Choose subscribed events (deselect all = receive everything)
7Toggle Auto-approve Plans if you want to skip the human review state
8Adjust retention windows (PII / assessment / recording)
9Save — 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.

MethodEndpointPermission / scope
GET/api/v1/me/webhook-configtenant:read OR webhook:read (OAuth)
PUT/api/v1/me/webhook-configtenant:update OR webhook:update (OAuth)
DELETE/api/v1/me/webhook-configtenant:update OR webhook:update (OAuth)
OAuth clients with the 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.

MethodEndpointPermission / scope
GET/api/v1/tenants/:id/webhook-configtenant:read OR webhook:read (OAuth)
PUT/api/v1/tenants/:id/webhook-configtenant:update OR webhook:update (OAuth)
DELETE/api/v1/tenants/:id/webhook-configtenant: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:

CallerRule applied to URL :id
super-adminAny tenant id is accepted
platform-adminTenant must belong to the caller's platform (else 403)
Tenant Admin login JWTURL :id must equal the user's tenantId (else 403)
API key + X-Tenant-IDURL :id must equal X-Tenant-ID (else 403)
OAuth tenant token / client_credentials with tenant_id claimURL :id must equal the token tenant_id claim (else 403)
Holding a 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:

CallerRule applied to URL :id
super-adminAny tenant id is accepted
platform-adminTenant must belong to the caller's platform (else 403)
Tenant Admin login JWTURL :id must equal the user's tenantId (else 403)
API key + X-Tenant-IDURL :id must equal X-Tenant-ID (else 403)
OAuth tenant token / client_credentials with tenant_id claimURL :id must equal the token tenant_id claim (else 403)
Holding a 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

bash
curl -X GET https://mayaapi.teamcast.ai/api/v1/me/webhook-config \
  -H "Authorization: Bearer <TENANT_JWT_OR_OAUTH_TOKEN>"
json
{
  "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

bash
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

bash
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:

ApproachUse caseHow
Pre-register one webhook URLRecommended for production. Receive every event for every interview at one place.Configure once via PUT /me/webhook-config
Per-interview callbackUrlOne-off interviews routed to a different system.Pass callbackUrl in the create-interview request body
BothPer-request override wins; falls back to tenant config when missing.Set tenant config + optionally pass callbackUrl per request
Per-interview callbackUrl overrides use Maya's global 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

CheckWhy
Validate X-Webhook-Signature with HMAC-SHA256Reject forged events
Reject timestamps older than 5 minutesPrevent replay attacks
Respond 200 within 5sLong delays trigger retries; process async on a queue
Be idempotent on runId + eventMaya retries with exponential backoff on 5xx
Log unknown event types instead of 500ingNew events may be added; keep deliveries flowing
typescript
// 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

SymptomLikely causeAction
401 in Maya logs after retries exhaustedReceiver rejected signatureCompare your verifier secret with the tenant config; rotate if drifted
No webhook ever firesNo config registered, or platform-level fallback missingGET /tenants/:id/webhook-config — 404 means none; use the test endpoint to confirm
Some events delivered, others missingevents[] array filters out some typesSet events to [] (all) or add the missing types
Receiver gets 5xx in Maya retriescallbackUrl rejected by SSRF guardURL must be public HTTPS — no private/loopback/metadata addresses
Events arrive but unauthorized in receiverPer-interview callbackUrl uses global WEBHOOK_SECRET, not tenant secretEither drop the per-request override or verify both possible secrets
Was this page helpful?