docs/platform/integrations/tool/webhook.mdx
Tool Webhook sends workflow Tool step output to an HTTP endpoint you control. Choose static routing to call a single integration URL for every subscriber, or dynamic routing to register one or more destination URLs per subscriber as channel endpoints.
<Note> Static mode never uses channel endpoints. Dynamic mode fans out to every `tool_webhook` endpoint registered for the subscriber on the integration. If a subscriber has no endpoints in dynamic mode, the Tool step is marked **skipped** for that subscriber. </Note>POST, PUT, or PATCH requests and returns a 2xx response| Mode | Where the URL lives | When to use |
|---|---|---|
| Static | Integration Endpoint URL (webhookUrl) | One shared webhook for all subscribers — internal automation, a single SaaS inbox, or a team-wide handler. |
| Dynamic | Per-subscriber tool_webhook channel endpoints | Each subscriber (or tenant) brings their own callback URL — customer webhooks, per-org automation, or multi-tenant fan-out. |
Set Routing Mode when you create the integration. Defaults to Static when omitted.
<Warning> Dynamic routing does not fall back to the integration URL. Register at least one `tool_webhook` endpoint per subscriber you intend to deliver to, or the step is skipped for that subscriber. </Warning>| Field | Static | Dynamic | Description |
| --- | --- | --- | --- |
| **Routing Mode** | `Static` | `Dynamic` | Static uses the integration URL; dynamic uses subscriber endpoints. |
| **Endpoint URL** | Required | Omit | Destination URL for static mode. |
| **HTTP Method** | Yes | Yes | Default `POST`, `PUT`, or `PATCH`. Overridable per endpoint or step. |
| **Headers** | Optional | Optional | Default request headers as a JSON object string, for example `{"Authorization":"Bearer shared-token"}`. |
| **Body** | Optional | Optional | Default JSON object string merged into every request body (see [Body merge](#body-merge)). |
| **Signing Secret** | Optional | Optional | HMAC secret for the `X-Novu-Signature` header (see [Verify signatures](#verify-x-novu-signature)). |
Novu sends Content-Type: application/json. The exact JSON body depends on whether you configured a Body template on the integration.
content and wins over any content key in the integration Body template.Without an integration Body template, the request body is:
{
"content": "Rendered Tool step text with {{payload}} placeholders resolved"
}
With an integration Body template of {"event":"novu.tool","version":1}, the outbound body becomes:
{
"event": "novu.tool",
"version": 1,
"content": "Rendered Tool step text"
}
Headers are merged in this order (later keys override earlier ones):
Content-Type: application/jsonheaders (when present on the tool_webhook endpoint)Use endpoint or step headers to override integration defaults — for example, swap an Authorization token per subscriber without changing the integration.
Resolution order: endpoint method (dynamic) → step override → integration HTTP Method → POST.
X-Novu-SignatureWhen you set a Signing Secret on the integration, Novu signs the exact UTF-8 request body string with HMAC SHA-256 and sends the hex digest in the X-Novu-Signature header.
Verify on your server using the raw request body bytes (before JSON re-serialization):
import crypto from 'crypto';
const signingSecret = process.env.NOVU_TOOL_WEBHOOK_SECRET!;
export function verifyNovuToolWebhook(rawBody: string, signatureHeader: string | undefined): boolean {
if (!signatureHeader) {
return false;
}
const expected = crypto.createHmac('sha256', signingSecret).update(rawBody, 'utf-8').digest('hex');
if (signatureHeader.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
}
// Express example — register before express.json()
app.post('/novu-tool-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf-8');
const signature = req.headers['x-novu-signature'] as string | undefined;
if (!verifyNovuToolWebhook(rawBody, signature)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(rawBody);
// handle payload.content, etc.
res.status(200).json({ id: 'received' });
});
Your endpoint should return any 2xx response. Novu treats non-2xx responses as delivery failures.
In dynamic routing, each subscriber can have multiple tool_webhook endpoints on the same integration. Novu delivers the Tool step once per endpoint — useful when a subscriber wants parallel notifications to several systems.
sequenceDiagram
participant Browser
participant CustomerBackend as Customer backend
participant NovuAPI as Novu API
participant CustomerWebhook as Subscriber webhook
Browser->>CustomerBackend: callback URL (over HTTPS)
CustomerBackend->>NovuAPI: POST /v1/channel-endpoints (secret key)
NovuAPI-->>CustomerBackend: 201 endpoint created
Note over NovuAPI: url and header values stored encrypted on endpoint
Browser->>CustomerBackend: trigger workflow
CustomerBackend->>NovuAPI: POST /v1/events/trigger
NovuAPI->>CustomerWebhook: POST merged payload + optional signature
Register a subscriber destination with type tool_webhook. Set createSubscriberIfMissing: true to provision the Novu subscriber on first connect.
const novu = new Novu({ secretKey: '<NOVU_SECRET_KEY>' });
await novu.channelEndpoints.create({ type: 'tool_webhook', integrationIdentifier: 'tool-webhook', subscriberId: '<SUBSCRIBER_ID>', createSubscriberIfMissing: true, endpoint: { url: 'https://customer.example.com/hooks/novu', headers: { Authorization: 'Bearer subscriber-token' }, method: 'POST', }, });
</Tab>
<Tab title="Python">
```python
import os
from novu_py import Novu
with Novu(secret_key=os.getenv("NOVU_SECRET_KEY", "")) as novu:
novu.channel_endpoints.create(create_channel_endpoint_request_body={
"type": "tool_webhook",
"integration_identifier": "tool-webhook",
"subscriber_id": "<SUBSCRIBER_ID>",
"create_subscriber_if_missing": True,
"endpoint": {
"url": "https://customer.example.com/hooks/novu",
"headers": {"Authorization": "Bearer subscriber-token"},
"method": "POST",
},
})
novugo "github.com/novuhq/novu-go"
"github.com/novuhq/novu-go/models/components"
)
s := novugo.New(novugo.WithSecurity(os.Getenv("NOVU_SECRET_KEY")))
_, err := s.ChannelEndpoints.Create(context.Background(), components.CreateToolWebhookEndpointDto{ Type: "tool_webhook", IntegrationIdentifier: "tool-webhook", SubscriberID: "<SUBSCRIBER_ID>", CreateSubscriberIfMissing: novugo.Bool(true), Endpoint: components.ToolWebhookEndpointDto{ URL: "https://customer.example.com/hooks/novu", Headers: map[string]string{"Authorization": "Bearer subscriber-token"}, Method: novugo.String("POST"), }, }, nil)
</Tab>
<Tab title="PHP">
```php
use novu;
use novu\Models\Components;
$sdk = novu\Novu::builder()->setSecurity('<NOVU_SECRET_KEY>')->build();
$sdk->channelEndpoints->create(
createChannelEndpointRequestBody: new Components\CreateToolWebhookEndpointDto(
type: 'tool_webhook',
integrationIdentifier: 'tool-webhook',
subscriberId: '<SUBSCRIBER_ID>',
createSubscriberIfMissing: true,
endpoint: new Components\ToolWebhookEndpointDto(
url: 'https://customer.example.com/hooks/novu',
headers: ['Authorization' => 'Bearer subscriber-token'],
method: 'POST',
),
),
);
var sdk = new NovuSDK(secretKey: "<NOVU_SECRET_KEY>");
await sdk.ChannelEndpoints.CreateAsync( createChannelEndpointRequestBody: new CreateToolWebhookEndpointDto() { Type = "tool_webhook", IntegrationIdentifier = "tool-webhook", SubscriberId = "<SUBSCRIBER_ID>", CreateSubscriberIfMissing = true, Endpoint = new ToolWebhookEndpointDto() { Url = "https://customer.example.com/hooks/novu", Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer subscriber-token", }, Method = "POST", }, });
</Tab>
<Tab title="Java">
```java
import co.novu.Novu;
import co.novu.models.components.*;
Novu novu = Novu.builder().secretKey("<NOVU_SECRET_KEY>").build();
novu.channelEndpoints().create()
.body(CreateToolWebhookEndpointDto.builder()
.type("tool_webhook")
.integrationIdentifier("tool-webhook")
.subscriberId("<SUBSCRIBER_ID>")
.createSubscriberIfMissing(true)
.endpoint(ToolWebhookEndpointDto.builder()
.url("https://customer.example.com/hooks/novu")
.headers(Map.of("Authorization", "Bearer subscriber-token"))
.method("POST")
.build())
.build())
.call();
| Field | Type | Description |
|---|---|---|
type | "tool_webhook" | Discriminator for the endpoint variant. |
integrationIdentifier | string | Identifier of the Tool Webhook integration (must have Routing Mode = Dynamic). |
subscriberId | string | Subscriber whose endpoints receive the Tool step payload. |
createSubscriberIfMissing | boolean | Optional. When true, Novu creates the subscriber if missing. Defaults to false. |
endpoint.url | string | HTTPS or HTTP URL Novu calls for this endpoint. Encrypted at rest. |
endpoint.headers | Record<string, string> | Optional. Per-endpoint headers; override integration defaults on key collision. Header values are encrypted at rest. |
endpoint.method | "POST" | "PUT" | "PATCH" | Optional. Overrides the integration HTTP method for this endpoint only. Remains plaintext. |
Unlike PagerDuty or Opsgenie, a subscriber may register multiple tool_webhook endpoints on the same integration. Each endpoint receives its own HTTP request when the workflow runs.
Rotate a URL or headers with PATCH /v1/channel-endpoints/:identifier. List endpoints for a subscriber:
curl -L 'https://api.novu.co/v1/channel-endpoints?subscriberId=<SUBSCRIBER_ID>&integrationIdentifier=tool-webhook' \
-H 'Authorization: ApiKey <NOVU_SECRET_KEY>'
Delete an endpoint to remove a destination. This removes the encrypted url and header values stored on the endpoint.
See the channel endpoints API reference for full request and response details.
const novu = new Novu({ secretKey: '<NOVU_SECRET_KEY>' });
await novu.trigger({ workflowId: 'ops-handoff', to: { subscriberId: '<SUBSCRIBER_ID>' }, payload: { incidentId: 'INC-42', severity: 'high', }, });
</Tab>
<Tab title="Python">
```python
import os
import novu_py
from novu_py import Novu
with Novu(secret_key=os.getenv("NOVU_SECRET_KEY", "")) as novu:
novu.trigger(trigger_event_request_dto=novu_py.TriggerEventRequestDto(
workflow_id="ops-handoff",
to={"subscriber_id": "<SUBSCRIBER_ID>"},
payload={
"incidentId": "INC-42",
"severity": "high",
},
))
novugo "github.com/novuhq/novu-go"
"github.com/novuhq/novu-go/models/components"
)
s := novugo.New(novugo.WithSecurity(os.Getenv("NOVU_SECRET_KEY")))
_, err := s.Trigger(context.Background(), components.TriggerEventRequestDto{ WorkflowID: "ops-handoff", To: components.CreateToSubscriberPayloadDto(components.SubscriberPayloadDto{ SubscriberID: "<SUBSCRIBER_ID>", }), Payload: map[string]any{ "incidentId": "INC-42", "severity": "high", }, }, nil)
</Tab>
<Tab title="PHP">
```php
use novu;
use novu\Models\Components;
$sdk = novu\Novu::builder()->setSecurity('<NOVU_SECRET_KEY>')->build();
$sdk->trigger(
triggerEventRequestDto: new Components\TriggerEventRequestDto(
workflowId: 'ops-handoff',
to: new Components\SubscriberPayloadDto(subscriberId: '<SUBSCRIBER_ID>'),
payload: [
'incidentId' => 'INC-42',
'severity' => 'high',
],
),
);
var sdk = new NovuSDK(secretKey: "<NOVU_SECRET_KEY>");
await sdk.TriggerAsync(triggerEventRequestDto: new TriggerEventRequestDto() { WorkflowId = "ops-handoff", To = To.CreateSubscriberPayloadDto(new SubscriberPayloadDto() { SubscriberId = "<SUBSCRIBER_ID>" }), Payload = new Dictionary<string, object> { ["incidentId"] = "INC-42", ["severity"] = "high", }, });
</Tab>
<Tab title="Java">
```java
import co.novu.Novu;
import co.novu.models.components.*;
Novu novu = Novu.builder().secretKey("<NOVU_SECRET_KEY>").build();
novu.trigger()
.body(TriggerEventRequestDto.builder()
.workflowId("ops-handoff")
.to(To2.of(SubscriberPayloadDto.builder().subscriberId("<SUBSCRIBER_ID>").build()))
.payload(Map.of(
"incidentId", "INC-42",
"severity", "high"
))
.build())
.call();
If Routing Mode is Dynamic and a subscriber has no tool_webhook endpoints on the integration, Novu marks the Tool step as skipped for that subscriber in the Activity feed. Other subscribers on the same trigger are unaffected.