Skip to the documentation
Python

Endpoints

`webhooks.list`, `list_all`, `iterate`, `get`, `create`, `update`, `delete`, `rotate_secret`, `test`, `get_delivery` and `replay_delivery`, and the delivery and activity logs.

Every method

usage.py
from acme.secrets import store endpoint = client.webhooks.create({    'url': 'https://acme.com/hooks/mail',    'eventTypes': ['email.sent', 'email.bounced'],    'description': 'Billing service',}) store(endpoint['secret']) client.webhooks.list()client.webhooks.get(endpoint['id'])client.webhooks.update(endpoint['id'], {'enabled': False})client.webhooks.test(endpoint['id'])latest = client.webhooks.list_deliveries(endpoint['id'], limit=1)['items'][0]client.webhooks.get_delivery(endpoint['id'], latest['id'])client.webhooks.replay_delivery(endpoint['id'], latest['id'])rotated = client.webhooks.rotate_secret(endpoint['id'])store(rotated['secret'])client.webhooks.delete(endpoint['id'])

create is the ONLY time the secret is returned, apart from rotate_secret. 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.

rotate_secret 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.

What you can subscribe to

WEBHOOK_EVENTS is exported so you can render the list. 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 is FileEventData: 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. form.submitted carries FormSubmittedEventData: formId, formName, submissionId, email, status, answers, audienceIds, sourceUrl and submittedAt. form.confirmed carries FormConfirmedEventData: 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.

Each of these data shapes is a TypedDict in nextmail.types. Annotate a verified event as WebhookPayload[FileEventData], for example, and a type checker knows what event['data'] holds.

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 raise 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 raises form.submitted, not contact.resubscribed, and someone who unsubscribes while inside an automation also raises 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.

The data of these events is typed as well: ContactEventData for contact.created, contact.updated and contact.deleted, ContactSubscriptionEventData for contact.unsubscribed and contact.resubscribed, SendingPausedEventData, SendingResumedEventData and AutomationResumedEventData.

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 raises 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.

The data of the task events is typed as TaskRunEventData and ApprovalRequestedEventData.

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.

The data of the booking and broadcast events is typed as BookingEventData and BroadcastEventData.

Proving it works

webhook_test.py
result = client.webhooks.test('whe_…')delivery = result['delivery'] if delivery is not None:    print(delivery['status'], delivery['responseCode']) for d in client.webhooks.iterate_deliveries('whe_…'):    print(d['eventType'], d['status'], d['responseCode'], d['error'])

A responseCode of None 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.py
detail = client.webhooks.get_delivery('whe_…', 'whd_…')print(detail['payload'], detail['responseBody'], detail['replayRefusal']) replay = client.webhooks.replay_delivery('whe_…', 'whd_…')print(replay['delivery']['status'], replay['delivery']['responseCode'])

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.

  • replay_delivery 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, replay_delivery sends nothing and is refused with 409 retry_in_progress, and while another replay of it is still being sent it is refused with 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 get_delivery, since that retry or replay may deliver it. Replay is one event at a time: no call sends every failed delivery again.
  • It also refuses with 409 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). get_delivery reports that answer in advance as replayRefusal.

The SDK never retries replay_delivery on its own, because a retry after a lost response would send the event again.

Each refusal raises NextmailApiError with status 409, is_conflict true and the reason as code, one of the values in WEBHOOK_REPLAY_ERROR_CODES.

Parameters: webhooks.create

urlstrrequired
Where deliveries are POSTed. HTTPS only, and the host may not be `localhost`, a `.localhost`/`.local`/`.internal` name, or a loopback, private, CGNAT or link-local IP literal. This is a server-side fetch to an address you supply, so those are a 422 on `url`; the check reads the hostname as written and never resolves DNS. What is stored is the URL parser's serialisation of what you sent, so `https://acme.com` reads back as `https://acme.com/`.
eventTypeslist[WebhookEvent]
Which events reach this endpoint: any of the names in `WEBHOOK_EVENTS`. `POST /webhooks` caps the array at the number of events that exist, so one more than that is a 422 on `eventTypes`; `PATCH` does not cap it. Only the length is capped, and a repeated name is stored and read back exactly as you sent it. Omitted or empty 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.
descriptionstr
A label for the endpoint, at most 200 characters, so a list of webhooks reads as names rather than a column of URLs. Omitted, it is stored and returned as null.

Response: CreatedWebhookResource

objectLiteral['webhook']
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.
idstr
The endpoint's identifier: `whe_` followed by 24 hex characters. Every other webhook call takes it: `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `list_all_deliveries`, `iterate_deliveries`, `get_delivery` and `replay_delivery`.
urlstr
The endpoint as stored, having passed the HTTPS and blocked-host checks. It is the parsed URL re-serialised, so compare against this value rather than against the string you sent.
descriptionstr | None
The label you gave it, or null if you gave none. An `update` that sends an explicit null clears it back to null.
eventTypeslist[WebhookEvent] | ['*']
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.
enabledbool
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 `WebhookCreate` has no `enabled` and only `WebhookPatch` does.
lastDeliveryAtstr | None
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 `list_deliveries` tells you how it went. Null until the first attempt, and so always null on `create`.
createdAtstr
ISO 8601 timestamp of when the endpoint was registered. `list` returns endpoints newest first by this field.
secretstr
The HMAC-SHA-256 key that signs each delivery's `X-OpenEmail-Signature`: `whsec_` followed by 32 random bytes in base64url, and what you hand to `verify_webhook_signature`. Returned by `create` and `rotate_secret` and by nothing else. A read never echoes it, so store it now; a lost secret can only be replaced with `rotate_secret`, which keeps the old one signing for 24 hours.

Filtering the logs

webhook_logs.py
from datetime import datetime, timedelta, timezone failed = client.webhooks.list_workspace_deliveries(    status='failed',    since=datetime.now(timezone.utc) - timedelta(days=1),)print(len(failed['items'])) history = client.webhooks.list_activity('whe_…')print([(change['type'], change['actor']['label'] if change['actor'] else None) for change in history['items']])

list_deliveries reads one endpoint and list_workspace_deliveries every endpoint, or the ones endpoint_ids= names, and both take status=, since= and until=, the filters of the console’s Deliveries tab. list_activity and list_workspace_activity read the audit log: who created, changed, switched, rotated, tested, replayed or removed what. Each has a list_all_… and an iterate_… beside it, and every row of the workspace log carries endpointId.

since= and until= take a datetime or an ISO 8601 string. A naive datetime is read as local time and converted to UTC, so pass an aware one, as above.

Reference