Skip to the documentation
Ruby

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

webhooks.rb
endpoint = client.webhooks.create(  url: "https://acme.com/hooks/mail",  eventTypes: ["email.sent", "email.bounced"],  description: "Billing service") File.write(".openemail-webhook-secret", endpoint[:secret]) client.webhooks.listclient.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.firstclient.webhooks.get_delivery(endpoint[:id], latest[:id])client.webhooks.replay_delivery(endpoint[:id], latest[:id])rotated = client.webhooks.rotate_secret(endpoint[:id])File.write(".openemail-webhook-secret", 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.* and form.* reach an endpoint only when it names them.

rotate_secret has no overlap window. The old secret stops working immediately, so deploy the new one before you rotate. It is never retried automatically: a retry would rotate a second time and invalidate the secret the first attempt returned.

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

OpenEmail::WEBHOOK_EVENTS is a frozen Hash of every event name, so you can render the list without a request, and webhooks.list_events returns the same names with a sentence for each, plus the limits an endpoint is held to. Events are events of the **mailbox**, not of this API: email.received fires for mail that arrives in the app, and email.sent fires for a message the composer sent. Subscribing is not the same as watching your own API traffic.

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 nil 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 nil, 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.

Proving it works

webhook_test.rb
result = client.webhooks.test("whe_3f9c2a7b1e4d8f60a5c7b92d")puts result.dig(:delivery, :status), result.dig(:delivery, :responseCode) client.webhooks.iterate_deliveries("whe_3f9c2a7b1e4d8f60a5c7b92d") do |delivery|  puts "#{delivery[:eventType]} #{delivery[:status]} #{delivery[:responseCode]} #{delivery[:error]}"end

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 delivery[:status], not on whether the call raised. 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 nil 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.rb
detail = client.webhooks.get_delivery("whe_3f9c2a7b1e4d8f60a5c7b92d", "whd_8c1e4a7f2b9d3e6a0c5f1b28")p detail[:payload], detail[:responseBody], detail[:replayRefusal] replay = client.webhooks.replay_delivery("whe_3f9c2a7b1e4d8f60a5c7b92d", "whd_8c1e4a7f2b9d3e6a0c5f1b28")p replay.dig(:delivery, :status), replay.dig(: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 raises a 409 retry_in_progress, and while another replay of it is still being sent it raises 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 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 raises 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). get_delivery reports that answer in advance as replayRefusal.

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

Parameters: webhooks.create

urlStringrequired
Where deliveries are POSTed. HTTPS only, and the host may not be `localhost`, a `.localhost`, `.local` or `.internal` name, or a loopback, private, CGNAT or link-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/`.
eventTypesArray<String>
Which events reach this endpoint: any of the values in `OpenEmail::WEBHOOK_EVENTS`. `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 the domain, suppression, file or form families. 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.
descriptionString
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 nil.
addressAllowlistArray<String>
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`.
domainAllowlistArray<String>
Whole domains this endpoint hears about, including addresses added to them later. A domain also carries its own `domain.*` events. At most 25.
api_keyString
Creates the endpoint with this key instead of the client’s.

Response: the created endpoint

A Hash with Symbol keys. get, list and update return the same shape without secret.

objectString
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.
idString
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`.
urlString
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.
descriptionString or nil
The label you gave it, or nil if you gave none. An `update` that sends `description: nil` clears it.
eventTypesArray<String>
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.
enabledBoolean
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`.
disabledAtString or nil
When the server switched the endpoint off after 100 failed deliveries in a row. nil while it is on, and when you switched it off yourself.
disabledReasonString or nil
Why the server switched it off. nil whenever `disabledAt` is nil.
consecutiveFailuresInteger
Failed deliveries in a row. Any delivered event resets it to 0, and so does `update` with `enabled: true`.
addressAllowlistArray<String>
The single addresses this endpoint hears about.
domainAllowlistArray<String>
The whole domains this endpoint hears about. Both lists empty means every address the workspace owns.
lastDeliveryAtString or nil
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. nil until the first attempt, and so always nil on `create`.
createdAtString
ISO 8601 timestamp of when the endpoint was registered. `list` returns endpoints newest first by this field.
secretString
The HMAC-SHA-256 key that signs each delivery’s `X-OpenEmail-Signature`: `whsec_` followed by 43 base64url characters, and what you pass to `OpenEmail.verify_webhook_signature`, prefix included. 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 invalidates the old one immediately.

Filtering the logs

webhook_logs.rb
failed = client.webhooks.list_workspace_deliveries(status: "failed", since: Time.now - 86_400)p failed.items.map { |delivery| [delivery[:endpointId], delivery[:eventType], delivery[:responseCode]] } history = client.webhooks.list_activity("whe_3f9c2a7b1e4d8f60a5c7b92d")p history.items.map { |change| [change[:type], change.dig(:actor, :label)] }

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_ version beside it, 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 Time, a DateTime or an ISO 8601 instant as a String, and a Ruby Date means midnight UTC on that day. until is a Ruby keyword, but it works as a keyword argument like any other: list_deliveries(id, since: start, until: finish).