Webhooks
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.
Creating a subscription
Section titled “Creating a subscription”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.
Receiving events
Section titled “Receiving events”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.
Delivery guarantees
Section titled “Delivery guarantees”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.
Retries and timeouts
Section titled “Retries and timeouts”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.
Verifying signatures
Section titled “Verifying signatures”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;
}