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.
Annotate the payload
Section titled “Annotate the payload”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.
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, ) {}}"webhooks": { "invoice.paid": { "post": { "operationId": "invoice.paid", "summary": "An invoice was paid.", "description": "Delivered once payment has settled, and retried with an exponential backoff until your\nendpoint answers with a 2xx.", "tags": ["Billing"], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InvoicePaid" } } } }, "responses": { "200": { "description": "Delivery accepted." } } } }}The class docblock becomes the summary and description, so the prose a consumer reads is the prose your team already keeps beside the code.
Send a different class than you annotate
Section titled “Send a different class than you annotate”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.
The rest of the entry
Section titled “The rest of the entry”| 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 { /* … */ }Where webhooks end up
Section titled “Where webhooks end up”| 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.
Test what you send
Section titled “Test what you send”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.
What Docuccino tells you
Section titled “What Docuccino tells you”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.