Skip to content

Webhooks

3 min readLast updated Sep 9, 2026

Webhooks let you be notified when events occur — a matter is created, an invoice is issued, a work item is approved — whether triggered by a user or via the API.

See the webhooks endpoint in the REST API docs for a full list of available event types.

Send a PUT to v1/webhooks to create or update a subscription:

public async Task Subscribe(Guid id, IList<string> events) {
    var request = new HttpRequestMessage(HttpMethod.Put, "v1/webhooks") {
        Content = JsonContent.Create(new {
            id = $"webhook_{id:n}",
            enabled = true,
            url = receiverUrl,
            secret = "12345678-0000-0000-0000-123456789012",
            events = events
        })
    };
    await Send(request);
}

The id must follow the format webhook_<guid-no-dashes> (e.g. webhook_12345678000000000000123456789012). Use a hard-coded value so re-initialisation doesn’t create duplicate subscriptions.

Each subscription supports multiple events. The maximum is 5 webhook subscriptions per tenant.

The event name used when registering a subscription is not necessarily the same as the event name delivered in the payload. For example, subscribe to PE.Mk2.Accounting.V1.ClientCreated; the delivered type is PE.Mk2.App.ApiTypes.V1.ClientCreated.

Create and host a HTTPS endpoint and register its URL. Deliveries are POST requests. The request body is an envelope containing a requestId, a type, and event-specific data; for ClientCreated, the payload shape is:

{
    "requestId": "9f7ab30c-8bd4-43ac-826e-ddccf54f9979",
    "type": "PE.Mk2.App.ApiTypes.V1.ClientCreated",
    "data": {
        "id": "client_...",
        "data": {
            "id": "client_..."
        }
    }
}

Return a 2xx response after accepting the event.

Webhook delivery is at least once. Your receiver must be idempotent: it may receive an event more than once, including when it successfully processed an earlier attempt but the sender did not record the response.

Do not use X-PE2-REQUEST-ID as an idempotency key. The sender creates a new request ID for every delivery attempt, including retries. Instead, use a durable, event-specific key from the payload and make recording that key and performing the associated side effect atomic.

Events are delivered in order for an individual subscription. If an event fails, later events for that subscription wait until it succeeds or the subscription is disabled. No ordering is guaranteed between separate webhook subscriptions.

Any network error or non-2xx response is retried. The current default policy uses exponential backoff from 10 seconds to 3 minutes with up to 20% random jitter. The sender retries for up to 3 days of consecutive failures, then disables the subscription. These timing values may change.

By default, the delivery request has a 30-second timeout. Respond promptly and hand longer processing work to an asynchronous job after recording the event.

Every delivery includes these headers. Validate the signature and timestamp before processing the body:

Header Description Example
X-PE2-REQUEST-ID Unique request identifier 12345678-0000-0000-0000-123456789012
X-PE2-TIMESTAMP UTC time the request was sent, in round-trip ISO 8601 format 2025-10-12T14:30:45.1234567Z
X-PE2-WEBHOOK-SIGNATURE HMACSHA256: followed by the Base64 HMAC-SHA256 of `requestId timestamp`
X-PE2-TENANT-ALIAS Alias of the tenant that emitted the event acme-sandbox

The signing key is the subscription secret parsed as a GUID and converted with .ToByteArray(). The signed value is the UTF-8 text {requestId}|{timestamp}.

Invalid signatures and stale timestamps produce a non-2xx response in the example receiver, so they are retried under the delivery policy. Monitor these responses to find an incorrect secret or a receiver with clock skew.

Example ASP.NET Core receiver:

[HttpPost]
public ActionResult Receive([FromBody] JsonElement body)
{
    if (!IsValidSignature(Request)) return Unauthorized();
    if (!IsValidTimestamp(Request.Headers["X-PE2-TIMESTAMP"])) return BadRequest("Stale request");

    // Process idempotently. X-PE2-REQUEST-ID changes on retries, so use an
    // event-specific domain key from the body before performing side effects.
    return Ok();
}

private static bool IsValidSignature(HttpRequest request)
{
    if (_signingKey == null) return false;

    var requestId = request.Headers["X-PE2-REQUEST-ID"];
    var timestamp = request.Headers["X-PE2-TIMESTAMP"];
    var receivedSignature = request.Headers["X-PE2-WEBHOOK-SIGNATURE"];

    using var hmac = new HMACSHA256(_signingKey.Value.ToByteArray());
    var computedHash = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes($"${requestId}|${timestamp}")));
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(receivedSignature.ToString()),
        Encoding.UTF8.GetBytes($"HMACSHA256:${computedHash}"));
}

private static bool IsValidTimestamp(string timestamp)
{
    return DateTimeOffset.TryParse(timestamp, out var sentAt)
        && Math.Abs((DateTimeOffset.UtcNow - sentAt).TotalMinutes) <= 5;
}