> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lancer-erp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed POST requests when payments, leads, quotes, tickets, projects and clients change.

Lancer sends a signed `POST` to your URL whenever an event you subscribed to happens. Use it to feed n8n, Make, a data warehouse or your own backend without polling.

## Set up a webhook

**1. Add the endpoint**

Settings → **API and webhooks** ("API ו-Webhooks") → **Outgoing webhooks** ("Webhooks יוצאים") → **New webhook**. Paste an HTTPS URL that is reachable from the public internet (internal and private addresses are rejected) and pick the events.

**2. Copy the signing secret**

Each webhook gets its own secret (`whsec_…`), visible on the webhook's card. Store it with your endpoint's config.

**3. Send a test**

**Test** sends a `ping` event and shows the response code. The card also lists the last 100 deliveries, with status, response code and error, and lets you retry one by hand.

## Events

| Event | Fires when | `data` |
| - | - | - |
| `payment.succeeded` | A payment is recorded (pay link, standing order, manual) | The payment |
| `payment.failed` | A card charge through a pay link or processor fails | `{ paymentRequestId, attemptId, clientId, reason }` |
| `payment.refunded` | A payment is refunded, fully or partly | `{ refund, payment }` |
| `lead.created` | A lead is created (any source, including the API) | The lead |
| `lead.won` | A lead is marked won and converted | `{ lead, clientId }` |
| `quote.approved` | A client approves a quote | The quote |
| `ticket.created` | A support ticket is opened | The ticket |
| `project.status_changed` | A project moves to another status | `{ id, title, clientId, from, to }` |
| `client.created` | A client is created, or a lead is converted to one | The client |
| `payment_request.created` | A payment request is created | The payment request |
| `ping` | You click **Test** | `{ message: "Lancer webhook test" }` |

> [!NOTE]
>
> `data` carries the same record the Lancer app works with, so it can include more fields than the public API returns. Rely on `id` and the fields you need, and ignore unknown fields: new ones are added without notice.

## The request

```http theme={null}
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Lancer-Webhooks/1
X-Lancer-Event: payment.succeeded
X-Lancer-Delivery: 0193a5b2-…
X-Lancer-Timestamp: 1762936200
X-Lancer-Signature: sha256=5d2c…

{"id":"0193a5b2-…","event":"payment.succeeded","createdAt":"2026-11-12T08:30:00.000Z","organizationId":"…","data":{…}}
```

**id** `string`

The delivery id, the same as `X-Lancer-Delivery`. Retries keep the same id: use it to de-duplicate.

**event** `string`

The event name.

**createdAt** `string`

When the event happened (ISO 8601, UTC).

**organizationId** `string`

The organization the event belongs to.

**data** `object`

The event's payload, see the table above.

## Verify the signature

The signature is an HMAC-SHA256 over the timestamp, a dot and the **raw** request body, keyed with the webhook's secret, hex encoded and prefixed with `sha256=`:

```text theme={null}
X-Lancer-Signature = "sha256=" + hex( HMAC_SHA256(secret, X-Lancer-Timestamp + "." + rawBody) )
```

To accept a delivery:

1. Recompute the signature from the **raw bytes** of the body (not a re-serialised JSON object).
2. Compare it to the header in constant time.
3. Reject timestamps more than 5 minutes away from your clock, to stop replays.

```javascript Node.js (Express) theme={null}
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.LANCER_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 5 * 60;

function verify(rawBody, timestamp, signature) {
  if (!timestamp || !signature) return false;
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// Keep the raw body: verification must use the exact bytes Lancer sent.
app.post('/lancer/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verify(raw, req.get('X-Lancer-Timestamp'), req.get('X-Lancer-Signature'))) {
    return res.status(401).send('bad signature');
  }
  const delivery = JSON.parse(raw);
  // De-duplicate on delivery.id, then hand off to a queue and answer fast.
  if (delivery.event === 'payment.succeeded') {
    console.log('paid', delivery.data.amountAgorot / 100, 'ILS');
  }
  res.sendStatus(204);
});

app.listen(3000);
```

```python Python (Flask) theme={null}
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

SECRET = os.environ["LANCER_WEBHOOK_SECRET"].encode()  # whsec_…
TOLERANCE_SECONDS = 5 * 60

app = Flask(__name__)

def verify(raw: bytes, timestamp: str | None, signature: str | None) -> bool:
    if not timestamp or not signature:
        return False
    try:
        if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
            return False
    except ValueError:
        return False
    mac = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256)
    return hmac.compare_digest("sha256=" + mac.hexdigest(), signature)

@app.post("/lancer/webhooks")
def lancer_webhook():
    raw = request.get_data()  # the exact bytes Lancer sent
    if not verify(raw, request.headers.get("X-Lancer-Timestamp"), request.headers.get("X-Lancer-Signature")):
        abort(401)
    delivery = json.loads(raw)
    # De-duplicate on delivery["id"], then hand off to a queue and answer fast.
    if delivery["event"] == "lead.created":
        print("new lead", delivery["data"]["name"])
    return "", 204
```

> [!WARNING]
>
> Frameworks that parse JSON before your handler runs (Express's `express.json()`, Next.js API routes, many serverless platforms) give you an object, not the original bytes. Re-serialising it usually produces the same string because Lancer sends `JSON.stringify` output with no extra whitespace, but don't rely on that: read the raw body.

## Delivery and retries

* Answer with any **2xx** within **10 seconds**. Anything else (another status, a timeout, a redirect, a connection error) counts as a failure. Redirects are not followed.
* A failed delivery is retried after **1, 5, 30, 120 and 720 minutes**. After the last retry fails it is marked "נכשל סופית" (exhausted) and you can still retry it by hand from the webhook's card.
* Deliveries can arrive more than once and out of order. De-duplicate on `id` and, when order matters, re-read the current state from the [API](https://docs.lancer-erp.com/api-reference/introduction.md).
* Do slow work in the background: acknowledge first, process after.

> [!TIP]
>
> Building on n8n or Make? The [n8n template](https://docs.lancer-erp.com/guides/n8n-and-make.md) already verifies the signature for you.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.