# Webhooks

> Signed HTTPS notifications for every event: endpoints, Standard Webhooks headers, verifiers in Node, Python, Go and PHP, retries, ordering and rotation.

Updated 2026-10-24 · HTML: https://layercrew.com/developers/webhooks · Markdown: https://layercrew.com/developers/webhooks.md · OpenAPI: https://layercrew.com/openapi.json

A webhook endpoint is an HTTPS URL of yours that receives every event — or the types you pick — as a signed POST, soon after it happens. Endpoints are per environment; your plan sets how many you may register (`webhook_endpoints_max`).

## Register an endpoint

```bash
curl -X POST "https://engine.layercrew.com/v1/webhook-endpoints" \
  -H "Authorization: Bearer $LAYERCREW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://shop.example.com/webhooks/layercrew",
  "description": "Production events",
  "event_types": [
    "order.accepted",
    "order.rejected",
    "order.shipped",
    "order.delivered",
    "order.cancelled"
  ]
}'
```

_201 — the secret is shown once_

```json
{
  "data": {
    "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
    "url": "https://shop.example.com/webhooks/layercrew",
    "description": "Production events",
    "environment": "live",
    "event_types": [
      "order.accepted",
      "order.rejected",
      "order.shipped",
      "order.delivered",
      "order.cancelled"
    ],
    "enabled": true,
    "disabled_reason": null,
    "consecutive_failures": 0,
    "last_success_at": null,
    "secret_rotated_at": null,
    "created_at": "2026-10-24T09:40:00Z",
    "secret": "whsec_c2VjcmV0LWJ5dGVzLWZvci1kb2NzLW9ubHk="
  }
}
```

- `url` is HTTPS on port 443 or 8443, on a public host that resolves, without credentials — `422 ENDPOINT_URL_INVALID` otherwise. Private and link-local addresses are refused at creation and again at every dial.
- `event_types` is null for every event, or a list of names from the [lifecycle page](https://layercrew.com/developers/lifecycle#event-types) — `422 EVENT_TYPE_INVALID` for a name outside it.
- `secret` is `whsec_` followed by the base64 of 24 random bytes. It is shown once; store it in your secret manager.
- `PATCH` changes `url`, `description`, `event_types` and `enabled`; `DELETE` removes the endpoint and its pending deliveries.

## What a delivery looks like

```http
POST /webhooks/layercrew HTTP/1.1
Host: shop.example.com
Content-Type: application/json
User-Agent: LayerCrew-Webhooks/1.0 (+https://layercrew.com/developers/webhooks)
webhook-id: d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f80
webhook-timestamp: 1793196312
webhook-signature: v1,Yk9tTk5xZ3pRQ3pFQlJ6R1Nnd0RkWlZ4N2Fqb1VqeHNuV3N0Y3F0VmVHaz0=
X-LayerCrew-Event: order.shipped
X-LayerCrew-Sequence: 1234
X-Request-ID: 01J9Z8Y7X6W5V4U3T2S1R0Q9P8

{"id":"9c8b7a6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d","type":"order.shipped","api_version":"2026-10-24","created_at":"2026-10-27T14:05:12Z","seq":1234,"environment":"live","data":{"order":{…}}}
```

| Header | Meaning |
| --- | --- |
| `webhook-id` | The delivery id — the same across every retry of this delivery. Deduplicate on it. |
| `webhook-timestamp` | Unix seconds of this attempt. Refuse it when more than five minutes from your clock, either way. |
| `webhook-signature` | `v1,<base64>` — one entry, or two separated by a space during a secret rotation. |
| `X-LayerCrew-Event` | The event type, so you can route before parsing. |
| `X-LayerCrew-Sequence` | The event's `seq`. |
| `X-Request-ID` | Our id for this attempt; it appears in the delivery log. |

## Verifying the signature

The scheme is Standard Webhooks, so any of its libraries verifies a LayerCrew delivery. By hand: take the characters after `whsec_` and base64-decode them — that is the HMAC key. Build the signed content `<webhook-id>.<webhook-timestamp>.<raw body>` from the exact bytes you received, before any parsing. Compute HMAC-SHA256 over it, base64-encode the result, and compare it in constant time against each `v1,` entry of the header. Refuse a timestamp more than five minutes from your clock. Pass both secrets during a rotation.

_Read the raw body (express.raw(), not express.json()); secrets is [current] or [current, previous]_

```node
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifyLayerCrewWebhook(headers, rawBody, secrets) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];
  if (!id || !timestamp || !signature) return false;
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(skew) || skew > TOLERANCE_SECONDS) return false;

  const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
  const candidates = signature.split(" ").filter((s) => s.startsWith("v1,")).map((s) => s.slice(3));
  for (const secret of secrets) {
    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = Buffer.from(createHmac("sha256", key).update(signed).digest("base64"));
    for (const candidate of candidates) {
      const given = Buffer.from(candidate);
      if (given.length === expected.length && timingSafeEqual(given, expected)) return true;
    }
  }
  return false;
}
```

_raw_body is request.get_data() (Flask) or await request.body() (FastAPI), never the parsed JSON_

```python
import base64, hashlib, hmac, time

TOLERANCE_SECONDS = 300

def verify_layercrew_webhook(headers: dict, raw_body: bytes, secrets: list[str]) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature = headers.get("webhook-signature")
    if not (msg_id and timestamp and signature):
        return False
    try:
        if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
            return False
    except ValueError:
        return False

    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    candidates = [s[3:] for s in signature.split(" ") if s.startswith("v1,")]
    for secret in secrets:
        key = base64.b64decode(secret.removeprefix("whsec_"))
        expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
        if any(hmac.compare_digest(expected, c) for c in candidates):
            return True
    return False
```

_body is the exact request body (io.ReadAll(r.Body) before any decoding)_

```go
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const tolerance = 5 * time.Minute

// Verify checks a LayerCrew delivery; secrets is the current secret, plus the previous one during a rotation.
func Verify(h http.Header, body []byte, secrets []string, now time.Time) error {
	id, ts, sig := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
	if id == "" || ts == "" || sig == "" {
		return errors.New("webhook: missing webhook-id, webhook-timestamp or webhook-signature")
	}
	unix, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return errors.New("webhook: webhook-timestamp is not unix seconds")
	}
	if d := now.Sub(time.Unix(unix, 0)); d > tolerance || d < -tolerance {
		return errors.New("webhook: timestamp outside the tolerance window")
	}
	signed := append([]byte(id+"."+ts+"."), body...)
	for _, secret := range secrets {
		key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
		if err != nil {
			continue
		}
		mac := hmac.New(sha256.New, key)
		mac.Write(signed)
		expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
		for _, candidate := range strings.Fields(sig) {
			version, given, ok := strings.Cut(candidate, ",")
			if ok && version == "v1" && subtle.ConstantTimeCompare([]byte(expected), []byte(given)) == 1 {
				return nil
			}
		}
	}
	return errors.New("webhook: no matching signature")
}
```

_$rawBody = file_get_contents('php://input'); $headers with lower-case names_

```php
<?php
const TOLERANCE_SECONDS = 300;

function verifyLayerCrewWebhook(array $headers, string $rawBody, array $secrets): bool
{
    $id = $headers['webhook-id'] ?? '';
    $timestamp = $headers['webhook-timestamp'] ?? '';
    $signature = $headers['webhook-signature'] ?? '';
    if ($id === '' || $timestamp === '' || $signature === '') {
        return false;
    }
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }

    $signed = $id . '.' . $timestamp . '.' . $rawBody;
    $candidates = [];
    foreach (explode(' ', $signature) as $entry) {
        if (str_starts_with($entry, 'v1,')) {
            $candidates[] = substr($entry, 3);
        }
    }
    foreach ($secrets as $secret) {
        $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
        if ($key === false) {
            continue;
        }
        $expected = base64_encode(hash_hmac('sha256', $signed, $key, true));
        foreach ($candidates as $candidate) {
            if (hash_equals($expected, $candidate)) {
                return true;
            }
        }
    }
    return false;
}
```

- **Read the raw body.** A re-serialised JSON body has different bytes and the signature fails.
- **Answer `2xx` within 10 seconds**, then do the work. Queue it if it takes longer; a slow handler is retried as a failure.
- **Treat every refusal the same.** A missing header, a stale timestamp and a wrong signature all mean the request is not from LayerCrew.
- **Deduplicate on `webhook-id`** and order by `seq`, never by arrival time.

## Retries

A delivery is tried until your endpoint answers `2xx`, up to `webhook_max_attempts` — 12 on the default plan; it can differ per plan — on this ladder, each wait with ±20 percent jitter:

| Attempt | Wait before it |
| --- | --- |
| 1 | at once |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 and later | 6 hours |

Twelve attempts span about two days. A `Retry-After` header on your `429` or `503` is honoured, up to 6 hours. How we read your answer:

| Your answer | What happens |
| --- | --- |
| `2xx` | Delivered. `last_success_at` and the attempt are recorded. |
| `410 Gone` | The endpoint is disabled at once; you get `webhook_endpoint.disabled` and an email. |
| `429`, `408`, `425`, any `5xx`, a timeout or a connection failure | Retried on the ladder, honouring `Retry-After`; nothing is counted against the endpoint. |
| `3xx` and every other `4xx` | Retried on the ladder and counted as a refusal. Redirects are never followed. |

## Ordering

Deliveries to one endpoint go out in `seq` order, one at a time: a delivery that keeps failing holds the ones behind it until it succeeds, runs out of attempts or you redeliver it. Across endpoints there is no ordering. Your handler should be idempotent on `webhook-id` and read `seq` to order events; the feed `GET /v1/events?after=` is the authoritative order when in doubt.

## Automatic disabling

An endpoint is disabled when 20 consecutive deliveries were refused, when it has had no success for 72 hours and a delivery ran out of attempts, or at once on a `410`. You get `webhook_endpoint.disabled` on the feed and an email; its pending deliveries are marked `skipped`. Re-enable with `PATCH /v1/webhook-endpoints/{id}` and `{"enabled": true}`: the failure count resets and the deliveries skipped in the last 7 days are queued again, in order. A disabled endpoint answers `409 ENDPOINT_DISABLED` to ping and redeliver.

## Rotating the secret

`POST /v1/webhook-endpoints/{id}/rotate-secret` returns a new secret once. For 24 hours every delivery carries two signatures — `v1,<new> v1,<old>` — so you deploy the new secret at your own pace; after that only the new one signs. `webhook_endpoint.secret_rotated` marks the moment.

## Testing and the delivery log

`POST /v1/webhook-endpoints/{id}/ping` queues a `test.ping` with an empty `data` so you can verify your signature check before going live. The delivery log — `GET /v1/webhook-deliveries` with `endpoint_id`, `status` and `event_id` filters, and `GET /v1/webhook-deliveries/{id}` — shows every attempt with status code, duration, our request id and the first kilobyte of your response. `POST /v1/webhook-deliveries/{id}/redeliver` sends the exact original bytes again with a fresh signature and timestamp; a delivery in flight answers `409 DELIVERY_IN_FLIGHT`. The same log is under Integrations → Webhooks in your dashboard.

> **Polling is the truth.** Webhooks are at-least-once and may arrive late. `GET /v1/events?after=<seq>` is the authoritative, gap-free order of everything; reconcile against it on a timer and on every restart. See [Lifecycle and events](https://layercrew.com/developers/lifecycle#polling).

Every endpoint is in the [reference](https://layercrew.com/developers/reference#tag-webhooks).
