---
title: Webhooks
---

# Webhooks

Webhooks let your server react to account events in real time. Configure them per-client in the [developer portal](https://app.littlexlittle.org/developers).

## Event taxonomy

| Event | Fired when |
|---|---|
| `account.signed_in` | A user successfully signed in via your client. |
| `account.signed_out` | A user signed out via RP-initiated logout. |
| `account.consent_granted` | A user granted consent for a new scope set. |
| `account.consent_revoked` | A user revoked consent (from their LXL profile). |
| `account.linked` | A provider (Google/etc.) was linked. |
| `account.unlinked` | A provider was unlinked. |
| `account.deleted` | The user's LXL account was deleted. |
| `tokens.revoked` | An access or refresh token was revoked. |

## Delivery format

```http
POST /your/webhook/endpoint HTTP/1.1
Host: yoursite.org
Content-Type: application/json
X-LXL-Event: account.signed_in
X-LXL-Delivery: 8c5e4e2a-2e1d-4f7e-a4c2-1b9c3d5e7f02
X-LXL-Signature: t=1714680000,v1=4f6e2c3a91b5...
User-Agent: Little-X-Little-Webhooks/1.0

{
  "id": "evt_8c5e4e2a",
  "event": "account.signed_in",
  "created_at": "2026-05-03T10:00:00Z",
  "client_id": "your-client-id",
  "data": {
    "account": "Bootim",
    "scopes": ["openid","profile","email"],
    "ip": "203.0.113.42",
    "user_agent": "Mozilla/5.0..."
  }
}
```

## Verify the signature

The header `X-LXL-Signature` follows the Stripe convention:

```
t=<unix-timestamp>,v1=<hex hmac_sha256(secret, t + "." + body)>
```

=== "PHP"

    ```php
    function verify($secret, $body, $header) {
        if (!preg_match('/t=(\d+),v1=([a-f0-9]+)/', $header, $m)) return false;
        [$_, $ts, $sig] = $m;
        if (abs(time() - (int)$ts) > 300) return false;     // reject stale
        $expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
        return hash_equals($expected, $sig);
    }
    ```

=== "Node.js"

    ```js
    import crypto from 'crypto';
    function verify(secret, body, header) {
      const m = /t=(\d+),v1=([a-f0-9]+)/.exec(header);
      if (!m) return false;
      const [, ts, sig] = m;
      if (Math.abs(Date.now()/1000 - +ts) > 300) return false;
      const expected = crypto.createHmac('sha256', secret).update(ts + '.' + body).digest('hex');
      return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
    }
    ```

=== "Python"

    ```python
    import hmac, hashlib, time, re
    def verify(secret, body, header):
        m = re.match(r't=(\d+),v1=([a-f0-9]+)', header)
        if not m: return False
        ts, sig = m.group(1), m.group(2)
        if abs(time.time() - int(ts)) > 300: return False
        expected = hmac.new(secret.encode(), f'{ts}.{body}'.encode(), hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, sig)
    ```

## Retry policy

If your endpoint returns a non-2xx status (or times out after 10s), we retry on this schedule:

| Attempt | After |
|---|---|
| 2 | 1 second |
| 3 | 5 seconds |
| 4 | 30 seconds |
| 5 | 5 minutes |
| 6 | 30 minutes |
| 7 | 2 hours |
| 8 | 12 hours |

After 8 failed attempts, the delivery moves to the dead-letter queue. You can replay any delivery from the developer portal.

## Best practices

- **Respond fast.** Return `2xx` immediately and process asynchronously.
- **Be idempotent.** We may deliver the same event more than once; key your processing on `id`.
- **Verify timing.** Reject events older than 5 minutes to defeat replay attacks.
- **Rotate secrets** by adding a second secret in the portal, switching your verifier to accept either, then removing the old one.
