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
/organizations/{org_id}/webhook-subscriptionsCreate Subscription Register an endpoint to receive platform events.
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
{
"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.
| 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
/organizations/{org_id}/webhook-subscriptionsList Subscriptions Returns all subscriptions for the organization. Secrets are never included.
/organizations/{org_id}/webhook-subscriptions/{subscription_id}Update Subscription Change the URL, event filter, outcome filter, active flag, or rotate the secret.
/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.
/organizations/{org_id}/webhook-deliveriesList Delivery Logs Returns recent delivery attempts across your subscriptions, including status and response outcomes.
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"
/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:
{
"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
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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
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.
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)
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_atwhen sequence matters. - Visibility: authorized users can use delivery logs to see pending attempts, successful sends, failures, retries, and exhausted deliveries.
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.
- 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_idas the dedupe key in your receiving system.
What to Build on This Surface
- Case and workflow systems. Turn a
reviewordeclineinto 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.createdfires on every evaluation, so your base rates are complete. Approvals are signal too. - Change auditing. The
rule.*andtable.*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
- Email: support@runloci.com
- Related: Transaction Monitoring API Reference, Event Counters API