Webhooks — LoopNow developer docs
LoopNow webhooks: 22 event types, JSON payload schema, HMAC-SHA256 signature verification (Python and Node examples), exponential backoff retry policy, ngrok testing.
On this page
Event types
Webhook event types fall into four groups. The full list of 22 events, with payload schemas, is in the API reference; here are the 10 most commonly subscribed to.
| Event | When it fires | Common use |
|---|---|---|
contact.created | A new contact is added (via form, API, or import) | Sync to your CRM, trigger a welcome flow in another system |
contact.updated | A contact field or tag is changed | Keep your data warehouse in sync |
contact.unsubscribed | A contact hits unsubscribe in any email | Mark the contact as marketing-opt-out in your CRM |
campaign.sent | A campaign has been queued for delivery | Update a "campaigns sent" counter on your internal dashboard |
campaign.opened | A recipient opened the email | Track engagement in your own analytics system |
campaign.clicked | A recipient clicked a link in the email | Route high-intent leads to sales |
automation.triggered | A contact entered an automation | Update your CRM with the automation enrollment |
automation.completed | A contact reached the end of an automation | Tag the contact as "automation-completed" in your warehouse |
transactional.delivered | A transactional email was accepted by the recipient's MX | Mark the order as "confirmation delivered" in your system |
transactional.bounced | A transactional email hard-bounced | Suppress the recipient address from future sends |
transactional.complained | A recipient marked a transactional email as spam | Suppress the recipient immediately and review the send |
Payload schema
Every webhook delivery is a POST request with a JSON body. The body has a consistent envelope around the event-specific data:
{
"id": "evt_abc123",
"type": "campaign.opened",
"created_at": "2026-01-14T10:30:00.000Z",
"workspace_id": "ws_xyz789",
"data": {
"campaign_id": "cmp_xyz789",
"contact_id": "ct_def456",
"email": "user@example.com",
"opened_at": "2026-01-14T10:32:14.000Z",
"user_agent": "Mozilla/5.0 ...",
"ip": "203.0.113.42"
}
}
The id is unique per event delivery and is the idempotency key — you can safely retry-handle the same id without double-processing. The type matches the subscribed event type. The data object is event-specific; the full schema for each type is in the API reference.
Two headers are sent with every delivery:
X-LoopNow-Signature: t=1705267800,v1=4f3b...c8a2— the timestamp and the HMAC-SHA256 signature. See verification below.X-LoopNow-Event-Id: evt_abc123— the same asidin the body. Either source works for idempotency.X-LoopNow-Delivery-Attempt: 1— the attempt number (1, 2, 3, 4, or 5). Useful in logs.
Signature verification (HMAC-SHA256)
Every webhook payload is signed with your webhook signing secret. The signature is in the X-LoopNow-Signature header in the format t={timestamp},v1={hmac}.
To verify:
- Extract the timestamp
tand the signaturev1from the header. - Construct the signed payload:
{timestamp}.{raw_request_body}. - Compute the HMAC-SHA256 of the signed payload using your webhook signing secret as the key.
- Compare the computed HMAC to the
v1value. Use a constant-time compare to avoid timing attacks. - Reject the request if the timestamp is more than 5 minutes old (replay attack protection).
The signing secret is shown once when you create the webhook and is available thereafter in Settings → Webhooks. It is different from your API key — webhook signing secrets can be rotated independently.
Signature verification in Python
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
WEBHOOK_SECRET = "whsec_xxxxxxxxxxxxxxxxxxxx"
@app.post("/loopnow/webhooks")
async def handle(request: Request):
body = await request.body()
sig_header = request.headers.get("X-LoopNow-Signature", "")
# Parse the header
parts = dict(p.split("=", 1) for p in sig_header.split(","))
timestamp = parts.get("t", "")
received_sig = parts.get("v1", "")
# Replay protection: reject if older than 5 minutes
if abs(time.time() - int(timestamp)) > 300:
raise HTTPException(400, "Stale timestamp")
# Compute expected signature
signed_payload = f"{timestamp}.{body.decode('utf-8')}".encode()
expected_sig = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload,
hashlib.sha256
).hexdigest()
# Constant-time compare
if not hmac.compare_digest(received_sig, expected_sig):
raise HTTPException(401, "Invalid signature")
# Process the event
event = await request.json()
print(f"Received {event['type']}: {event['id']}")
return {"received": True}
Signature verification in Node
import express from "express";
import crypto from "crypto";
const app = express();
const WEBHOOK_SECRET = "whsec_xxxxxxxxxxxxxxxxxxxx";
app.post(
"/loopnow/webhooks",
express.raw({ type: "application/json" }),
(req, res) => {
const sigHeader = req.headers["x-loopnow-signature"] || "";
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.split("=", 2))
);
const timestamp = parts.t;
const receivedSig = parts.v1;
// Replay protection
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send("Stale timestamp");
}
const signedPayload = `${timestamp}.${req.body.toString("utf8")}`;
const expectedSig = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
if (
receivedSig.length !== expectedSig.length ||
!crypto.timingSafeEqual(
Buffer.from(receivedSig),
Buffer.from(expectedSig)
)
) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
console.log(`Received ${event.type}: ${event.id}`);
res.json({ received: true });
}
);
app.listen(3000);
Retry policy
If your endpoint returns a non-2xx response, the delivery is retried with exponential backoff:
| Attempt | Delay after previous attempt | Total time elapsed |
|---|---|---|
| 1 | 0s (immediate) | 0s |
| 2 | 30s | 30s |
| 3 | 5 min | ~6 min |
| 4 | 30 min | ~36 min |
| 5 | 2 hours | ~2.5 hours |
After 5 failed attempts, the event is moved to the dead-letter queue. You can replay any dead-lettered event from the dashboard for up to 30 days.
Important: respond with 2xx after you have processed the event, not before. If you ack and then crash during processing, the event is lost. The recommended pattern is: receive → enqueue to your own queue → ack → process from the queue. This makes your webhook handler resilient to your own infrastructure failures.
Testing with ngrok
For local development, use ngrok to expose your local server:
# In one terminal: run your local webhook handler on port 3000
python app.py # or: node index.js
# In another terminal: expose it publicly
ngrok http 3000
# Copy the https URL ngrok gives you (e.g. https://a1b2c3.ngrok-free.app)
# Register it in the LoopNow dashboard as your webhook URL
ngrok's free tier gives you a random subdomain on every restart. LoopNow's webhook dashboard lets you re-register the URL with one click when ngrok restarts.
For production, your endpoint should be HTTPS, on a stable domain, and ideally on infrastructure that does not have cold-start latency (a serverless function on AWS Lambda, for example, can add 200-500ms of cold start that puts you close to the 5-second response-time SLO).
Registering a webhook
Two ways to register:
- Dashboard: Settings → Webhooks → Add endpoint. Enter the URL, pick the event types, save. The signing secret is displayed once.
- API:
POST /v1/webhookswith{"url": "...", "events": [...]}. The signing secret is returned in the response and is shown only once.
You can register up to 10 webhook endpoints per workspace. Each endpoint can subscribe to any subset of the 22 event types. The same event can be delivered to multiple endpoints.
Testing locally without ngrok
ngrok is the standard solution, but if you are on a corporate network that blocks outbound ngrok connections, or if you want to test without a public tunnel, the alternative is loopnow-cli — our small command-line tool that subscribes to your workspace's webhook stream and prints the events to your terminal.
# Install
npm install -g @loopnow/cli
# Subscribe
loopnow webhooks listen --workspace ws_xyz789
# Trigger an event from another terminal
loopnow contacts create --email test@example.com --consent-text "test"
# See the event printed in the listener
{
"id": "evt_abc123",
"type": "contact.created",
"data": { "email": "test@example.com" }
}
The CLI handles signature verification automatically, so you can use it to confirm that the events you receive are exactly what your endpoint would see in production.
Webhook best practices
Five things to do in your webhook handler that will save you support tickets:
- Verify the signature on every request. No exceptions, even for "internal" endpoints. The cost of a missed verification is a security incident.
- Respond with 2xx as fast as possible. Within 5 seconds. Defer real work to a background queue.
- Use the event id for idempotency. Store the
idof every event you have processed. Reject duplicates. - Log the request_id from the error body. When you contact support, the request_id is the single most useful piece of information.
- Test your retry handling. Return 500 on purpose. Confirm the event comes back at 30s, 5min, 30min, 2h.