Webhook Events

Every decision Loci makes is also a record other systems need. Webhook events deliver that record durably: signed, retried, and idempotent, so your case managers, warehouses, ledgers, and alerting pipelines stay in step with the decisions your transaction flow already acted on.

Platform webhooks cover Loci events across fraud decisions, rule lifecycle changes, custom table changes, case updates, and screening hits.

Looking for AccessGate-specific webhooks (device and session risk events)? See AccessGate Webhooks. This page covers platform-wide events.

---

Managing Subscriptions

Subscriptions are managed through the organization API with an administrator token. Each organization can register multiple endpoints, each with its own event filter and signing secret.

Create a Subscription

POST/organizations/{org_id}/webhook-subscriptions

Create Subscription Register an endpoint to receive platform events.

bash
curl -X POST "https://api.runloci.com/organizations/your_loci_org_id/webhook-subscriptions" \
  -H "Authorization: Bearer your_loci_admin_token" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-backend.com/webhooks/loci",
    "events": ["decision.created", "case.lifecycle"],
    "outcome_filter": ["review", "decline"],
    "description": "Case management intake",
    "active": true
  }'

Response: 201 Created

json
{
  "status": "success",
  "data": {
    "subscription": {
      "subscription_id": "whsub_a1b2c3d4",
      "url": "https://your-backend.com/webhooks/loci",
      "events": ["decision.created", "case.lifecycle"],
      "outcome_filter": ["review", "decline"],
      "active": true,
      "description": "Case management intake"
    },
    "secret": "whsec_generated_signing_secret"
  }
}

The signing secret is returned once, at creation. Store it securely; it is not retrievable later. You can supply your own secret (minimum 16 characters) in the create request, or rotate it at any time with a PATCH that sets rotate_secret: true.

**Request fields:**
Field Required Description
url Yes HTTPS endpoint that receives events
events Yes One or more event types from the table below
outcome_filter No For decision.created only: limit delivery to approve, review, or decline outcomes. Empty means all outcomes
active No Defaults to true. Set false to pause delivery without deleting
description No Free-text label for your team
secret No Your own signing secret (minimum 16 characters). Generated for you when omitted

List, Update, and Delete

GET/organizations/{org_id}/webhook-subscriptions

List Subscriptions Returns all subscriptions for the organization. Secrets are never included.

PATCH/organizations/{org_id}/webhook-subscriptions/{subscription_id}

Update Subscription Change the URL, event filter, outcome filter, active flag, or rotate the secret.

DELETE/organizations/{org_id}/webhook-subscriptions/{subscription_id}

Delete Subscription Remove the subscription. Pending deliveries for it are dropped.

Inspecting Deliveries

Every delivery attempt is logged, so you can see what was sent, when it was sent, and how your endpoint responded.

GET/organizations/{org_id}/webhook-deliveries

List Delivery Logs Returns recent delivery attempts across your subscriptions, including status and response outcomes.

bash
curl "https://api.runloci.com/organizations/your_loci_org_id/webhook-deliveries?limit=25&status=failed&event_type=decision.created" \
  -H "Authorization: Bearer your_loci_admin_token"
GET/organizations/{org_id}/webhook-deliveries/{delivery_id}

Get Delivery Log Returns one delivery attempt in detail. Use it to investigate a specific missed or failed event.

Delivery log filters:

Query field Description
limit Maximum number of delivery rows to return
before ISO timestamp for cursor-style backfill through older delivery attempts
status Filter by delivery status, such as pending, delivered, failed, or exhausted
event_type Filter to one public event type, for example decision.created

Delivery log fields:

Field Description
delivery_id Stable id for this delivery attempt
subscription_id Webhook subscription that received the event
event_id Stable event id from the webhook envelope
event_type Public event type being delivered
status Current delivery state
attempts Number of attempts made so far
next_attempt_at When the next retry is scheduled, if any
delivered_at When the endpoint accepted the event, if successful
last_error Last delivery error or non-2xx response summary
response_status Last HTTP status returned by your endpoint

When an expected event is missing, check the delivery log first. It answers "did Loci send it, and what response did my endpoint return?" in one read.

---

Available Events

Event When It Fires
decision.created A transaction evaluation produced a decision. Fires on every successful evaluation, approvals included
rule.created A rule definition was created
rule.updated A rule definition was changed
rule.deleted A rule definition was removed
rule.paused A rule was paused
rule.activated A rule was activated
rule.deployed A rule was deployed to evaluation
table.created A custom table was created
table.deleted A custom table was deleted
table.rows_inserted Rows were added to a custom table
table.row_updated A custom table row was changed
table.row_deleted A custom table row was removed
case.lifecycle A case changed state
screening.hit A screening check produced a hit
organization.api_key_regenerated The organization API key was rotated

Subscribe only to the events you consume. A case system rarely needs rule.updated; an audit pipeline usually wants everything. Separate subscriptions with separate filters keep each consumer simple.

---

Event Envelope

Every event, regardless of type, arrives in the same envelope:

json
{
  "event_id": "evt_9f8e7d6c",
  "event_type": "decision.created",
  "event_version": "2026-07-24.webhook.1",
  "occurred_at": "2026-07-24T14:03:22.512Z",
  "org_id": "your_loci_org_id",
  "data": { }
}
Field Description
event_id Stable identifier for this event. Use it as your idempotency key: a redelivery carries the same id, so processing it twice should be a no-op
event_type One of the types above. Route on this
event_version Envelope contract version string, for example 2026-07-24.webhook.1. Treat it as opaque and compare for change, so your parser can evolve safely
occurred_at When the event happened, RFC 3339
org_id The organization the event belongs to
data The event payload. Shape depends on event_type

decision.created Payload

json
{
  "event_id": "evt_9f8e7d6c",
  "event_type": "decision.created",
  "event_version": "2026-07-24.webhook.1",
  "occurred_at": "2026-07-24T14:03:22.512Z",
  "org_id": "your_loci_org_id",
  "data": {
    "decision": "review",
    "fraud_score": 46,
    "flagged": true,
    "approved": false,
    "decision_reason": "Fraud score 46% exceeds review threshold 30%",
    "triggered_rules": ["velocity_spike_24h", "new_beneficiary_high_amount"],
    "transaction": {
      "transaction_id": "txn_20260724_0091",
      "entity_id": "cust_88321",
      "amount": 250000,
      "currency": "NGN"
    }
  }
}

The data block carries the same facts the synchronous evaluation response returned: the outcome, the score, the reason, and the rules that fired, along with the transaction as evaluated. Because the event fires on approvals too, your analytics keep complete base rates rather than only the flagged tail.

Rule Lifecycle Payloads

Rule events use the same envelope and differ only by event_type. Subscribe to rule.created, rule.updated, rule.deleted, rule.paused, rule.activated, and rule.deployed when connected systems need to keep rule status and definitions in sync.

json
{
  "event_id": "evt_rule_2f4a9b",
  "event_type": "rule.activated",
  "event_version": "2026-07-24.webhook.1",
  "occurred_at": "2026-07-24T15:10:11.210Z",
  "org_id": "your_loci_org_id",
  "data": {
    "rule_id": "rule_8f31d9",
    "name": "High Outbound Velocity",
    "active": true,
    "threshold": 0.7,
    "actor_id": "user_123",
    "change_type": "activated"
  }
}

Table CRUD Payloads

Table events describe changes to custom tables and intelligence assets used by rules. Subscribe to them when reporting, review, or governance tools need to know that analyst-managed reference data changed.

json
{
  "event_id": "evt_table_41c9bd",
  "event_type": "table.rows_inserted",
  "event_version": "2026-07-24.webhook.1",
  "occurred_at": "2026-07-24T15:18:44.019Z",
  "org_id": "your_loci_org_id",
  "data": {
    "table_name": "sanctions",
    "table_type": "reference_list",
    "rows_inserted": 31,
    "rows_failed": 0,
    "actor_id": "user_123"
  }
}

Case Lifecycle Payload

Case lifecycle events tell external case managers, queues, and audit tools when investigation state changes.

json
{
  "event_id": "evt_case_91a0c2",
  "event_type": "case.lifecycle",
  "event_version": "2026-07-24.webhook.1",
  "occurred_at": "2026-07-24T15:21:02.557Z",
  "org_id": "your_loci_org_id",
  "data": {
    "case_id": "case_10042",
    "case_number": "CASE-2026-10042",
    "action": "status_changed",
    "from_status": "open",
    "to_status": "in_review",
    "actor_id": "user_123"
  }
}

AML Screening Hit Payload

screening.hit fires when AML screening produces a hit that should be visible to connected workflow, audit, or review systems.

json
{
  "event_id": "evt_screening_77a6e3",
  "event_type": "screening.hit",
  "event_version": "2026-07-24.webhook.1",
  "occurred_at": "2026-07-24T15:30:12.003Z",
  "org_id": "your_loci_org_id",
  "data": {
    "screening_id": "scr_9d204",
    "subject": {
      "name": "Jane Example",
      "type": "person"
    },
    "hit_count": 2,
    "highest_risk": "high",
    "source": "aml_screening"
  }
}

API Key Rotation Payload

organization.api_key_regenerated lets your security team detect credential rotation and reconcile integration cutovers.

json
{
  "event_id": "evt_orgkey_4e89ac",
  "event_type": "organization.api_key_regenerated",
  "event_version": "2026-07-24.webhook.1",
  "occurred_at": "2026-07-24T15:42:09.440Z",
  "org_id": "your_loci_org_id",
  "data": {
    "actor_id": "admin_123",
    "key_hint": "lc_...7q9m",
    "rotated_at": "2026-07-24T15:42:09.440Z"
  }
}

Verifying Signatures

Every delivery is signed with HMAC-SHA256 using your subscription secret. Verify before processing; never act on an unverified event.

Delivery Headers

text
X-Loci-Event-Id: evt_9f8e7d6c
X-Loci-Event-Type: decision.created
X-Loci-Timestamp: 2026-07-24T14:03:23.101Z
X-Loci-Signature: sha256=3f1a9c...
X-Loci-Signature-Algorithm: hmac-sha256
User-Agent: Loci-Webhooks/1.0

The signature is computed over the string {timestamp}.{raw request body}, where the timestamp is the X-Loci-Timestamp header value and the body is the exact bytes received.

Verify against the raw request body, not a re-serialized copy of the parsed JSON. Re-serializing can reorder keys or change whitespace, which changes the bytes and fails verification even for a genuine event.

### Verification (Node.js)
javascript
const crypto = require('crypto');
const express = require('express');
const app = express();

// Capture the raw body; verification needs the exact bytes.
app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); }
}));

function verifyLociSignature(rawBody, signature, timestamp, secret) {
  if (!signature || !timestamp || !secret) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest('hex');
  const received = Buffer.from(signature);
  const expectedBuffer = Buffer.from(`sha256=${expected}`);

  if (received.length !== expectedBuffer.length) return false;
  return crypto.timingSafeEqual(received, expectedBuffer);
}

app.post('/webhooks/loci', (req, res) => {
  const signature = req.headers['x-loci-signature'];
  const timestamp = req.headers['x-loci-timestamp'];

  if (!verifyLociSignature(req.rawBody, signature, timestamp, process.env.LOCI_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  res.status(200).json({ received: true });
  processEventAsync(req.body);
});

Verification (Python)

python
import hmac
import hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = 'whsec_your_secret'

def verify_loci_signature(raw_body, signature, timestamp, secret):
    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.{raw_body}".encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, f"sha256={expected}")

@app.route('/webhooks/loci', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Loci-Signature')
    timestamp = request.headers.get('X-Loci-Timestamp')

    if not verify_loci_signature(request.data.decode(), signature, timestamp, WEBHOOK_SECRET):
        return jsonify({'error': 'Invalid signature'}), 401

    event = request.json
    return jsonify({'received': True}), 200

Delivery, Retries, and Idempotency

Deliveries are durable. Events are queued before delivery, so temporary endpoint failures do not lose them.

  • Timeout: each delivery attempt allows 10 seconds for your endpoint to respond.
  • Retries: a non-2xx response or timeout is retried with exponential backoff, starting at 5 seconds and growing to roughly 5 minutes between attempts, for up to 12 attempts.
  • Idempotency: redeliveries carry the same event_id. Track processed ids and treat repeats as a no-op.
  • Ordering: events are delivered at least once and may arrive out of order under retry. Use occurred_at when sequence matters.
  • Visibility: authorized users can use delivery logs to see pending attempts, successful sends, failures, retries, and exhausted deliveries.
javascript
const processed = new Set();

app.post('/webhooks/loci', (req, res) => {
  const { event_id } = req.body;
  if (processed.has(event_id)) {
    return res.status(200).json({ received: true, duplicate: true });
  }
  processed.add(event_id);

  // Acknowledge fast, then process the event.
  res.status(200).json({ received: true });
  processEventAsync(req.body);
});

Respond with 2xx quickly, then process the event. If processing may take longer than a few seconds, queue the work and use event_id to avoid duplicate side effects during retries.

## Security And Trust Notes
  • Tenant scoped. Every event includes org_id; subscriptions and delivery logs are scoped to one organization.
  • Signed. Every delivery includes HMAC-SHA256 headers. Verify the signature before parsing or acting on the payload.
  • Rotatable. Rotate secrets when endpoint ownership changes or when a secret may have been exposed.
  • Durable. Events are queued for delivery, so transient endpoint failures are retried instead of dropped.
  • Idempotent. Use event_id as the dedupe key in your receiving system.

What to Build on This Surface

  • Case and workflow systems. Turn a review or decline into a case the moment it happens. The event carries the reasons and triggered rules, so the case opens with the evidence attached.
  • Analytics and reporting. decision.created fires on every evaluation, so your base rates are complete. Approvals are signal too.
  • Change auditing. The rule.* and table.* events give you a trail of rule and table changes without polling.
  • Reconciliation. Annotate your system of record with the decision and reason so your ledger and Loci always agree about what was decided.

Support