Skip to content

Webhooks

Webhooks tell other applications what happened: a record was created, a workflow moved, a task was done. The API answers questions, webhooks send news. Together they connect Anvil applications with each other and with other systems, without a message broker.

  • Standard formats. The body is a CloudEvents 1.0 event, the signature follows Standard Webhooks. Libraries exist for many languages, and checking by hand takes a few lines.
  • Nothing gets lost. Events go through the outbox: written in the transaction of the change, delivered by anvil:tick, retried for about a day and a half.
  • Thin by default. An event says what happened to which record. The receiver fetches details with its API client, so the permissions of the API apply.
app/Config/Anvil.php
public bool $webhooks = true;

Run php spark migrate --all after the update to 0.5.0. Access → Webhooks lists the subscriptions of the tenant (permissions webhooks.view, webhooks.create, webhooks.update, webhooks.delete).

Field Meaning
URL HTTPS endpoint of the receiver
Events names or patterns: record.*, record.updated, workflow.transitioned, task.completed, own events, *
Kinds of records tables such as orders; empty for all
Include changed values send the changed values as well, masked like in the audit log
Responsible person is notified when the webhook is disabled

The detail page shows the deliveries and offers:

  • Create secret / New secret. The secret is shown once. Without a secret nothing is delivered. After a rotation the old secret signs as well for $webhookSecretOverlapHours (24), so the receiver can switch without losing messages.
  • Send test event. Queues a webhook.ping for this subscription only.
  • Send again / Cancel for single deliveries.
POST /hooks/anvil HTTP/1.1
Content-Type: application/cloudevents+json; charset=utf-8
webhook-id: 0193a6b4-7c1e-7d2f-9a51-3f0c2b8e4d17
webhook-timestamp: 1793952000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"specversion": "1.0",
"id": "0193a6b4-7b90-7c44-8f0e-6d1a2c3b4e5f",
"type": "workflow.transitioned",
"source": "https://iam.example.com",
"subject": "0193a5f0-1c2d-7e3f-8a4b-5c6d7e8f9a0b",
"time": "2026-11-05T09:12:44+00:00",
"datacontenttype": "application/json",
"tenant": "0192f1e0-0000-7000-8000-000000000001",
"data": {
"kind": "orders",
"uuid": "0193a5f0-1c2d-7e3f-8a4b-5c6d7e8f9a0b",
"changed": ["state"],
"data": {"workflow": "order", "transition": "accept", "from": "submitted", "to": "processing", "override": false},
"api": "/api/v1/orders/0193a5f0-1c2d-7e3f-8a4b-5c6d7e8f9a0b"
}
}
Field Meaning
id UUID of the event; the same for every subscription
type name of the domain event
source $webhookSource, by default the base URL of the application
subject, data.uuid UUID of the record
tenant UUID of the tenant
data.kind kind of record, the table
data.changed names of the changed fields
data.data what the event adds, e.g. the transition
data.api path of the record in the API, if a resource offers it
data.changes, data.actor only with include changed values: {"field": [old, new]}, masked, without ids and foreign keys (*_id); who did it

webhook-id is the id of the delivery. It stays the same for every retry, so the receiver can recognise repetitions.

Answer What happens
2xx delivered
408, 425, 429, 5xx, timeout, no connection retried with increasing pauses (see Outbox)
other 4xx, 3xx failed at once; redirects are not followed

Answer quickly (within $webhookTimeout, 10 seconds) and do the work afterwards. A subscription that has not delivered anything for $webhookDisableAfterDays (3) days and has failed messages is disabled. Its responsible person is notified. Enable it again and send the failed messages again on the detail page or with php spark anvil:outbox --retry <uuid>.

Messages are delivered at least once and in no guaranteed order. Ignore a webhook-id you have seen before, and compare time when the order matters.

The signature is a base64 HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<body>. The key is the base64 part of the secret after whsec_. During a rotation the header holds several signatures, separated by spaces. Without Anvil, in plain PHP:

$secret = getenv('ANVIL_WEBHOOK_SECRET'); // whsec_...
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$body = file_get_contents('php://input');
$key = base64_decode(substr($secret, 6));
$expected = 'v1,' . base64_encode(hash_hmac('sha256', $id . '.' . $timestamp . '.' . $body, $key, true));
$valid = false;
foreach (explode(' ', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '') as $signature) {
$valid = $valid || hash_equals($expected, $signature);
}
if (! $valid || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
// Ignore ids you have seen before, then store the event and answer quickly.
http_response_code(202);

Compare the time as well: an old, recorded message must not be accepted again.

service('webhookReceiver') checks the signature and the time and refuses message ids it has seen before (kept $webhookInboxDays, 30 days). Hand the work to the outbox, so that the sender gets its answer at once:

use Anvil\Webhooks\WebhookException;
final class IamWebhook extends BaseController
{
public function receive(): ResponseInterface
{
try {
$event = service('webhookReceiver')->receive($this->request, 'iam', [env('iam.webhookSecret')]);
} catch (WebhookException) {
return $this->response->setStatusCode(401);
}
if ($event !== null) {
service('tenantContext')->set(1); // the tenant this sender delivers to
service('outbox')->push('iam-event', $event, 'iam:' . $event['id']);
}
return $this->response->setStatusCode(202);
}
}

The route must not need a session or a CSRF token. Keep the secret in .env. During a rotation pass both secrets: [env('iam.webhookSecret'), env('iam.webhookSecretPrevious')].

  • HTTPS only. $webhookAllowHttp allows http:// for tests and closed networks.
  • No internal addresses. The host is resolved before every delivery. Private, loopback, link-local and reserved addresses are refused, and the connection goes to the checked address. Allow internal applications explicitly: $webhookAllowedNetworks = ['10.20.0.0/16'].
  • Proxy. $webhookProxy sends deliveries through a proxy. The address is then resolved by the proxy and not pinned.
  • Secrets are stored encrypted with the key of the application and encrypted again by anvil:rekey.
  • Audit. Changes of subscriptions, webhook.secret_rotated, webhook.pinged, webhook.disabled and failed deliveries (outbox.failed) are in the audit log.
  • Payloads are kept $outboxDays (30) days and then deleted by anvil:prune.
Setting Default Meaning
$webhooks false turns webhooks on; while off, writes look up nothing
$webhookSource base URL source of the events
$webhookTimeout 10 seconds per delivery
$webhookAllowHttp false allow http://
$webhookAllowedNetworks [] private networks that may be called, as CIDR
$webhookProxy '' proxy for deliveries
$webhookSecretOverlapHours 24 hours the previous secret still signs
$webhookDisableAfterDays 3 days without success before a subscription is disabled
$webhookInboxDays 30 days received message ids are kept

Not yet: subscriptions through the API, mutual TLS, own payload formats.