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 thewebhook-idheader).
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 type | When it fires | Notes |
|---|---|---|
identity.scim.user.provisioned | SCIM user create or re-enable (active: true) | Company access only; not account creation |
identity.scim.user.deprovisioned | SCIM deprovision / active: false | Disables membership; user row remains |
identity.user.created | First OAuth sign-in creates a User, or Django admin adds a user with company membership | Company-scoped |
identity.user.deleted | Django admin or DELETE /api/v1/user (self-service) deletes a User | One emit per company membership before delete |
identity.user.updated | — | Registered; emit_by_default=false |
identity.group.created | SCIM or Django admin group create | |
identity.group.updated | Display name / external id change | |
identity.group.deleted | Group removed | |
identity.group.membership_changed | SCIM group members add/remove/replace | |
identity.scim.token.created | Admin REST or Django admin token create | No secret in payload |
identity.scim.token.revoked | Token revoke | |
identity.scim.provisioning_conflict | SCIM 409 / displayName collision | Ties to ScimProvisioningEvent |
identity.auth.magic_link.requested | User requested a passwordless email sign-in link | Payload 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
- Run migrations (
apps.actionsis inINSTALLED_APPS). - Open Action rules in Django admin.
- 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:
| Header | Meaning |
|---|---|
webhook-id | Same as envelope id (stable across retries for one delivery) |
webhook-timestamp | Unix seconds |
webhook-signature | v1,<base64(hmac_sha256)> |
X-Shellui-Event | Catalog event type (for example identity.user.created) |
X-Shellui-Delivery-Attempt | Attempt number for this outbox row (1 on first try) |
User-Agent | shellui-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_atwith 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 statusdead.
| Outcome | Retry? |
|---|---|
| 2xx | No (delivered) |
| 404, 408, 409, 425, 429 | Yes (404 covers inactive n8n workflows) |
| 400, 401, 403, 405, 410, 413, 422 | No (dead) |
| 5xx | Yes |
| Timeouts, connection errors | Yes |
| SSRF block (private URL not allowed) | No (dead) |
| Disabled/deleted rule | No (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.
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/actions/events | Event catalog with payload_fields and sample_envelope |
GET | /api/v1/actions/rules | List webhook rules (secrets redacted) |
POST | /api/v1/actions/rules | Create 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-test | POST a sample envelope to the rule URL (no outbox row) |
POST | /api/v1/actions/rules/<id>/rotate-secret | Generate a new whsec_ signing secret (returned once) |
GET | /api/v1/actions/deliveries | Paginated delivery log |
GET | /api/v1/actions/deliveries/<uuid> | Delivery detail with envelope and attempts |
POST | /api/v1/actions/deliveries/<uuid>/requeue | Re-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)
- Register the type in
apps/actions/identity_events.py. - Call
emit_event(...)oremit_event_if_rules(...)from the business path inside a transaction when appropriate. - 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.