Webhooks
Receive signed Signals events at an organisation-owned public HTTPS endpoint.
Event types
| Event type | When it is sent |
|---|---|
| signals.monitor.delivery | When a configured monitor has new paid resources to deliver. |
| integration.test | When an owner or admin sends a test delivery. |
Envelope
Every body is canonical JSON using schema version 1.
| Field | Type | Required |
|---|---|---|
| schema_version | 1 | Yes |
| event_id | UUIDv7 | Yes |
| delivery_id | UUIDv7 | Yes |
| event_type | event type | Yes |
| occurred_at | RFC 3339 timestamp | Yes |
| data | event payload | Yes |
Delivery headers
| Header | Purpose |
|---|---|
| X-Luranta-Delivery-Id | Unique delivery attempt identity. Use it when investigating delivery state. |
| X-Luranta-Event-Id | Stable event identity. Use it as the primary idempotency key. |
| X-Luranta-Event-Type | The event discriminator for routing and payload handling. |
| X-Luranta-Signature | The v0 HMAC-SHA256 signature. |
| X-Luranta-Timestamp | Unix time in seconds included in the signed input. |
Verify before processing
Read the request body as raw bytes. Reject timestamps more than 5 minutes from your clock. Compute HMAC-SHA256 over v0.<timestamp>.<raw_body> using the destination secret, encode it as lowercase hexadecimal, prefix it with v0=, then compare in constant time.
Only parse or enqueue the body after verification succeeds. Deduplicate business processing by event_id; retain delivery_id for per-attempt diagnostics.
Receive and verify
body='{"data":{"action_url":"https://luranta.com/signals/monitors","resource_ids":[],"schema_version":1,"summary":"A monitored hiring signal changed.","title":"Hiring signal update"},"delivery_id":"019c0000-0000-7000-8000-000000000201","event_id":"019c0000-0000-7000-8000-000000000202","event_type":"integration.test","occurred_at":"2026-08-02T12:00:00.000Z","schema_version":1}'
timestamp=$(date +%s)
signature=$(printf '%s' "v0.$timestamp.$body" \
| openssl dgst -sha256 -hmac "$LURANTA_WEBHOOK_SECRET" -hex \
| awk '{print $NF}')
curl --request POST "http://localhost:8787/luranta-webhook" \
--header "Content-Type: application/json" \
--header "X-Luranta-Event-Id: 019c0000-0000-7000-8000-000000000202" \
--header "X-Luranta-Delivery-Id: 019c0000-0000-7000-8000-000000000201" \
--header "X-Luranta-Event-Type: integration.test" \
--header "X-Luranta-Timestamp: $timestamp" \
--header "X-Luranta-Signature: v0=$signature" \
--data-binary "$body"const encoder = new TextEncoder()
function hexBytes(value: string): Uint8Array {
if (!/^[0-9a-f]{64}$/.test(value)) throw new Error("Invalid signature")
return Uint8Array.from(value.match(/.{2}/g)!, (byte) => Number.parseInt(byte, 16))
}
export async function verifyLurantaWebhook(request: Request, secret: string) {
const timestamp = request.headers.get("X-Luranta-Timestamp")
const signature = request.headers.get("X-Luranta-Signature")
if (!timestamp || !signature?.startsWith("v0=")) return false
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!Number.isSafeInteger(Number(timestamp)) || age > 300) return false
const rawBody = await request.text()
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
)
return crypto.subtle.verify(
"HMAC",
key,
hexBytes(signature.slice(3)),
encoder.encode(`v0.${timestamp}.${rawBody}`),
)
}import hashlib
import hmac
import time
def verify_luranta_webhook(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Luranta-Timestamp")
signature = headers.get("X-Luranta-Signature")
if not timestamp or not signature or not signature.startswith("v0="):
return False
try:
if abs(int(time.time()) - int(timestamp)) > 300:
return False
except ValueError:
return False
signed = b"v0." + timestamp.encode() + b"." + raw_body
expected = "v0=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Acknowledgement and retries
| Receiver result | Luranta behaviour |
|---|---|
| Any 2xx | Marks that destination delivery as complete. |
| 408, 409, 425, 429 or 5xx | Retries with bounded backoff, up to 12 attempts. |
| Other non-2xx | Treats the delivery as terminal. |
| Repeated terminal deliveries | Pauses the destination after 5 consecutive failures. |
| Redirect | Does not follow it; the delivery fails. |
Endpoint requirements
- Use a public HTTPS hostname on port 443.
- Do not use credentials in the URL.
- Luranta rejects loopback, private, link-local, metadata and non-public DNS answers before every attempt.
- The maximum body size is 64 KiB and the request timeout is 10 seconds.
- Return a 2xx as soon as the verified event is durably queued; process it asynchronously.
Rotate or troubleshoot
Create a new secret from Signals → Destinations, update your receiver, then send a test event before resuming production delivery. Delivery receipts show the destination, event, attempt count and terminal failure code without exposing the secret or payload. A paused destination must be explicitly resumed after the cause is fixed.