Skip to content

Webhooks

Pro & EnterpriseWebhooks require a Pro or Enterprise plan.
Compare plans

Webhooks 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

EventFires when
project.publishedA project transitions from Draft to Published
project.updatedA published project's live snapshot is updated
project.unpublishedA published project transitions back to Draft
project.archivedA previously published project is archived
project.deletedA 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:

ParamValues
statussuccess / failed
eventevent name (e.g. project.published, webhook.ping)
pagepage 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

HeaderDescription
X-Layota-EventEvent name — same as event in the body
X-Layota-DeliveryUnique delivery ID — use for idempotency
X-Layota-TimestampUnix timestamp (seconds) at send time
X-Layota-SignatureHMAC-SHA256 signature, sha256= prefix

Payload (data field)

Eventdata 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-Delivery for 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, then PATCH is_active: true to 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-Delivery for 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.

Layota Documentation