---
title: "Endpoints"
description: "`webhooks->list`, `listAll`, `iterate`, `get`, `create`, `update`, `delete`, `rotateSecret`, `test`, `getDelivery` and `replayDelivery`, and the delivery and activity logs."
url: "https://nextmail.sh/docs/php/webhooks/endpoints"
area: "PHP"
category: "Webhooks"
---

# Endpoints

`webhooks->list`, `listAll`, `iterate`, `get`, `create`, `update`, `delete`, `rotateSecret`, `test`, `getDelivery` and `replayDelivery`, and the delivery and activity logs.

## Every method

**webhooks.php**

```
use Nextmail\Constants\WebhookEvents;

$endpoint = $client->webhooks->create([
    'url' => 'https://acme.com/hooks/mail',
    'eventTypes' => [WebhookEvents::EMAIL_SENT, WebhookEvents::EMAIL_BOUNCED],
    'description' => 'Billing service',
]);

file_put_contents('.nextmail-webhook-secret', $endpoint['secret']);

$client->webhooks->list();
$client->webhooks->get($endpoint['id']);
$client->webhooks->update($endpoint['id'], ['enabled' => false]);
$client->webhooks->test($endpoint['id']);

foreach ($client->webhooks->listDeliveries($endpoint['id'], limit: 1) as $latest) {
    $client->webhooks->getDelivery($endpoint['id'], $latest['id']);
    $client->webhooks->replayDelivery($endpoint['id'], $latest['id']);
}

$rotated = $client->webhooks->rotateSecret($endpoint['id']);
file_put_contents('.nextmail-webhook-secret', $rotated['secret']);

$client->webhooks->delete($endpoint['id']);
```

`create` is the ONLY time the secret is returned, apart from `rotateSecret`. A read never echoes it, so store it before doing anything else. Omit `eventTypes` for the default set, every `email.*` event except `email.replied`. `email.replied`, `domain.*`, `suppression.*`, `file.*`, `form.*`, `automation.*`, `contact.*`, `sending.*`, `connector.*`, `task.*`, `approval.*`, `booking.*` and `broadcast.*` reach an endpoint only when it names them.

`list` returns one `Nextmail\Result\Page`, `listAll` returns every endpoint in one array, and `iterate` returns a `Generator` that yields one endpoint at a time. `create` and `update` take the body as one array under the API’s names, and every endpoint comes back as an array keyed in camelCase.

> `rotateSecret` keeps the old secret signing beside the new one for 24 hours, so a receiver that still holds it keeps verifying while you deploy the new one, and `previousSecretExpiresAt` says when that ends. It is never retried automatically, because a retry would rotate a second time and mint another secret.

> `create` is not retried either, so a network failure can leave an endpoint created with a secret you never saw. Check `list` before you create it again. A workspace holds 10 endpoints by default, and the next one past the limit is a 422 `workspace_limit_reached`.

## What you can subscribe to

`Nextmail\Constants\WebhookEvents` names every event as a constant, and `WebhookEvents::values()` lists them, so you can render the list without a request. `webhooks->listEvents` returns the same names with a label for each, plus the limits an endpoint is held to in `maxEndpoints`, `maxAddresses` and `maxDomains`. `email.received` fires for mail that arrives in the workspace, and `email.sent` fires for every message that goes out, whether this API, the composer, an automation or a mail app over SMTP sent it.

`file.uploaded` fires when a file is put on the Files page, and `file.deleted` when one is deleted. Their `data` holds `fileId`, `filename`, `mimeType`, `sizeBytes`, `direction`, `to`, `threadId`, `messageId`, and `uploadedAt` or `deletedAt`. `to` is the address the file belongs to, or null for a file that belongs to the whole workspace.

> The file events are not in the default set, so an endpoint receives them only when it names them in `eventTypes`. An endpoint limited to some addresses hears only about the files of those addresses, so an upload for the whole workspace, with `to` null, is not sent to it.

`form.submitted` fires when someone signs up through one of your forms, and `form.confirmed` when a pending sign-up joins the audiences, because the person opened the confirmation link or because you approved it. The `data` of `form.submitted` holds `formId`, `formName`, `submissionId`, `email`, `status`, `answers`, `audienceIds`, `sourceUrl` and `submittedAt`. The `data` of `form.confirmed` holds `formId`, `formName`, `submissionId`, `email`, `audienceIds`, `via`, which is `link` or `approval`, and `confirmedAt`.

> A sign-up on a form without double opt-in sends `form.submitted` with `status` `added` and no `form.confirmed`, so treat that pair as the moment someone joins. Someone who signs up again before confirming keeps the same `submissionId`, and `form.submitted` fires again only when their answers changed. The form events are not in the default set, and an endpoint limited to some addresses never receives them, because sign-ups belong to the whole workspace.

`automation.entered` fires when a contact enters one of your automations and `automation.exited` when they leave it, finished or not. Both carry `automationId`, `enrollmentId` and `contactId`. `automation.entered` adds `source` and `enteredAt`, and `automation.exited` adds `reason`, `stepKey` and `exitedAt`. `automation.paused` fires when an automation stops, with `automationId`, `reason` and `detail`. `automation.webhook` is what a webhook step sends to the endpoint it names: `automationId`, `enrollmentId`, `stepKey`, `stepName`, the `contact` with its `attributes`, and `data`, the properties of the event that started the run. `automation.resumed` fires when a paused automation is switched back on, with `automationId` and `resumedAt`. An automation also pauses, with `reason` `audience_missing`, when an audience it starts on or records unsubscribes in is deleted, and a contact who is deleted leaves with `reason` `removed`.

> `automation.entered`, `automation.exited`, `automation.paused` and `automation.resumed` are not in the default set, so an endpoint receives them only when it names them in `eventTypes`, and an endpoint limited to some addresses never receives them, because automations belong to the whole workspace. `automation.webhook` is different: a webhook step sends it only to the endpoint the step names, whatever events that endpoint lists and even when the endpoint is limited to some addresses or domains. It is retried and can be replayed like any other delivery, and because no endpoint subscribes to it, the list of events an endpoint can pick from leaves it out. Archiving an automation ends everybody in it without an `automation.exited` for each person.

`domain.verified` fires once, the first time a domain is found verified, whether someone opened the Domains page, a call listed the domains or verified one, the hourly check ran or a DNS connection did it. Its data holds `domainId`, `domain`, `verifiedAt`, `sendingState` and `records`, which is not filled in yet. `domain.sending_changed` fires every time the sending state of a domain changes, whoever notices it first, with `domainId`, `domain`, `previousState`, `state` and `changedAt`. `domain.deleted` carries `domainId`, `domain`, `reason`, which is `removed`, `never_verified` or `moved`, and `deletedAt`.

`suppression.added` fires when an address goes on the suppression list, after a bounce or a complaint or because somebody added it by hand, and `suppression.removed` when one is taken off. Both carry `email`, `reason`, which is `bounce`, `complaint` or `manual`, and `detail`, with `addedAt` or `removedAt`. Suppressions brought in by an import send no event.

`contact.created`, `contact.updated` and `contact.deleted` fire when a contact is added, changed or removed one at a time: in the app, over the REST API, the SDKs, the CLI or MCP, through an address book card, or when recording an event creates the contact. All three carry `contactId`, `email` and `name`. `contact.created` adds `source` and `createdAt`, `contact.updated` adds `updatedAt` and `contact.deleted` adds `deletedAt`.

> They do not fire for an import, for a removal of more than 25 contacts in one call, or for people collected automatically from mail. A form sign-up is `form.submitted`, not `contact.created`.

`contact.unsubscribed` fires when a person uses the unsubscribe link or the one-click unsubscribe of a broadcast or an automation email, and `contact.resubscribed` when they subscribe again from that page. Both carry `contactId`, `email`, `audienceIds`, `broadcastId` and `automationId`, of which exactly one is set, and `unsubscribedAt` or `resubscribedAt`. Someone who signs up again through a form sends `form.submitted`, not `contact.resubscribed`, and someone who unsubscribes while inside an automation also sends `automation.exited` with `reason` `unsubscribed`.

`sending.paused` fires when the broadcast lane is paused because its bounce rate or its complaint rate is too high. It carries `stream`, `reason`, which is `bounce_rate` or `complaint_rate`, `recipients`, `bounced` and `complained`, the counts the decision was made on, and `pausedAt`. `sending.resumed` fires when the owner resumes the lane, with `stream` and `resumedAt`.

> The contact and sending events are not in the default set, so an endpoint receives them only when it names them in `eventTypes`. An endpoint limited to some addresses never receives them, because contacts and the sending lanes belong to the whole workspace.

`connector.connected` fires when a workspace connector starts working: once the first sign-in is finished, and again each time it is reconnected. `connector.disconnected` fires when one is disconnected, and `connector.needs_attention` when one stops working and has to be reconnected. All three carry `id`, `app`, `appName`, `scope` and `access`. `connector.connected` adds `connectedAt`, `connector.disconnected` adds `disconnectedAt`, and `connector.needs_attention` adds `reason`, which is `expired`, `revoked`, `failed` or `disabled`, and `at`.

> The connector events are not in the default set, so an endpoint receives them only when it names them in `eventTypes`. An endpoint limited to some addresses never receives them, because these connectors belong to the whole workspace, and a connector that belongs to one person sends none of them.

Four events tell your systems about tasks: `task.run.completed` when a run finishes, `task.run.failed` when it fails, `task.run.cancelled` when somebody stops it, and `approval.requested` when it waits for a person to allow an action. A run event carries `runId`, `taskId`, `taskName`, `trigger`, `threadId`, `status`, `steps`, `actions`, `error`, `createdAt` and `finishedAt`. `approval.requested` carries `approvalId`, `runId`, `taskId`, `taskName`, `tool`, `expiresAt` and `requestedAt`.

> They carry ids, names and counts, never what a run read or wrote, so read the run or the request for that. They are not in the default set, so an endpoint receives them only when it names them in `eventTypes`, and an endpoint limited to some addresses or domains never does, because a task works across the whole workspace.

Four events tell your systems about booking pages: `booking.created` when a guest books a time, `booking.confirmed` when the host accepts a booking that waited for approval, `booking.rescheduled` when the guest or the host moves it, and `booking.cancelled` when either of them cancels it or the host declines it. Each carries `bookingId`, `pageId`, `status`, `startAt`, `endAt`, `guestName`, `guestEmail`, `guestTimezone`, `location`, `rescheduleCount`, `endedBy`, `reason`, `createdAt` and `at`.

> Only the pages of the workspace owner send them, because the calendar behind the page of another member is theirs alone. They are not in the default set, so an endpoint receives them only when it names them in `eventTypes`, and an endpoint limited to some addresses or domains never does.

Four events follow a broadcast: `broadcast.sending` when it starts going out, `broadcast.sent` when every copy has been handed over for sending, `broadcast.failed` when it stops because it cannot go out, and `broadcast.cancelled` when it is cancelled before it finishes. Each carries `broadcastId`, `status`, `subject`, `fromEmail`, `audienceIds`, `recipients`, `created`, `skipped`, `failed`, `error` and `at`.

> Every copy still sends its own `email.*` events as it goes, and `GET /broadcasts/{id}` reads `sending` until the last one has gone out. These four are not in the default set, so an endpoint receives them only when it names them in `eventTypes`, and an endpoint limited to some addresses or domains never does, because a broadcast goes to the audiences of the whole workspace.

## Proving it works

**webhook_test.php**

```
$result = $client->webhooks->test('whe_3f9c2a7b1e4d8f60a5c7b92d');
echo $result['delivery']['status'], ' ', $result['delivery']['responseCode'] ?? 'no response', PHP_EOL;

foreach ($client->webhooks->iterateDeliveries('whe_3f9c2a7b1e4d8f60a5c7b92d') as $delivery) {
    echo $delivery['eventType'], ' ', $delivery['status'], ' ', $delivery['responseCode'] ?? '-', ' ', $delivery['error'] ?? '', PHP_EOL;
}
```

`test` posts a signed synthetic `email.sent` event and waits for the attempt to finish. It returns normally whatever your receiver answered, so branch on `$result['delivery']['status']`, not on whether the call threw. A 4xx is a useful answer: the URL is reachable and the refusal came from your own handler, often its signature check.

> A `responseCode` of null means there was no response at all (DNS, TLS, a timeout), which is a different fact from a response that said 0. Each row carries `attempt` and `maxAttempts`, so several rows can describe one event: the same `eventId` across them is the event, and the attempt number is the try. `nextAttemptAt` says when the automatic retry after a row is due.

## Sending it again

**webhook_replay.php**

```
$detail = $client->webhooks->getDelivery('whe_3f9c2a7b1e4d8f60a5c7b92d', 'whd_8c1e4a7f2b9d3e6a0c5f1b28');
echo json_encode($detail['payload'], JSON_THROW_ON_ERROR), PHP_EOL;
echo $detail['responseBody'] ?? 'no answer', ' ', $detail['replayRefusal']['code'] ?? 'replayable', PHP_EOL;

$replay = $client->webhooks->replayDelivery('whe_3f9c2a7b1e4d8f60a5c7b92d', 'whd_8c1e4a7f2b9d3e6a0c5f1b28');
echo $replay['delivery']['status'], ' ', $replay['delivery']['responseCode'] ?? 'no response', PHP_EOL;
```

A delivery that keeps failing is tried up to 8 times: as it happens, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, about 27 and a half hours in all. Only a failure worth repeating is repeated: no answer, 408, 425, 429 or a 5xx. A replay sends the stored event again with the same `id`, `type`, `createdAt` and `data`, so a receiver that drops ids it has already handled treats it as the event it knows. Only the signature is new.

- `replayDelivery` sends one event now and returns what your server answered. It works on a delivered attempt too and is never retried. Before it sends, the automatic retries of that event that have not started are paused: they stay cancelled when the replay is delivered, and resume on their schedule when it fails.
- If an automatic retry of the same event is being sent at that moment, `replayDelivery` sends nothing and throws a 409 `retry_in_progress`, and while another replay of it is still being sent it throws a 409 `replay_in_progress`, so your receiver never gets two copies at once, even from two replays sent at the same instant. Wait a few seconds and read `getDelivery`, since that retry or replay may deliver it. Replay is one event at a time: no call sends every failed delivery again.
- It also throws a 409 for a switched-off endpoint (`webhook_disabled`), an event the endpoint no longer listens for (`event_not_subscribed`) or no longer covers (`event_out_of_scope`), and an attempt with no stored event (`delivery_not_replayable`). Each is a `ConflictException`, and `Nextmail\Constants\WebhookReplayErrorCodes` names the codes. `getDelivery` reports that answer in advance as `replayRefusal`, null when a replay would go ahead and otherwise an array with `code` and `message`.

> The package never retries `replayDelivery` on its own, because a retry after a lost response would send the event again.

## Parameters: webhooks->create

- `url` (string, required): Where deliveries are POSTed. HTTPS only, and the host may not be `localhost`, a `.localhost`, `.local` or `.internal` name, or a loopback, private, carrier-grade NAT, link-local, multicast or unique local IP literal. This is a server-side request to an address you supply, so those are a 422 `invalid_webhook_url` on `url`. The check reads the hostname as written, and every delivery looks the host up again and refuses to send to an address in one of those ranges. Deliveries never follow redirects, so register the final address. What is stored is the URL parser’s serialisation of what you sent, so `https://acme.com` reads back as `https://acme.com/`.
- `eventTypes` (array): Which events reach this endpoint: any of the values in `Nextmail\Constants\WebhookEvents`. `create` caps the array at the number of events that exist, so one more than that is a 422 on `eventTypes`, and `update` does not cap it. Only the length is capped, and a repeated name is stored and read back exactly as you sent it. Left out or empty, it is stored as an empty list, which is why it reads back as `['*']`, and it means every `email.*` event except `email.replied`, fourteen today, and never another family. A family added later never reaches an endpoint that did not name it, so an integration cannot start receiving a shape it has never seen because of a release.
- `description` (string): A label for the endpoint, at most 200 characters, so a list of webhooks reads as names rather than a column of URLs. Left out, it is stored and returned as null. Leave the key out rather than passing null: the client sends a null as it is, and `create` refuses it with a 422.
- `addressAllowlist` (array): Single addresses this endpoint hears about. An event is delivered when the address it concerns is on this list, or when its domain is in `domainAllowlist`. Leave both empty and the endpoint hears about every address the workspace owns. At most 50, and an address this workspace does not own is a 422 `invalid_parameter`.
- `domainAllowlist` (array): Whole domains this endpoint hears about, including addresses added to them later. A domain also carries its own `domain.*` events. At most 25.
- `apiKey` (string): A named argument beside the array rather than a key inside it: creates the endpoint with this API key instead of the client’s.

## Response: the created endpoint

An array keyed in camelCase. `get`, `list` and `update` return the same shape without `secret`.

- `object` (string): Always `webhook`, the same discriminator a plain read returns, because the secret is one extra key on the ordinary shape rather than an object type of its own. Whether `secret` is present is decided by which method you called, not by this field.
- `id` (string): The endpoint’s identifier: `whe_` followed by 24 hex characters. Every other webhook call takes it: `get`, `update`, `delete`, `rotateSecret`, `test`, `listDeliveries`, `listAllDeliveries`, `iterateDeliveries`, `getDelivery` and `replayDelivery`.
- `url` (string): The endpoint as stored, having passed the HTTPS and blocked-host checks. It is the parsed URL serialised again, so compare against this value rather than against the string you sent.
- `description` (string or null): The label you gave it, or null if you gave none. An `update` that sends `'description' => null` clears it.
- `eventTypes` (array): The subscribed events, or `['*']` when the endpoint named none. `['*']` is how an empty stored list is rendered on read and cannot be sent back, and it stands for the fourteen message events rather than the whole catalogue. `create` and `update` accept only the literal event names.
- `enabled` (bool): Whether deliveries are attempted. A disabled endpoint is skipped when events are dispatched and keeps its secret and its delivery history. Always true here, since only `update` takes `enabled`.
- `disabledAt` (string or null): When the server switched the endpoint off after 100 failed deliveries in a row. null while it is on, and when you switched it off yourself.
- `disabledReason` (string or null): Why the server switched it off. null whenever `disabledAt` is null.
- `consecutiveFailures` (int): Failed deliveries in a row. Any delivered event resets it to 0, and so does `update` with `enabled` set to true.
- `addressAllowlist` (array): The single addresses this endpoint hears about.
- `domainAllowlist` (array): The whole domains this endpoint hears about. Both lists empty means every address the workspace owns.
- `lastDeliveryAt` (string or null): ISO 8601 timestamp of the last delivery ATTEMPT, not the last success. It is stamped after a failed POST too, so it tells you the endpoint was tried and `listDeliveries` tells you how it went. null until the first attempt, and so always null on `create`.
- `createdAt` (string): ISO 8601 timestamp of when the endpoint was registered. `list` returns endpoints newest first by this field.
- `secret` (string): The HMAC-SHA-256 key that signs each delivery’s `X-OpenEmail-Signature`: `whsec_` followed by 43 base64url characters, and what you pass to `Nextmail::verifyWebhookSignature`, prefix included. Returned by `create` and `rotateSecret` and by nothing else. A read never echoes it, so store it now. A lost secret can only be replaced with `rotateSecret`, which keeps the old one signing for 24 hours.

## Filtering the logs

**webhook_logs.php**

```
$failed = $client->webhooks->listWorkspaceDeliveries(status: 'failed', since: new \DateTimeImmutable('-1 day'));

foreach ($failed as $delivery) {
    echo $delivery['endpointId'], ' ', $delivery['eventType'], ' ', $delivery['responseCode'] ?? '-', PHP_EOL;
}

$history = $client->webhooks->listActivity('whe_3f9c2a7b1e4d8f60a5c7b92d');

foreach ($history as $change) {
    echo $change['type'], ' ', $change['actor']['label'] ?? 'Nextmail', PHP_EOL;
}
```

`listDeliveries` reads one endpoint and `listWorkspaceDeliveries` every endpoint, or the ones `endpointIds:` names, as an array or one comma-separated string, and both take `status:` (`delivered` or `failed`), `since:` and `until:`, the filters of the console’s Deliveries tab. `listActivity` and `listWorkspaceActivity` read the audit log: who created, changed, switched, rotated, tested, replayed or removed what. Each has a `listAll` and an `iterate` version beside it, such as `listAllDeliveries` and `iterateDeliveries`, and every row of the workspace log carries `endpointId`. `webhooks->stats` returns the numbers behind the Analytics tab for a window of your choosing.

> `since:` and `until:` take a `DateTimeInterface` or an ISO 8601 string, and a string holding a bare date means midnight UTC on that day. `until:` has to be later than `since:`, or the call throws an `InvalidRequestException` with `errorCode` set to `invalid_parameter`.
