Skip to content

Webhooks

Every other page here documents requests your API answers. A webhook is the other direction: a request your API sends, to an endpoint your consumer implements. There is no route to read, so nothing about it can be inferred from routing — you name the payload class, and Docuccino documents it exactly as it documents any other body.

Point the document at your webhook classes

Section titled “Point the document at your webhook classes”

In docuccino.yaml:

documents:
default:
webhooks: { dir: 'app/Webhooks' }

Every class under that directory carrying #[Webhook] is published. Leave the key out and the document has no webhooks member at all.

The annotated class is the delivered body. Give it the shape you send and the schema comes from the same machinery that documents your resources, Data objects and models — nested classes, enums and all.

app/Webhooks/InvoicePaid.php
namespace App\Webhooks;
use Docuccino\Attributes\Group;
use Docuccino\Attributes\Webhook;
/**
* An invoice was paid.
*
* Delivered once payment has settled, and retried with an exponential backoff until your
* endpoint answers with a 2xx.
*/
#[Webhook('invoice.paid')]
#[Group('Billing')]
final readonly class InvoicePaid
{
public function __construct(
public int $invoiceId,
public int $amountInCents,
public string $paidAt,
) {}
}

The class docblock becomes the summary and description, so the prose a consumer reads is the prose your team already keeps beside the code.

Where the payload lives in its own DTO — or where you annotate a domain event that carries more than you actually transmit — name the type you send:

#[Webhook('invoice.paid', payload: InvoicePaidPayload::class)]
final readonly class InvoicePaid { /* … */ }

payload is a type string, read by the same grammar as your docblocks, so a list (list<InvoicePaidPayload>) or an array shape (array{id: int, total: int}) works just as well as a class name. Unqualified names resolve against the webhook file’s own use statements.

Argument Default What it sets
name The key consumers subscribe to, and the operationId.
method 'post' The HTTP method the receiving endpoint must implement.
payload the annotated class The delivered body’s type.
mediaType 'application/json' The media type the body is delivered as.

A webhook class reads the attributes a controller reads, so nothing here is new vocabulary:

Attribute On a webhook class
#[Group] Tags the webhook, so it groups with the endpoints it belongs beside.
#[Response] Documents a status your sender acts on, beside the default 200 acknowledgement.
#[DeprecatedOperation] Marks a webhook you are retiring, and publishes its reason: in the description. The class’s @deprecated docblock tag says the same thing.
#[Internal] Flags it x-internal: true.
#[InDocs] Pins it to named documents.
#[ExcludeFromDocs] Keeps it out entirely.
#[Webhook('invoice.paid', method: 'put')]
#[Response(status: 202, description: 'Queued for asynchronous processing.')]
final readonly class InvoicePaid { /* … */ }
Artifact Webhooks
UIR, OpenAPI 3.2, OpenAPI 3.1 Published under webhooks.
OpenAPI 3.0 Dropped — 3.0 defines no webhooks member. Each dropped name is listed in a downlevel.webhooks warning; keep the 3.1 or 3.2 artifact for consumers who need the contract.
Postman Dropped, with a postman.webhooks-dropped warning — a collection describes requests you send to an API.

docuccino:diff compares webhooks as the operations they are, under webhooks.<name> in place of a path, so removing one or narrowing its payload is a breaking change like any other.

A webhook is the half of your contract no HTTP test touches: nothing in your suite calls it, so a payload that drifts from its own documentation reaches your subscribers before it reaches you. assertValidWebhook() holds the payload you are about to dispatch to the schema this page just published for it:

use App\Webhooks\InvoicePaid;
it('delivers a payload subscribers can rely on', function () {
$this->assertValidWebhook('invoice.paid', new InvoicePaid(42, 1250, '2026-03-01T09:00:00Z'));
});

Pass the payload in whatever form your code holds it — the object, an array, JSON text. Full setup, what the failures look like and the rest of the contract assertions are on Contract testing.

A directory that isn’t there, a blank or contested name, a payload that resolves to no shape — each is reported against the class it came from. Every code a webhook can raise, how loud it is and what to change, is in the diagnostics reference.

The document lints read webhooks too, and name one POST webhooks.invoice.paid. lint.operation-id-style is on by default, so a name a generated client can’t turn into a method — 1 form submitted! — is caught here as it is on a route; rename the #[Webhook], since that name is the webhook’s operationId.