# Webhooks

Receive signed Signals events at an organisation-owned public HTTPS endpoint.

> **Store the secret once:** The signing secret is displayed only when the destination is created or rotated. Keep it in a server-side secret store and never log it.

## 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

### cURL

```shell
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"
```

### TypeScript

```ts
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}`),
  )
}
```

### Python

```python
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.
