Receiving Webhooks
When an event fires, Merdeka sends a POST request to your configured endpoint with a JSON payload. This page covers how to verify authenticity, understand the payload, and handle failures.
Verifying Requests
During webhook creation, you define a header name and secret value. Merdeka includes this header in every delivery so your endpoint can confirm the request is authentic.
POST /your-webhook-endpoint HTTP/1.1
Content-Type: application/json
X-Webhook-Secret: your-configured-secret
{ "eventSlug": "lead.created", ... }
The header name and secret are entirely up to you — whatever you set during webhook creation is what Merdeka sends verbatim.
- Node.js
- PHP (Laravel)
- Python (FastAPI)
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/merdeka', (req, res) => {
const secret = req.headers['x-webhook-secret'];
if (secret !== process.env.MERDEKA_WEBHOOK_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { eventSlug, entityType, entityId, data } = req.body;
// Respond immediately, process async
res.status(200).json({ received: true });
// Handle event asynchronously
processEvent({ eventSlug, entityType, entityId, data });
});
Route::post('/webhooks/merdeka', function (Request $request) {
$secret = $request->header('X-Webhook-Secret');
if ($secret !== config('services.merdeka.webhook_secret')) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$payload = $request->all();
// Dispatch to queue for async processing
dispatch(new ProcessMerdekaWebhook($payload));
return response()->json(['received' => true]);
});
from fastapi import FastAPI, Request, HTTPException
import os
app = FastAPI()
@app.post("/webhooks/merdeka")
async def handle_webhook(request: Request):
secret = request.headers.get("x-webhook-secret")
if secret != os.environ["MERDEKA_WEBHOOK_SECRET"]:
raise HTTPException(status_code=401, detail="Unauthorized")
payload = await request.json()
# Process event...
return {"received": True}
Retry Behavior
If your endpoint fails to respond with a 2xx status, Merdeka retries automatically:
| Max attempts | 3 (initial + 2 retries) |
| Backoff | Exponential — ~1s, then ~2s |
| Timeout | 15 seconds per attempt |
A delivery is considered failed when:
- HTTP response is non-2xx (e.g., 404, 500)
- No response within 15 seconds
- Network error (DNS failure, connection refused)
After all 3 attempts exhaust, the delivery is permanently marked as failed. Monitor failures via Delivery Logs in the Backoffice.
Payload Structure
Every delivery body follows this envelope:
{
"eventSlug": "project.updated",
"entityType": "project",
"entityId": "4a51e919-9d74-4b8e-94e7-0c99ac879c53",
"occurredAt": "2026-07-30T09:17:48.872Z",
"data": { ... }
}
| Field | Type | Description |
|---|---|---|
eventSlug | string | Event identifier (e.g., lead.created) |
entityType | string | "lead" or "project" |
entityId | string | UUID of the affected entity |
occurredAt | string | ISO 8601 timestamp of when the event fired |
data | object | Event-specific payload — contents vary by event type |
Notes:
- If you configured data filters, only selected fields appear in
data. - If you set a custom body with
meta, it merges intodata.meta.
Event Payloads
Below are full sample payloads for each event type. These represent the unfiltered payload (no data filters applied).
lead.created
Fires when a new lead is created.
View full payload
{
"eventSlug": "lead.created",
"entityType": "lead",
"entityId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"occurredAt": "2026-07-30T09:17:48.872Z",
"data": {
"lead": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"leadIncrementId": 42,
"leadSource": { "id": "src-1", "name": "Web" },
"leadStatus": { "id": "status-1", "name": "New" },
"leadType": { "id": "type-1", "name": "Residential" },
"sdrChannel": { "id": "ch-1", "name": "Inbound" },
"companyBrand": { "id": "brand-1", "name": "SunPower" },
"unqualifiedReason": null,
"previousLeadSource": null,
"leadOwner": {
"id": "user-1",
"firstName": "Greta",
"lastName": "Knowles",
"email": "greta@example.com",
"phone": "+1234567890"
},
"contact": {
"id": "contact-1",
"firstName": "Alexandre",
"lastName": "Fry",
"email": "alex@example.com",
"phone1": "+1987654321",
"phone2": null,
"contactSource": { "id": "csrc-1", "name": "Website Form" },
"contactSourceDetail": { "id": "csd-1", "name": "Landing Page A" },
"account": {
"id": "acct-1",
"address1": "123 Solar Lane",
"address2": null,
"city": "Austin",
"stateId": "TX",
"stateCode": "TX",
"zipCode": "78701",
"country": "US",
"propertyType": { "id": "pt-1", "name": "Single Family" },
"roofType": { "id": "rt-1", "name": "Composition Shingle" },
"state": { "id": "TX", "name": "Texas" }
}
},
"salesforceExternalIds": []
},
"project": null
}
}
lead.updated
Fires when a lead is updated (status change, owner reassignment, contact info edit, etc.).
View full payload
{
"eventSlug": "lead.updated",
"entityType": "lead",
"entityId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"occurredAt": "2026-07-30T10:22:15.441Z",
"data": {
"lead": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"leadIncrementId": 42,
"leadSource": { "id": "src-1", "name": "Web" },
"leadStatus": { "id": "status-2", "name": "Qualified" },
"leadType": { "id": "type-1", "name": "Residential" },
"sdrChannel": { "id": "ch-1", "name": "Inbound" },
"companyBrand": { "id": "brand-1", "name": "SunPower" },
"unqualifiedReason": null,
"previousLeadSource": { "id": "src-3", "name": "Referral" },
"leadOwner": {
"id": "user-2",
"firstName": "Marcus",
"lastName": "Chen",
"email": "marcus@example.com",
"phone": "+1555000111"
},
"contact": {
"id": "contact-1",
"firstName": "Alexandre",
"lastName": "Fry",
"email": "alex@example.com",
"phone1": "+1987654321",
"phone2": "+1987654322",
"contactSource": { "id": "csrc-1", "name": "Website Form" },
"contactSourceDetail": { "id": "csd-1", "name": "Landing Page A" },
"account": {
"id": "acct-1",
"address1": "123 Solar Lane",
"address2": "Suite B",
"city": "Austin",
"stateId": "TX",
"stateCode": "TX",
"zipCode": "78701",
"country": "US",
"propertyType": { "id": "pt-1", "name": "Single Family" },
"roofType": { "id": "rt-1", "name": "Composition Shingle" },
"state": { "id": "TX", "name": "Texas" }
}
},
"salesforceExternalIds": [
{
"epcExternalId": "sf-ext-001",
"projectId": "proj-1",
"projectName": "Fry Residence Solar"
}
]
},
"project": {
"id": "proj-1",
"incrementId": 7,
"name": "Fry Residence Solar",
"projectStatusId": "ps-2",
"projectStatusName": "In Progress"
}
}
}
project.created
Fires when a new project is created.
View full payload
{
"eventSlug": "project.created",
"entityType": "project",
"entityId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"occurredAt": "2026-07-30T11:05:33.219Z",
"data": {
"project": {
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Fry Residence Solar",
"incrementId": "PRJ-0042",
"epcId": "epc-1",
"companyId": "company-1",
"currentStageId": "stage-1",
"salesRepId": "user-2",
"createdAt": "2026-07-30T11:05:33.219Z",
"updatedAt": "2026-07-30T11:05:33.219Z",
"epc": { "id": "epc-1", "name": "Tynergy" },
"territory": { "id": "terr-1", "name": "Texas South", "code": "TX-S" },
"stage": { "id": "stage-1", "name": "Site Survey", "order": 1 },
"salesRep": {
"id": "user-2",
"firstName": "Marcus",
"lastName": "Chen",
"email": "marcus@example.com",
"phone": "+1555000111"
},
"customer": {
"id": "contact-1",
"firstName": "Alexandre",
"lastName": "Fry",
"address": "123 Solar Lane",
"city": "Austin",
"zipCode": "78701",
"phone": "+1987654321",
"email": "alex@example.com"
}
},
"projectDetail": {
"id": "pd-1",
"address": "123 Solar Lane",
"city": "Austin",
"zipCode": "78701",
"stateId": "TX",
"systemSize": "8.5 kW",
"leadSource": "Web",
"projectStatusId": "ps-1",
"saleDate": null,
"installedAt": null,
"ptoCompletedAt": null,
"territory": { "id": "terr-1", "name": "Texas South" }
},
"timeline": []
}
}
project.updated
Fires when a project is updated (stage change, sale date set, installation completed, etc.).
View full payload
{
"eventSlug": "project.updated",
"entityType": "project",
"entityId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"occurredAt": "2026-08-01T14:30:12.887Z",
"data": {
"project": {
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Fry Residence Solar",
"incrementId": "PRJ-0042",
"epcId": "epc-1",
"companyId": "company-1",
"currentStageId": "stage-3",
"salesRepId": "user-2",
"createdAt": "2026-07-30T11:05:33.219Z",
"updatedAt": "2026-08-01T14:30:12.887Z",
"epc": { "id": "epc-1", "name": "Tynergy" },
"territory": { "id": "terr-1", "name": "Texas South", "code": "TX-S" },
"stage": { "id": "stage-3", "name": "Installation", "order": 3 },
"salesRep": {
"id": "user-2",
"firstName": "Marcus",
"lastName": "Chen",
"email": "marcus@example.com",
"phone": "+1555000111"
},
"customer": {
"id": "contact-1",
"firstName": "Alexandre",
"lastName": "Fry",
"address": "123 Solar Lane",
"city": "Austin",
"zipCode": "78701",
"phone": "+1987654321",
"email": "alex@example.com"
}
},
"projectDetail": {
"id": "pd-1",
"address": "123 Solar Lane",
"city": "Austin",
"zipCode": "78701",
"stateId": "TX",
"systemSize": "8.5 kW",
"leadSource": "Web",
"projectStatusId": "ps-2",
"saleDate": "2026-07-31T00:00:00.000Z",
"installedAt": null,
"ptoCompletedAt": null,
"territory": { "id": "terr-1", "name": "Texas South" }
},
"timeline": [
{
"id": "tl-1",
"name": "Site Survey Completed",
"date": "2026-07-31T00:00:00.000Z",
"timelineCategoryId": "tlcat-1"
},
{
"id": "tl-2",
"name": "Permit Approved",
"date": "2026-08-01T00:00:00.000Z",
"timelineCategoryId": "tlcat-2"
}
]
}
}
Best Practices
| Practice | Why |
|---|---|
Respond with 200 immediately | Processing > 15s triggers a retry. Do heavy work async. |
| Handle duplicates | Use entityId + occurredAt as idempotency key — retries can cause duplicate delivery. |
| Use data filters | Reduce payload size by selecting only the fields you need. |
| Monitor delivery logs | Catch failures early in the Backoffice. |
| Keep your secret safe | Store it in environment variables, never commit to source control. |