Webhooks are automatic notifications sent to your server when something happens in Loft. Instead of constantly checking Loft for updates, Loft will "push" the information to you instantly.
Think of it like this: instead of refreshing your email every 5 minutes, you get a notification on your phone the moment an email arrives.
Before configuring Loft, you need a server that can receive HTTP POST requests. For testing, you can use free services like:
For production, you'll need your own server endpoint (e.g., https://yourcompany.com/webhooks/loft).
https://webhook.site/your-unique-id)⚠️ Important: The secret is only shown once. If you lose it, you'll need to click "Regenerate" to get a new one.
After setting up your URL, you need to choose which notifications should trigger webhooks:
When an event occurs, Loft sends a POST request to your URL with this structure:
{
"event": "notification",
"timestamp": "2026-01-20T12:34:56Z",
"data": {
"subject": "New comment on Deal #1234",
"body": "John Doe left a comment: Looking good!",
"action": "comment",
"actor": {
"id": 123,
"name": "John Doe",
"email": "john@example.com"
},
"deal": {
"id": 456,
"full_deal_number": "1234",
"deal_identifier": "123 Main Street"
}
}
}
| Header | Description |
|---|---|
Content-Type | application/json |
X-Loft-Signature | HMAC-SHA256 signature to verify the request is from Loft |
You can send Loft notifications directly to a Slack channel. Since Slack expects a specific payload format, you'll need to set up a small middleware to transform the data.
data.subject and data.body from the payloadIf you prefer more control, create a small server that transforms Loft webhooks to Slack format.
https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX)Here's an example using Node.js/Express:
const express = require('express');
const crypto = require('crypto');
const fetch = require('node-fetch');
const app = express();
app.use(express.json());
const LOFT_WEBHOOK_SECRET = process.env.LOFT_WEBHOOK_SECRET;
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;
function verifySignature(payload, signature) {
const expected = crypto
.createHmac('sha256', LOFT_WEBHOOK_SECRET)
.update(JSON.stringify(payload), 'utf8')
.digest('hex');
return signature === expected;
}
app.post('/webhooks/loft-to-slack', async (req, res) => {
const signature = req.headers['x-loft-signature'];
// Verify the webhook is from Loft
if (!verifySignature(req.body, signature)) {
return res.status(401).send('Invalid signature');
}
const { data } = req.body;
// Transform to Slack format
const slackMessage = {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: data.subject,
emoji: true
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: data.body
}
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `*From:* ${data.actor?.name || 'System'} | *Deal:* ${data.deal?.deal_identifier || 'N/A'}`
}
]
}
]
};
// Send to Slack
await fetch(SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(slackMessage)
});
res.status(200).send('OK');
});
app.listen(3000, () => console.log('Server running on port 3000'));
LOFT_WEBHOOK_SECRET - Your secret from LoftSLACK_WEBHOOK_URL - Your Slack incoming webhook URLYour Slack notifications will look like this:
┌─────────────────────────────────────────────┐
│ New comment on Deal #1234 │
├─────────────────────────────────────────────┤
│ John Doe left a comment: Looking good! │
│ │
│ From: John Doe | Deal: 123 Main Street │
└─────────────────────────────────────────────┘
The X-Loft-Signature header lets you verify that the webhook actually came from Loft and wasn't spoofed by someone else.
X-Loft-Signature headerYou can manually verify a signature using this command:
# Your webhook secret (from Loft settings)
SECRET="your-webhook-secret-here"
# The exact JSON payload you received (must be exact, no extra spaces)
PAYLOAD='{"event":"notification","timestamp":"2026-01-20T12:34:56Z","data":{"subject":"Test"}}'
# Generate the expected signature
echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET"
This outputs something like:
SHA2-256(stdin)= a1b2c3d4e5f6...
Compare this with the X-Loft-Signature header value - they should match!
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return signature === expectedSignature;
}
// In your Express route:
app.post('/webhooks/loft', (req, res) => {
const signature = req.headers['x-loft-signature'];
const payload = JSON.stringify(req.body);
if (!verifyWebhook(payload, signature, process.env.LOFT_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Process the webhook
console.log('Received:', req.body);
res.status(200).send('OK');
});
import hmac
import hashlib
def verify_webhook(payload: str, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# In your Flask route:
@app.route('/webhooks/loft', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Loft-Signature')
payload = request.get_data(as_text=True)
if not verify_webhook(payload, signature, os.environ['LOFT_WEBHOOK_SECRET']):
return 'Invalid signature', 401
data = request.json
print(f"Received: {data}")
return 'OK', 200
require 'openssl'
def verify_webhook(payload, signature, secret)
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
Rack::Utils.secure_compare(signature, expected)
end
# In your Rails controller:
def webhook
signature = request.headers['X-Loft-Signature']
payload = request.raw_post
unless verify_webhook(payload, signature, ENV['LOFT_WEBHOOK_SECRET'])
return head :unauthorized
end
data = JSON.parse(payload)
Rails.logger.info "Received webhook: #{data}"
head :ok
end
<?php
function verifyWebhook($payload, $signature, $secret) {
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
}
// In your webhook handler:
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_LOFT_SIGNATURE'] ?? '';
$secret = getenv('LOFT_WEBHOOK_SECRET');
if (!verifyWebhook($payload, $signature, $secret)) {
http_response_code(401);
die('Invalid signature');
}
$data = json_decode($payload, true);
error_log("Received webhook: " . print_r($data, true));
http_response_code(200);
echo 'OK';
localhost)curl -X POST -H 'Content-Type: application/json' \
--data '{"text":"Test message"}' \
YOUR_SLACK_WEBHOOK_URL
| Item | Value |
|---|---|
| HTTP Method | POST |
| Content-Type | application/json |
| Signature Header | X-Loft-Signature |
| Signature Algorithm | HMAC-SHA256 |
| Timeout | 30 seconds |
That's it! You're now ready to receive real-time notifications from Loft via webhooks.