Appearance
Webhooks
Pro & EnterpriseWebhooks require a Pro or Enterprise plan.
Compare plansWebhooks push project lifecycle events to your server in real time. Subscribe a URL to the events you care about and Layota will POST a signed JSON payload whenever one of them fires — no polling required.
All endpoints require JWT authentication (API keys cannot manage webhooks). The caller must be the organization's owner or admin.
Events
| Event | Fires when |
|---|---|
project.published | A project transitions from Draft to Published |
project.updated | A published project's live snapshot is updated |
project.unpublished | A published project transitions back to Draft |
project.archived | A previously published project is archived |
project.deleted | A previously published project is deleted |
Only previously published projects trigger archived/deleted. Draft-only projects do not emit events.
webhook.ping is a synthetic event delivered by POST /test/ — handy for verifying your endpoint during setup.
Endpoint Limits
- Maximum 20 webhooks per organization.
- Webhook URLs must use HTTPS (plain
http://is only allowed in local development). - URLs that resolve to private, loopback, or link-local addresses are rejected to prevent SSRF.
List Webhooks
http
GET /api/webhooks/?organization={org_id}
Authorization: Bearer <token>Response:
json
[
{
"id": "wh-uuid",
"url": "https://api.your-company.com/webhooks/layota",
"description": "Production CRM sync",
"events": ["project.published", "project.updated"],
"is_active": true,
"consecutive_failures": 0,
"last_delivery_at": "2026-04-20T12:00:00Z",
"last_success_at": "2026-04-20T12:00:00Z",
"created_at": "2026-04-15T10:00:00Z",
"updated_at": "2026-04-20T12:00:00Z",
"created_by": { "email": "owner@example.com", "full_name": "Owner" }
}
]Create Webhook
http
POST /api/webhooks/
Authorization: Bearer <token>
Content-Type: application/json
{
"organization": "org-uuid",
"url": "https://api.your-company.com/webhooks/layota",
"description": "Production CRM sync",
"events": ["project.published", "project.updated"],
"is_active": true
}Response 201 Created:
json
{
"id": "wh-uuid",
"url": "https://api.your-company.com/webhooks/layota",
"description": "Production CRM sync",
"events": ["project.published", "project.updated"],
"is_active": true,
"secret": "whsec_a1b2c3d4e5f6...",
"consecutive_failures": 0,
"last_delivery_at": null,
"last_success_at": null,
"created_at": "...",
"updated_at": "...",
"created_by": { "email": "owner@example.com", "full_name": "Owner" }
}Store the secret now
The secret field is returned only on create and on Regenerate Secret. It cannot be recovered later. Use it to verify every incoming request.
Get Webhook
http
GET /api/webhooks/{id}/?organization={org_id}
Authorization: Bearer <token>Same body as List, but secret is never included.
Update Webhook
http
PATCH /api/webhooks/{id}/?organization={org_id}
Authorization: Bearer <token>
Content-Type: application/json
{
"url": "https://api.your-company.com/webhooks/layota-v2",
"events": ["project.published"],
"is_active": false,
"description": "Paused during migration"
}Any subset of url, description, events, is_active can be patched.
After a plan downgrade (to a plan without webhooks), editing url/events/is_active is blocked — upgrade to re-enable. description can still be edited and the webhook can still be deleted.
Delete Webhook
http
DELETE /api/webhooks/{id}/?organization={org_id}
Authorization: Bearer <token>Returns 204 No Content. Associated delivery history is deleted with the webhook (retention is 30 days).
Regenerate Secret
http
POST /api/webhooks/{id}/regenerate-secret/?organization={org_id}
Authorization: Bearer <token>Response: webhook body including the new secret. The old secret is invalidated immediately — update your verification code before calling this.
Send Test Ping
http
POST /api/webhooks/{id}/test/?organization={org_id}
Authorization: Bearer <token>Sends a synthetic webhook.ping delivery to the endpoint and returns the delivery record (status code, response body, duration, error if any). Subject to a 10/minute per-user rate limit.
List Deliveries
http
GET /api/webhooks/{id}/deliveries/?organization={org_id}
Authorization: Bearer <token>Paginated (20 per page, newest first). Optional query params:
| Param | Values |
|---|---|
status | success / failed |
event | event name (e.g. project.published, webhook.ping) |
page | page number |
Response:
json
{
"count": 42,
"next": "...?page=2",
"previous": null,
"results": [
{
"id": "dlv-uuid",
"event": "project.published",
"status": "success",
"status_code": 200,
"response_body": "{\"ok\":true}",
"is_response_body_truncated": false,
"duration_ms": 142,
"error": "",
"created_at": "2026-04-20T12:00:00Z",
"payload": { "id": "evt_...", "event": "...", "created_at": "...", "data": { ... } }
}
]
}Response bodies are truncated at 1024 bytes (is_response_body_truncated: true when this happens).
Delivery Format
Request
Every delivery is a POST with Content-Type: application/json:
http
POST /webhooks/layota HTTP/1.1
Content-Type: application/json
User-Agent: Maplayota-Webhooks/1.0
X-Layota-Event: project.published
X-Layota-Delivery: evt_1713600000000
X-Layota-Timestamp: 1713600000
X-Layota-Signature: sha256=3a1f…
{
"id": "evt_1713600000000",
"event": "project.published",
"created_at": "2026-04-20T12:00:00Z",
"data": {
"project_id": "550e8400-...",
"name": "My Mall"
}
}Headers
| Header | Description |
|---|---|
X-Layota-Event | Event name — same as event in the body |
X-Layota-Delivery | Unique delivery ID — use for idempotency |
X-Layota-Timestamp | Unix timestamp (seconds) at send time |
X-Layota-Signature | HMAC-SHA256 signature, sha256= prefix |
Payload (data field)
| Event | data shape |
|---|---|
project.published | { "project_id": "...", "name": "..." } |
project.updated | { "project_id": "...", "name": "..." } |
project.unpublished | { "project_id": "...", "name": "..." } |
project.archived | { "project_id": "...", "name": "..." } |
project.deleted | { "project_id": "...", "name": "..." } |
webhook.ping | { "organization_id": "...", "sent_by": "email@..." } |
Verifying Signatures
Layota signs every request with HMAC-SHA256. The signed string is {timestamp}.{body} (same construction as Stripe). Always verify the signature before acting on the payload — otherwise anyone who learns your URL can forge events.
Node.js
javascript
import crypto from 'crypto'
function verifyLayotaSignature(req, secret) {
const timestamp = req.headers['x-layota-timestamp']
const signature = req.headers['x-layota-signature']
if (!timestamp || !signature) return false
// Reject replays older than 5 minutes
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp))
if (ageSeconds > 300) return false
const body = req.rawBody // raw bytes — parse JSON AFTER verification
const expected =
'sha256=' +
crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
)
}Python
python
import hmac
import hashlib
import time
def verify_layota_signature(request, secret: str) -> bool:
timestamp = request.headers.get('X-Layota-Timestamp')
signature = request.headers.get('X-Layota-Signature')
if not timestamp or not signature:
return False
# Reject replays older than 5 minutes
if abs(time.time() - int(timestamp)) > 300:
return False
body = request.body # raw bytes — parse JSON AFTER verification
message = f"{timestamp}.{body.decode('utf-8')}".encode('utf-8')
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'), message, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)TIP
Verify against the raw request body, not a re-serialized object. Any whitespace or key-order difference breaks the signature.
Delivery Semantics
- Timeout: 5 seconds per request. Any non-2xx response or timeout counts as a failure.
- No retries. A failed delivery is recorded but not retried. Use
X-Layota-Deliveryfor idempotency and List Deliveries to re-check history. - Circuit breaker: after 20 consecutive failures the webhook is automatically set to
is_active: false. Fix the endpoint, thenPATCHis_active: trueto resume. - Redirects are not followed — always point at the final URL.
- Fanout is synchronous: an event is dispatched after the triggering transaction commits, then delivered sequentially to each matching webhook. Keep your endpoints fast.
Best Practices
- Verify the signature on every request. Reject anything with a timestamp older than 5 minutes.
- Respond quickly (under 5s). Do heavy work asynchronously — acknowledge with
200, process later. - Use
X-Layota-Deliveryfor idempotency. Retries aren't automatic, but you may still see duplicates from your own retry logic or the test-ping button. - Rotate the secret (via Regenerate Secret) if you suspect it leaked. Deploy the new secret first; rotation invalidates the old one immediately.
- Monitor failures via List Deliveries. Hitting the circuit breaker silently disables delivery.