Webhooks
Subscribe to Warmbly platform events and receive HMAC-signed delivery callbacks at your own HTTPS endpoints.
Webhooks let your systems react to Warmbly activity in real time. You register one or more HTTPS endpoints, optionally filter them to specific event types, and Warmbly POSTs a signed JSON payload to each matching endpoint whenever a subscribed event fires. Every delivery is signed with HMAC-SHA256 and carries a unique event id so you can verify authenticity and dedupe replays. Failed deliveries are retried with exponential backoff and their history is queryable for debugging.
All webhook management endpoints are org-scoped and share one auth gate: Scope WEBHOOKS · Org permission manage_settings. The signing secret is server-generated and only returned at create and rotate time, so capture it immediately.
List webhook endpoints
GET /webhooks
Returns every endpoint configured for the caller's organization. Secrets are never included in this response.
Auth: Scope WEBHOOKS · Org permission manage_settings.
Response
Returns an object with the configured endpoints array. This is not a data + pagination envelope.
{
"endpoints": [
{
"id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b",
"organization_id": "11111111-2222-3333-4444-555555555555",
"url": "https://hooks.example.com/warmbly",
"description": "Production event sink",
"event_types": ["campaign.reply_received", "meeting.booked"],
"enabled": true,
"verified_at": "2026-06-11T18:02:14Z",
"ownership_confirmed": true,
"last_success_at": "2026-06-11T18:02:14Z",
"last_failure_at": null,
"last_failure_reason": null,
"consecutive_failures": 0,
"created_at": "2026-05-01T09:00:00Z",
"updated_at": "2026-06-11T18:02:14Z"
}
]
}List available event types
GET /webhooks/event-types
Returns the canonical catalog of every event Warmbly can emit. Use this to build an event-type picker or validate event_types values before writing them.
Auth: Scope WEBHOOKS · Org permission manage_settings.
Response
{
"event_types": [
{
"type": "campaign.reply_received",
"category": "campaign",
"description": "A human reply lands for a campaign thread.",
"firehose": false
},
{
"type": "campaign.email_sent",
"category": "campaign",
"description": "A campaign email is dispatched by a worker.",
"firehose": true
}
]
}firehose: true means the event is high-volume. An endpoint with an empty event_types list does not receive firehose events automatically. To receive them you must list each one explicitly in event_types. See Firehose events for details.
Create a webhook endpoint
POST /webhooks
Creates a new subscription. The response is the only time the signing secret is returned, so store it immediately. The URL must be HTTPS and resolve to a publicly routable host. Loopback, private, and link-local targets are rejected unless the server runs with unsafe webhook URLs enabled for local or self-hosted development.
New endpoints start in an unverified state. They receive a single signed challenge request and no real events until verification passes. See Endpoint verification.
Auth: Scope WEBHOOKS · Org permission manage_settings.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS URL that will receive POST callbacks. Must be publicly routable. |
description | string | No | Free-text label for your own reference. |
event_types | string[] | No | Event names to subscribe to. Each must be a known type. An empty or omitted array subscribes to all non-firehose events. |
enabled | boolean | No | Whether the endpoint is active. Defaults to true. |
{
"url": "https://hooks.example.com/warmbly",
"description": "Production event sink",
"event_types": ["campaign.reply_received", "meeting.booked"],
"enabled": true
}Response
201 Created with the full endpoint object, including the one-time secret. A new endpoint starts unverified (verified_at is null) and only receives the live event stream once it passes verification.
{
"id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b",
"organization_id": "11111111-2222-3333-4444-555555555555",
"url": "https://hooks.example.com/warmbly",
"description": "Production event sink",
"event_types": ["campaign.reply_received", "meeting.booked"],
"enabled": true,
"verified_at": null,
"ownership_confirmed": false,
"last_success_at": null,
"last_failure_at": null,
"last_failure_reason": null,
"consecutive_failures": 0,
"created_at": "2026-06-11T18:00:00Z",
"updated_at": "2026-06-11T18:00:00Z",
"secret": "whsec_9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0"
}Update a webhook endpoint
PATCH /webhooks/:id
Updates a subscription's URL, description, event filter, or enabled state. The signing secret is not changed here. Use the rotate endpoint for that. The same URL and event-type validation as create applies. Changing the URL host re-arms endpoint verification.
Auth: Scope WEBHOOKS · Org permission manage_settings.
| Parameter | In | Type | Description |
|---|---|---|---|
id | path | UUID | The endpoint id to update. |
Request body
Same shape as create. All fields are replaced with the values sent, so send the complete desired state (event_types is overwritten, not merged).
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS, publicly routable URL. |
description | string | No | Free-text label. |
event_types | string[] | No | Replacement event filter. Empty means all non-firehose events. |
enabled | boolean | No | Active state. Defaults to true if omitted. |
Response
200 OK with the updated endpoint (no secret).
Delete a webhook endpoint
DELETE /webhooks/:id
Deletes a subscription and cascades to its delivery history.
Auth: Scope WEBHOOKS · Org permission manage_settings.
| Parameter | In | Type | Description |
|---|---|---|---|
id | path | UUID | The endpoint id to delete. |
Response
204 No Content with an empty body.
Rotate the signing secret
POST /webhooks/:id/rotate-secret
Issues a new signing secret and returns it once. In-flight deliveries that were already signed continue to verify against the old secret until they settle, while new deliveries use the new secret. Update your verifier promptly after rotating.
Auth: Scope WEBHOOKS · Org permission manage_settings.
| Parameter | In | Type | Description |
|---|---|---|---|
id | path | UUID | The endpoint id to rotate. |
Response
200 OK with the new secret. This is the only time it is returned.
{
"secret": "whsec_1a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f7081"
}Endpoint verification
POST /webhooks/:id/verify
New endpoints start unverified and do not receive real events until they pass a reachability check. Calling this endpoint sends a single signed challenge request to the target URL. It is also useful as a "send a test event" button after the endpoint is already verified.
Auth: Scope WEBHOOKS · Org permission manage_settings.
| Parameter | In | Type | Description |
|---|---|---|---|
id | path | UUID | The endpoint id to verify. |
Challenge request
Warmbly sends a POST to the endpoint URL with X-Warmbly-Event: webhook.test and a body of:
{
"event_type": "webhook.test",
"data": {
"challenge": "whcg_...",
"endpoint_id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b",
"message": "This is a Warmbly webhook verification request."
}
}The challenge is signed with the endpoint's secret, so you can verify it exactly like any other delivery (see Verifying signatures).
Responding to the challenge
For simple reachability (dashboard-registered endpoints): respond with any 2xx. The endpoint is marked verified.
For URL ownership proof (required for OAuth-app-registered endpoints): additionally echo the challenge value back. You may return it in the JSON response body:
{ "challenge": "whcg_..." }Or in a response header:
X-Warmbly-Webhook-Challenge: whcg_...Changing an endpoint's URL host re-arms verification: the endpoint reverts to unverified and must pass the challenge again before it resumes receiving events.
List all deliveries (org-wide)
GET /webhooks/deliveries
Returns delivery attempts across all endpoints for the organization, newest first. Accepts query parameters to narrow the result.
Auth: Scope WEBHOOKS · Org permission manage_settings.
| Parameter | In | Type | Description |
|---|---|---|---|
status | query | string | Filter by delivery status. One of pending, in_flight, delivered, failed, abandoned. |
event_type | query | string | Filter by event type, for example campaign.reply_received. |
limit | query | integer | Max rows. Between 1 and 200, defaults to 50. Out-of-range values return 400. |
cursor | query | string | Opaque pagination cursor from the previous response's pagination.next_cursor. |
Response
Uses the standard data + pagination envelope with an opaque cursor.
{
"data": [
{
"id": "d1e2f3a4-b5c6-7d8e-9f0a-1b2c3d4e5f6a",
"endpoint_id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b",
"organization_id": "11111111-2222-3333-4444-555555555555",
"event_type": "campaign.reply_received",
"event_id": "e9f8a7b6-c5d4-3e2f-1a0b-9c8d7e6f5a4b",
"status": "delivered",
"attempt_count": 1,
"max_attempts": 8,
"next_attempt_at": null,
"last_attempt_at": "2026-06-11T18:02:14Z",
"response_status": 200,
"response_body_excerpt": "ok",
"error_reason": null,
"created_at": "2026-06-11T18:02:13Z",
"updated_at": "2026-06-11T18:02:14Z"
}
],
"pagination": {
"next_cursor": "eyJpZCI6ImQx...",
"has_more": true
}
}List deliveries for one endpoint
GET /webhooks/:id/deliveries
Returns recent delivery attempts for a specific endpoint, newest first. Each row reflects the latest state across all retry attempts for that event.
Auth: Scope WEBHOOKS · Org permission manage_settings.
| Parameter | In | Type | Description |
|---|---|---|---|
id | path | UUID | The endpoint id whose deliveries to list. |
status | query | string | Filter by delivery status. |
event_type | query | string | Filter by event type. |
limit | query | integer | Max rows. Between 1 and 200, defaults to 50. Out-of-range values return 400. |
cursor | query | string | Opaque pagination cursor. |
Response
Uses the same data + pagination envelope as the org-wide list.
Delivery status is one of pending, in_flight, delivered, failed, or abandoned (retries exhausted). response_body_excerpt is capped at the first 1024 bytes of the subscriber's response.
Redeliver a delivery
POST /webhooks/deliveries/:deliveryId/redeliver
Manually re-enqueues a delivery regardless of its current status. Useful for replaying a failed or abandoned delivery after you have fixed the receiver. The redelivery is a new attempt with its own retry lifecycle, but it carries the same event_id so your idempotency key logic will dedupe it correctly.
Auth: Scope WEBHOOKS · Org permission manage_settings.
| Parameter | In | Type | Description |
|---|---|---|---|
deliveryId | path | UUID | The delivery id to redeliver. |
Response
200 OK with the updated delivery object.
List throttle drops
GET /webhooks/throttle-drops
Returns events that were suppressed by the per-endpoint delivery rate limit or the per-org per-event-type dispatch throttle. Throttled events are surfaced here rather than silently discarded, so you can monitor for integration pressure and redeliver if needed.
Auth: Scope WEBHOOKS · Org permission manage_settings.
Delivery payload
Every callback is an HTTP POST with a Content-Type: application/json body in this shape:
| Field | Type | Description |
|---|---|---|
id | UUID | Unique event id. Stable across retries of the same event. Matches X-Warmbly-Event-Id. |
event_type | string | The event name, for example campaign.reply_received. |
organization_id | UUID | The organization the event belongs to. |
created_at | string | RFC 3339 UTC timestamp of when the event was dispatched. |
data | object | Event-specific payload. Shape depends on event_type. |
{
"id": "e9f8a7b6-c5d4-3e2f-1a0b-9c8d7e6f5a4b",
"event_type": "campaign.reply_received",
"organization_id": "11111111-2222-3333-4444-555555555555",
"created_at": "2026-06-11T18:02:13Z",
"data": { "...": "event-specific object" }
}There is no version field in the payload. If an event's payload ever changes in a backward-incompatible way, it is published under a new event_type name. Existing event type names are never re-versioned in place.
Delivery headers
Each delivery carries these request headers:
| Header | Description |
|---|---|
X-Warmbly-Signature | HMAC-SHA256 signature in the form t=<unix>,v1=<hex>. |
X-Warmbly-Event | The event type, so you can route without parsing the body. |
X-Warmbly-Event-Id | The unique event id (same as the payload id). Stable across retries. Use it as your idempotency key. |
User-Agent | Warmbly-Webhooks/1.0. |
Verifying signatures
Warmbly signs every delivery with HMAC-SHA256 using the endpoint's signing secret. The X-Warmbly-Signature header has the form t=<unix-timestamp>,v1=<hex-digest>. The signed string is the timestamp, a literal ., then the exact raw request body.
To verify:
- Parse
tandv1from the header. - Reject the request if
tis more than 5 minutes from the current wall clock. Never use a tolerance of 0 (that would break deliveries that arrive even one second late). - Compute
HMAC-SHA256(secret, t + "." + rawRequestBody)and hex-encode the result. - Compare your digest against
v1using a constant-time comparison to avoid timing attacks. - Ignore any scheme that is not
v1(downgrade defense: future schemes may exist).
Always compute the HMAC over the raw, unmodified request body before any JSON re-serialization. The secret is the whsec_-prefixed value returned at create or rotate time.
# Pseudocode
signed_payload = t + "." + raw_body
expected_v1 = hex(hmac_sha256(whsec_..., signed_payload))
is_valid = constant_time_equal(expected_v1, v1) AND abs(now() - t) < 300Example in Python:
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
if abs(time.time() - int(t)) > 300:
return False # replay too old
key = secret.encode() # whsec_... as bytes
signed = f"{t}.".encode() + raw_body
expected = hmac.new(key, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Use X-Warmbly-Event-Id (or the payload id field) as an idempotency key to dedupe replays. The same event id can arrive more than once if a retry fires before Warmbly receives your 2xx.
Retries and backoff
Deliveries are enqueued and drained asynchronously, so the dispatching API call returns immediately and is not blocked on your endpoint.
- A
2xxresponse marks the deliverydeliveredand resets the endpoint's failure health. - Any non-
2xxresponse (including3xx: redirects are never followed), a connection error, or a timeout (15-second request timeout) marks the attempt failed and schedules a retry. - Backoff doubles each retry starting at 30 seconds: 30s, 1m, 2m, 4m, 8m, 16m, 32m. Subsequent waits are capped at 1 hour.
- The default
max_attemptsis 8. Once attempts are exhausted the delivery is markedabandoned. - Both
4xxand5xxresponses are retried up to the attempt cap. A4xxmay be a transient validation flap. - Endpoints that fail continuously over a sustained window are auto-disabled to protect third parties. Re-enable them in Settings once your receiver is stable.
Make your handler idempotent. Dedupe on X-Warmbly-Event-Id since the same event id can arrive more than once across retries.
Firehose events
High-volume events (marked firehose: true in the event catalog) are opt-in only. An endpoint whose event_types list is empty or omitted subscribes to all standard events but will not receive firehose events. To receive a firehose event, list it explicitly:
{
"event_types": [
"campaign.reply_received",
"campaign.email_sent",
"campaign.email_opened"
]
}This keeps default integrations from being overwhelmed by send-pipeline or inbox-sync volume. Subscribe selectively and ensure your receiver can handle the sustained throughput before opting in.
Security
- HTTPS only: endpoint URLs must use
https://. Plain HTTP is rejected at registration time. - SSRF protection: outbound delivery is hardened against SSRF. Private, loopback, link-local, and cloud-metadata IP ranges are blocked at dial time. Redirects are never followed. Only standard web ports are allowed.
- Rate limits: a per-endpoint delivery rate limit and a per-org per-event-type dispatch throttle protect both your receivers and third parties. Throttled events appear in
GET /webhooks/throttle-drops. - Signature verification: always verify the
X-Warmbly-Signatureheader. Ignoring it means any party that knows your endpoint URL can replay or spoof events.
Event catalog
The full catalog is available at GET /webhooks/event-types. Below is the complete reference grouped by category. Events marked firehose must be subscribed to explicitly (see Firehose events).
Campaign
| Event | Description | Firehose |
|---|---|---|
campaign.started | A campaign starts sending. | |
campaign.paused | A campaign is paused. | |
campaign.completed | A campaign finishes all sends. | |
campaign.created | A campaign is created. | |
campaign.updated | A campaign's settings change. | |
campaign.deleted | A campaign is deleted. | |
campaign.email_sent | A campaign email is dispatched by a worker. | yes |
campaign.email_delivered | A campaign email is accepted by the recipient's mail server. | yes |
campaign.email_opened | A tracked open is recorded. | yes |
campaign.email_clicked | A tracked link click is recorded. | yes |
campaign.email_bounced | A campaign email bounces. | |
campaign.reply_received | A human reply lands for a campaign thread. | |
campaign.unsubscribed | A recipient clicks the unsubscribe link or requests removal. | |
campaign.deliverability_warning | The campaign's rolling bounce or complaint rate enters the early-warning band. | |
campaign.action | A sequence flow notify action node fires. |
Warmup
| Event | Description | Firehose |
|---|---|---|
warmup.email_sent | A warmup message is sent. | yes |
warmup.health_changed | A mailbox's warmup health state transitions. | |
warmup.placement_in_spam | A warmup message is observed landing in the spam folder. | |
warmup.quarantined | A mailbox is quarantined from the shared warmup pool. | |
warmup.blocked | A mailbox is blocked from the shared warmup pool. |
Deliverability
| Event | Description | Firehose |
|---|---|---|
deliverability.bounce | A bounce signal is processed (hard or soft). | |
deliverability.complaint | A complaint (spam report) is processed. |
Inbox
| Event | Description | Firehose |
|---|---|---|
inbox.email_received | A new email arrives in a connected mailbox. | yes |
inbox.email_updated | An inbox email's state changes (labels, read status). | yes |
inbox.email_deleted | An inbox email is deleted or expunged. | yes |
inbox.reply_received | A human reply arrives on a tracked thread (distinct from campaign.reply_received). |
Mailbox
| Event | Description | Firehose |
|---|---|---|
email_account.connected | A mailbox is connected to the organization. | |
email_account.disconnected | A mailbox loses its connection (auth failure, token revoked). | |
email_account.removed | A mailbox is removed from the organization. | |
email_account.error | A mailbox encounters a sync or sending error. | |
email_account.synced | A mailbox sync cycle completes. | yes |
email_account.health_changed | A mailbox's health state transitions. |
Contact
| Event | Description | Firehose |
|---|---|---|
contact.created | A contact is created. | |
contact.updated | A contact's fields change. | |
contact.deleted | A contact is deleted. |
CRM
| Event | Description | Firehose |
|---|---|---|
crm.deal_created | A deal is created. | |
crm.deal_updated | A deal's fields or stage change. | |
crm.deal_deleted | A deal is deleted. | |
crm.task_created | A CRM task is created. | |
crm.task_updated | A CRM task changes. | |
crm.note_created | A note is added to a contact or deal. | |
crm.pipeline_updated | A pipeline or its stages change. |
Automation
| Event | Description | Firehose |
|---|---|---|
automation.created | An automation is created. | |
automation.updated | An automation's definition or enabled state changes. | |
automation.deleted | An automation is deleted. | |
automation.run | An automation completes a run for a contact. |
Meeting
| Event | Description | Firehose |
|---|---|---|
meeting.booked | A call is booked through a connected scheduler (Calendly or Cal.com). | |
meeting.rescheduled | A booked call is rescheduled. | |
meeting.canceled | A booked call is canceled. |
Team and access
| Event | Description | Firehose |
|---|---|---|
team.member_invited | A team member invitation is sent. | |
team.member_removed | A member is removed from the organization. | |
role.created | A custom role is created. | |
role.updated | A custom role's permissions change. | |
role.deleted | A custom role is deleted. |
Bulk operations
| Event | Description | Firehose |
|---|---|---|
bulk_operation.started | A bulk import, export, or deletion begins. | |
bulk_operation.completed | A bulk operation finishes successfully. | |
bulk_operation.failed | A bulk operation fails or is aborted. |
Workspace
| Event | Description | Firehose |
|---|---|---|
lead_sync_source.updated | A lead sync source (CSV import, Google Sheets) is updated. | |
settings.updated | Organization settings change. | |
subscription.updated | The billing subscription changes (plan, seats, status). |
Developer
| Event | Description | Firehose |
|---|---|---|
custom.event | A custom event emitted by an automation or campaign sequence Fire Event step. |
Trigger-only (not delivered outbound)
inbound.webhook is an automation trigger for receiving inbound HTTP requests. It is never delivered outbound as a webhook event.
OAuth app-level subscriptions
OAuth apps can receive events without registering per-org endpoints manually. Set webhook_url and webhook_events on the app once, and Warmbly automatically materializes a managed endpoint for every org that authorizes the app. Events are gated by the intersection of the app's declared events and the org's granted scopes. The app signs all deliveries with a single shared whsec_ secret, verifiable with the same scheme documented in Verifying signatures.
Four management endpoints let you inspect and maintain the app-level delivery pipeline: reveal the secret, rotate it, list per-org endpoints with health, and page through the cross-org delivery log. All four require Scope API_KEYS and org permission manage_api_keys. See OAuth: receiving events as an app for the full reference, including the scope-to-event gating table and a comparison with the realtime gateway.
OAuth app webhook domains
OAuth applications that register webhook endpoints on behalf of their users must declare the domains those endpoints may use. The field is allowed_webhook_domains (a string array) on the OAuth app object.
Any webhook endpoint registered by an OAuth app must have its URL host within the app's allowed_webhook_domains list. This is enforced at registration time and re-checked at delivery time. An app with an empty allowed_webhook_domains list cannot register any webhook endpoints.
Matching rules:
- A bare entry (
acme.com) matches that exact host only. - A leading-dot entry (
.acme.com) matches the apex domain and any subdomain:acme.com,hooks.acme.com,prod.hooks.acme.com.
Example: if allowed_webhook_domains is [".acme.com"], then https://hooks.acme.com/warmbly is allowed and https://otherdomain.com/warmbly is rejected.
Set this field when creating or updating your OAuth app:
{
"name": "My App",
"allowed_webhook_domains": [".acme.com"]
}See OAuth for the full app registration flow and field reference.
Pushing custom data from a flow
To push custom data from a campaign or automation flow into your endpoints, add a "Fire event" action step: it emits a custom.event that every endpoint subscribed to it receives, signed with that endpoint's own secret and retried like any other delivery. That keeps a single, consistent verification model: one secret per endpoint, with signed and retried delivery.
Errors
Webhook endpoints use the standard error envelope with stable code and request_id fields (see error codes). Common cases:
400invalid payload, a non-HTTPS or non-routableurl, an unknown event type, or alimitoutside 1 to 200.400an invalid endpoint id or delivery id in the path.403an OAuth app attempting to register an endpoint whose URL host is not inallowed_webhook_domains.404the endpoint or delivery does not exist or does not belong to your organization.
{
"error": "Bad Request",
"message": "unknown event type: campaign.exploded",
"code": "bad_request",
"request_id": "req_7Yc2pK1nQ4"
}