Outbound Webhooks
LoomAPI sends an HTTP POST to your configured webhook URL when a verification session completes. This eliminates polling and lets your application react immediately to results.
Setup
- Go to dashboard.loomapi.com
- Navigate to Settings
- Enter your webhook URL and save
- Copy the webhook secret shown — you need it to verify signatures
Webhook URL requirements
- HTTPS only — HTTP endpoints are not accepted
- Publicly reachable — must be accessible from the internet
- Respond within 5 seconds — return a 2xx status quickly; do heavy work asynchronously
- Idempotent — the same event may be delivered more than once; deduplicate by
data.verificationId
Webhook payload
{
"event": "verification.completed",
"tenantId": "your_tenant_id",
"data": {
"verificationId": "cjld2cjxh0000qzrmn831i7rn",
"status": "approved",
"confidence": 0.97,
"provider": "idenfy"
},
"timestamp": "2024-01-15T14:30:00.000Z"
}
Payload fields
| Field | Type | Description |
|---|---|---|
event | string | Always verification.completed in v1 |
tenantId | string | Your tenant ID — useful for routing in multi-tenant setups |
data.verificationId | string | Matches the verificationId from POST /verify/start |
data.status | string | Outcome: approved, denied, resubmission, submitted, rejected |
data.confidence | number | Confidence score from iDenfy (0–1) |
data.provider | string | Always idenfy in v1 |
timestamp | string | ISO 8601 timestamp |
Event types
Only verification.completed is implemented in v1. The data.status field carries the outcome — check it to distinguish approved from denied etc. Additional event types will be added in future releases.
Signature verification
Every webhook request includes two security headers:
X-Webhook-Signature: <hex-encoded HMAC-SHA256>
X-Webhook-Event: verification.completed
The signature is computed as HMAC-SHA256(raw_request_body, webhook_secret) and hex-encoded. Always verify it on the raw request bytes before parsing JSON.
JavaScript / Node.js (Express)
const crypto = require('crypto')
function verifyWebhookSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex')
if (signature.length !== expected.length) return false
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
)
}
// Express handler — use express.raw() so you get the raw body
app.post('/webhooks/loom', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature'] // NOT x-loom-signature
if (!verifyWebhookSignature(req.body, signature, process.env.LOOM_WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized')
}
const event = JSON.parse(req.body)
// process event...
res.status(200).send('OK')
})
Python
import hmac
import hashlib
def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode('utf-8'),
raw_body, # raw bytes, before JSON parsing
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Security checklist
- Always verify the signature before processing the payload
- Use the raw request body — not the parsed JSON object — when computing the HMAC
- Use constant-time comparison (
timingSafeEqual/hmac.compare_digest) to prevent timing attacks - Store the secret in an environment variable, never in source code
- Use HTTPS for your webhook endpoint
Handling webhooks
Basic handler
app.post('/webhooks/loom', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature']
if (!verifyWebhookSignature(req.body, signature, process.env.LOOM_WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized')
}
const payload = JSON.parse(req.body)
const { event, data } = payload
if (event === 'verification.completed') {
if (data.status === 'approved') {
// User is verified — grant access, issue session token, etc.
} else {
// data.status is 'denied', 'resubmission', etc. — handle accordingly
}
}
res.status(200).send('OK')
})
Idempotency
Deduplicate by data.verificationId — the same event may be delivered more than once:
const processedIds = new Set()
function processWebhook(verificationId, status) {
if (processedIds.has(verificationId)) return
processedIds.add(verificationId)
// ... handle event
}
In production, use a database column or Redis key instead of an in-memory set.
Retry policy
LoomAPI attempts delivery immediately. If that fails (non-2xx response, timeout, or connection error), it retries up to 2 more times — 3 total attempts. Delivery state is tracked in Redis for up to 24 hours.
Webhook delivery fails if:
- HTTP status code >= 300
- Connection timeout (> 5 seconds)
- DNS resolution failure
- SSL/TLS error
Manual retry is not available in v1. To re-trigger delivery, re-run the verification flow or contact support.
Testing webhooks
Use ngrok or localtunnel to expose a local endpoint during development:
ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000
Set the ngrok URL as your webhook URL in the dashboard, then start a real verification via POST /verify/start and complete the iDenfy session. Your endpoint will receive the verification.completed event when iDenfy processes the result.
There is no test webhook API in v1 — trigger events via real or sandbox verifications.
Monitoring
Webhook configuration (URL and secret) is managed in the dashboard under Settings. Delivery logs are not yet available in the dashboard — check your own endpoint logs for delivery confirmation and debugging.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Signature verification fails | Wrong secret, or parsing JSON before hashing | Use raw request body; confirm secret matches dashboard |
| 401 from your endpoint | Signature check failing | See above |
| Timeouts | Endpoint takes > 5 s | Return 200 immediately; do work in background |
| Duplicate events | Normal — retries happen | Implement idempotency on data.verificationId |