Skip to main content

Action triggers (domain events → webhooks)

Company owners (and Django staff) can react when identity events happen (SCIM access changes, account create/delete, group changes, SCIM conflicts, token lifecycle) using webhook Action rules in the Shellui admin API or Django admin. Each rule maps a catalog event type (for example identity.scim.user.provisioned) to an HTTPS webhook endpoint.

Shellui identity stays the source of truth for domain events. Automation (n8n, Make, custom workers) consumes outbound webhooks on your URLs.


How it works​

Business code calls emit_event(type, company, payload)
│
▼
Match enabled webhook ActionRule rows for that company + event type
│
▼
Insert ActionOutbox row(s) in the same DB transaction
│
▼
transaction.on_commit → best-effort delivery (timeout-bounded HTTP, off the request thread)
│
▼
DeliveryAttempt audit log; retries via manage.py retry_webhooks
  • No Celery / Redis required for actions — the outbox lives in Postgres (or SQLite locally).
  • SCIM and API paths never block on slow external HTTP: delivery runs only after commit, with short webhook timeouts (default 5s).
  • Delivery is at-least-once; dedupe on the envelope id (same value as the webhook-id header).

Magic-link sign-in emails are not Action rules. Identity sends them directly when a user requests a link (see magic-link.md).


Event catalog (identity.*)​

Event typeWhen it firesNotes
identity.scim.user.provisionedSCIM user create or re-enable (active: true)Company access only; not account creation
identity.scim.user.deprovisionedSCIM deprovision / active: falseDisables membership; user row remains
identity.user.createdFirst OAuth sign-in creates a User, or Django admin adds a user with company membershipCompany-scoped
identity.user.deletedDjango admin or DELETE /api/v1/user (self-service) deletes a UserOne emit per company membership before delete
identity.user.updated—Registered; emit_by_default=false
identity.group.createdSCIM or Django admin group create
identity.group.updatedDisplay name / external id change
identity.group.deletedGroup removed
identity.group.membership_changedSCIM group members add/remove/replace
identity.scim.token.createdAdmin REST or Django admin token createNo secret in payload
identity.scim.token.revokedToken revoke
identity.scim.provisioning_conflictSCIM 409 / displayName collisionTies to ScimProvisioningEvent
identity.auth.magic_link.requestedUser requested a passwordless email sign-in linkPayload has request_id, email, expires_at (no secret or sign-in URL)

Envelope shape (webhooks)​

CloudEvents-inspired JSON:

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "identity.scim.user.provisioned",
"time": "2026-09-24T13:30:00+00:00",
"company": { "id": 1, "slug": "acme", "name": "Acme" },
"data": {
"user_id": 42,
"email": "ada@acme.com",
"username": "ada@acme.com",
"source": "scim",
"language": "en",
"region": "UTC"
}
}

Payloads never include secrets, bearer tokens, or password hashes.


Configuring rules​

Django admin​

  1. Run migrations (apps.actions is in INSTALLED_APPS).
  2. Open Action rules in Django admin.
  3. Create a rule: company, event type, webhook URL, signing secret, optional Authorization header.

Webhook config (stored JSON)​

{
"url": "https://your-n8n.example.com/webhook/abc",
"secret": "whsec_…",
"authorization_header": "Bearer optional-static-token"
}

Private / localhost webhooks: Per-rule allow_private_urls is not available to regular staff — only a superuser can enable it in Django admin, or operators set deployment-wide ACTIONS_WEBHOOK_ALLOW_PRIVATE=true.


Webhook signing (Standard Webhooks style)​

Each POST includes:

HeaderMeaning
webhook-idSame as envelope id (stable across retries for one delivery)
webhook-timestampUnix seconds
webhook-signaturev1,<base64(hmac_sha256)>
X-Shellui-EventCatalog event type (for example identity.user.created)
X-Shellui-Delivery-AttemptAttempt number for this outbox row (1 on first try)
User-Agentshellui-identity-actions/1.0

Signed content: {webhook-id}.{webhook-timestamp}.{raw_body} (UTF-8), HMAC-SHA256 with your signing key.

JSON body: compact separators, keys sorted, UTF-8 (ensure_ascii=false). Verify signatures against the raw HTTP body, not a re-serialized JSON object.

Secrets: prefer Standard Webhooks form whsec_<base64> (identity decodes the suffix as the HMAC key). Plain string secrets still work for existing rules.

Step-by-step n8n setup: Using Shellui webhooks with n8n.

Reject requests with timestamps too far from clock skew if you enforce replay protection.

SSRF protections​

Before delivery, identity resolves the webhook hostname once, rejects private/link-local/reserved targets (unless private URLs are explicitly allowed), and opens the TCP connection to that resolved address while sending the original hostname in the Host header and TLS SNI.


Delivery, retries, and cron​

  • After commit, identity attempts delivery once in a background thread (5s default timeout via ACTIONS_WEBHOOK_TIMEOUT_SECONDS; errors never fail the user request).
  • Failed deliveries schedule next_attempt_at with exponential backoff: 30s * 2^(attempt-1), capped at 1 hour, unless 429 or 503 returns Retry-After (then the larger of backoff and Retry-After applies, still capped at 1 hour).
  • Up to ACTIONS_OUTBOX_MAX_ATTEMPTS (default 8), then status dead.
OutcomeRetry?
2xxNo (delivered)
404, 408, 409, 425, 429Yes (404 covers inactive n8n workflows)
400, 401, 403, 405, 410, 413, 422No (dead)
5xxYes
Timeouts, connection errorsYes
SSRF block (private URL not allowed)No (dead)
Disabled/deleted ruleNo (dead)

Each attempt is logged in Delivery attempts (HTTP status, error excerpt, duration).

Retry pending rows with:

python manage.py retry_webhooks --batch-size 50 --max-seconds 50 --concurrency 4

Cron example (every minute):

* * * * * cd /app && python manage.py retry_webhooks >> /var/log/retry_webhooks.log 2>&1

On Coolify, add a Scheduled Task on the same identity-service image with that command and a 1-minute interval. Keep --max-seconds below 60 so overlapping runs stay safe (skip-locked claims + lease).

Flags:

  • --batch-size (default 50)
  • --max-seconds (default 50)
  • --concurrency (default 4)
  • --dry-run (claim only, no HTTP)

Re-queue a dead row from Django admin or POST /api/v1/actions/deliveries/<uuid>/requeue.


Company admin REST API (/api/v1/actions/)​

Same authentication as other Shellui admin endpoints: Bearer JWT (or PAT) plus company_id query parameter. Callers must be Django staff or a company owner for that company.

MethodPathPurpose
GET/api/v1/actions/eventsEvent catalog with payload_fields and sample_envelope
GET/api/v1/actions/rulesList webhook rules (secrets redacted)
POST/api/v1/actions/rulesCreate a webhook rule
GET/api/v1/actions/rules/<id>Rule detail
PATCH/api/v1/actions/rules/<id>Update fields or config
DELETE/api/v1/actions/rules/<id>Delete a rule
POST/api/v1/actions/rules/<id>/send-testPOST a sample envelope to the rule URL (no outbox row)
POST/api/v1/actions/rules/<id>/rotate-secretGenerate a new whsec_ signing secret (returned once)
GET/api/v1/actions/deliveriesPaginated delivery log
GET/api/v1/actions/deliveries/<uuid>Delivery detail with envelope and attempts
POST/api/v1/actions/deliveries/<uuid>/requeueRe-queue a row

Example: create a webhook rule​

Omit secret (or send a blank value) to auto-generate a Standard Webhooks secret (whsec_…). The create response matches rule GET plus top-level secret with the stored plaintext (generated or client-provided). List, GET, and PATCH never include top-level secret.

POST /api/v1/actions/rules?company_id=1
{
"name": "n8n user hook",
"event_type": "identity.user.created",
"url": "https://n8n.example.com/webhook/abc"
}

Example 201 body (same fields as GET /rules/<id>, plus secret):

{
"id": 1,
"company_id": 1,
"name": "n8n user hook",
"event_type": "identity.user.created",
"enabled": true,
"action_kind": "webhook",
"secret": "whsec_…",
"config": {
"url": "https://n8n.example.com/webhook/abc",
"has_secret": true,
"secret_hint": "abcd",
"authorization_header_set": false
},
"created_at": "…",
"updated_at": "…"
}

Rotate signing secret​

POST /api/v1/actions/rules/1/rotate-secret?company_id=1

200 response uses the same flat shape as create and GET, with a new top-level secret (update n8n before the next delivery):

{
"id": 1,
"company_id": 1,
"name": "n8n user hook",
"event_type": "identity.user.created",
"enabled": true,
"action_kind": "webhook",
"secret": "whsec_…",
"config": {
"url": "https://n8n.example.com/webhook/abc",
"has_secret": true,
"secret_hint": "wxyz",
"authorization_header_set": false
},
"created_at": "…",
"updated_at": "…"
}

List, detail, and update responses never include top-level secret. Webhook config includes has_secret, optional secret_hint (last four characters), and authorization_header_set instead of plaintext values.

Removed (admin UI): email rule fields, email template endpoints, and email_context_fields on the events catalog. Magic-link email is always sent by identity on request (not configurable via Actions).


Adding a new event type (developers)​

  1. Register the type in apps/actions/identity_events.py.
  2. Call emit_event(...) or emit_event_if_rules(...) from the business path inside a transaction when appropriate.
  3. Document the payload in this file and add tests under apps/actions/tests/.

To copy this pattern to storage-service or hosting-service, reuse the self-contained modules under apps/actions/ (emit.py, delivery.py, handlers/webhook.py, webhook_signing.py, webhook_transport.py, ssrf.py, management/commands/retry_webhooks.py, models, and admin API views), register service-specific events, and wire emit_event from domain code.