Webhooks
Reference article: Part of the API reference.
Webhooks tell your systems when something happens in Nimbus, so you don't have to poll. You register a webhook endpoint, a public HTTPS URL you run, and choose the event types it should receive. When one of those events happens, Nimbus sends it to your URL as a signed POST with a JSON body.
The same events are also available through the API for 30 days, through List events, so you can catch up on anything your endpoint missed.
Event types
| Type | When | Sent today |
|---|---|---|
message.created |
A message was posted in a workstream room. | Yes |
message.updated |
A message in a workstream room was edited. | Yes |
agent.response.completed |
The agent finished an answer that was started through the API. | Yes |
run.completed |
A run finished successfully. | Yes |
run.failed |
A run failed. | Yes |
decision.recorded |
A decision was recorded. | Yes |
app.installed |
An App was installed on a workstream or Conflux room. | Yes |
app.uninstalled |
An App was removed from a workstream or Conflux room. | Yes |
loop.execution.completed |
A loop execution finished. | Not yet |
document.processed |
An uploaded document finished processing and is searchable. | Not yet |
What's sent today: message.created, message.updated, agent.response.completed, run.completed, run.failed, decision.recorded, app.installed, and app.uninstalled. loop.execution.completed and document.processed are reserved: you can subscribe to them now, and they'll start arriving as those features ship. Message events cover workstream rooms only, not direct messages. agent.response.completed is sent only for answers started through the API, and only when they complete. app.uninstalled is sent for each install removed, including every install of an App that is deleted.
Events are recorded while your organization has at least one active API key or webhook endpoint.
The Event object
Every event, whether delivered to your endpoint or read through the API, has the same shape:
{
"id": "evt_Qm7Rt2Vx9Lb4Nc8Kp1Wz6Hd3",
"object": "event",
"type": "message.created",
"api_version": "2026-09-25",
"created": 1758792310,
"data": {
"object": {
"id": "9a4f1c3e-6b2d-4e8a-b7c5-1d0e3f2a8b64",
"object": "message",
"workstream": "3f6c2a1e-8b4d-4e2f-9a7c-5d1b0e6f2a94",
"text": "Can we hold prices for existing customers until January?",
"author": { "type": "user", "id": "d4e8b1a6-2c7f-4e9b-8a3d-5f0c1b7e2a94", "name": "Priya Shah" },
"reply_to": null,
"edited": false,
"created": 1758792310,
"updated": null
}
}
}
| Field | Type | Description |
|---|---|---|
id |
string | The event's ID (evt_…). The same event delivered twice has the same ID. |
object |
string | Always event. |
type |
string | One of the event types. |
api_version |
string | The API version the event was rendered with. |
created |
integer | When the event happened, in Unix seconds. |
data.object |
object | The object the event is about. See below. |
What data.object holds depends on the type:
| Type | data.object |
|---|---|
message.created, message.updated |
The Message object. |
agent.response.completed |
The AgentResponse object, with output_text set. |
run.completed, run.failed |
A summary of the run: id, object (run), status, and for some failures a detail string. Retrieve the run for the rest. |
decision.recorded |
A summary of the decision: id, object (decision), workstream, run, category, summary, and status. List decisions for the rest. |
app.installed |
The AppInstall object, without credentials, plus app_name. |
app.uninstalled |
The AppInstall object, without credentials. |
New fields can appear in data.object at any time, so ignore fields you don't use rather than rejecting them.
Receiving events
Nimbus sends each event as an HTTPS POST to your endpoint's URL:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Nimbus-Webhooks/1.0 (+https://docs.gonimbus.ai/api-reference/webhooks) |
Nimbus-Signature |
t=<unix seconds>,v1=<hex signature>. See Verifying signatures. |
Nimbus-Event-Id |
The event's id, the same as in the body. |
Nimbus-Delivery-Attempt |
1 on the first attempt, then 2, 3, and so on for retries. |
The body is the Event object.
Respond with any 2xx status within 10 seconds. Anything else counts as a failed delivery: another status, a timeout, a connection error, or a redirect (redirects are not followed). Do the minimum before responding: verify the signature, store or queue the event, and return 200. Process it afterwards.
Delivery guarantees
- At least once. An event can arrive more than once, for example when your
2xxresponse was lost. Use the eventid(or theNimbus-Event-Idheader) to ignore events you've already handled. - Unordered. Events can arrive in a different order from the one they happened in. Use
created, or fetch the latest state of the object, when order matters.
Retries
A failed delivery is retried with increasing gaps:
| Attempt | Sent |
|---|---|
| 1 | Straight away |
| 2 | About 1 minute after attempt 1 |
| 3 | About 5 minutes after attempt 2 |
| 4 | About 30 minutes after attempt 3 |
| 5 | About 2 hours after attempt 4 |
| 6 | About 6 hours after attempt 5 |
| 7 | About 12 hours after attempt 6 |
| 8 | About 24 hours after attempt 7 |
| 9 | About 24 hours after attempt 8 |
After the ninth failed attempt, about three days after the event, the delivery is marked failed and not retried again. The event itself stays available through List events for 30 days.
Each attempt is signed afresh, with a new timestamp and the endpoint's current signing secret.
Verifying signatures {#verifying-signatures}
Every delivery is signed with your endpoint's signing secret (whsec_…), so you can check it came from Nimbus and wasn't changed on the way. Always verify before you trust the body.
The Nimbus-Signature header has two parts:
Nimbus-Signature: t=1758792311,v1=5f2b9c0e7a1d4f8b3c6e9a2d5b8f1c4e7a0d3b6f9c2e5a8d1b4f7c0e3a6d9b2f
tis when the delivery was signed, in Unix seconds.v1is the hex-encoded HMAC-SHA256 of the string`${t}.${rawBody}`, keyed with the whole signing secret, including thewhsec_prefix.
To verify a delivery:
- Read the raw request body, exactly as received. Don't parse and re-serialize the JSON first: that changes the bytes, and the signature won't match.
- Split the header on
,and taketand eachv1value. - Compute HMAC-SHA256 of
t, a., and the raw body, with your signing secret as the key. - Compare it to
v1with a constant-time comparison. - Reject the delivery if
tis more than 5 minutes from your server's clock, so a captured delivery can't be replayed later.
::: code-group
import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'
const TOLERANCE_SECONDS = 5 * 60
export function verifyNimbusSignature(rawBody: Buffer, header: string | undefined, secret: string): boolean {
if (!header) return false
let timestamp = Number.NaN
const signatures: string[] = []
for (const part of header.split(',')) {
const [key, value] = part.trim().split('=')
if (key === 't') timestamp = Number(value)
else if (key === 'v1' && value) signatures.push(value)
}
if (!Number.isInteger(timestamp) || signatures.length === 0) return false
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest()
return signatures.some((signature) => {
const given = Buffer.from(signature, 'hex')
return given.length === expected.length && timingSafeEqual(given, expected)
})
}
const app = express()
// express.raw keeps the body as the exact bytes Nimbus signed.
app.post('/hooks/nimbus', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyNimbusSignature(req.body, req.header('Nimbus-Signature'), process.env.NIMBUS_WEBHOOK_SECRET!)) {
return res.status(400).send('Invalid signature')
}
const event = JSON.parse(req.body.toString('utf8'))
await enqueue(event) // your queue or database; dedupe on event.id when you process it
res.sendStatus(200)
})
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
TOLERANCE_SECONDS = 5 * 60
def verify_nimbus_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1" and value:
signatures.append(value)
if not timestamp or not timestamp.isdigit() or not signatures:
return False
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"),
timestamp.encode("utf-8") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
app = Flask(__name__)
@app.post("/hooks/nimbus")
def nimbus_webhook():
raw_body = request.get_data() # the exact bytes Nimbus signed
if not verify_nimbus_signature(raw_body, request.headers.get("Nimbus-Signature"), os.environ["NIMBUS_WEBHOOK_SECRET"]):
abort(400)
event = json.loads(raw_body)
enqueue(event) # your queue or database; dedupe on event["id"] when you process it
return "", 200
:::
Keep your server's clock synchronized (for example with NTP), or valid deliveries will fail the 5-minute check.
The WebhookEndpoint object
{
"id": "we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp",
"object": "webhook_endpoint",
"url": "https://hooks.example.com/nimbus",
"description": "Ops alerts",
"enabled_events": ["message.created", "run.failed"],
"status": "enabled",
"api_version": "2026-09-25",
"created": 1758792000
}
| Field | Type | Description |
|---|---|---|
id |
string | The endpoint's ID (we_…). |
object |
string | Always webhook_endpoint. |
url |
string | Where events are sent. |
description |
string or null | Your note about what it's for. |
enabled_events |
string[] | The event types it receives, or ["*"] for all of them. |
status |
string | enabled, or disabled while deliveries are stopped. |
api_version |
string | The API version current when the endpoint was created. |
created |
integer | When it was created, in Unix seconds. |
secret |
string | The signing secret (whsec_…). Only in the responses to Create a webhook endpoint and Roll the signing secret. |
Create a webhook endpoint
Registers a URL to receive events. The response includes the endpoint's signing secret: store it in your secret manager straight away, because it isn't shown again.
POST /v1/webhook_endpoints
Scope: webhooks:write
| Body field | Type | Description |
|---|---|---|
url |
string | The HTTPS URL to send events to, up to 2,048 characters. See URL rules. |
enabled_events |
string[] | The event types to receive, or ["*"] for all of them, including types added later. |
description |
string, optional | A note about what the endpoint is for, up to 200 characters. |
::: code-group
curl https://api.gonimbus.ai/v1/webhook_endpoints \
-H "Authorization: Bearer $NIMBUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/nimbus",
"enabled_events": ["message.created", "run.failed"],
"description": "Ops alerts"
}'
const response = await fetch('https://api.gonimbus.ai/v1/webhook_endpoints', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NIMBUS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://hooks.example.com/nimbus',
enabled_events: ['message.created', 'run.failed'],
description: 'Ops alerts',
}),
})
const endpoint = await response.json()
// Store endpoint.secret now: it isn't returned again.
response = requests.post(
"https://api.gonimbus.ai/v1/webhook_endpoints",
headers={
"Authorization": f"Bearer {os.environ['NIMBUS_API_KEY']}",
},
json={
"url": "https://hooks.example.com/nimbus",
"enabled_events": ["message.created", "run.failed"],
"description": "Ops alerts",
},
)
endpoint = response.json()
# Store endpoint["secret"] now: it isn't returned again.
:::
{
"id": "we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp",
"object": "webhook_endpoint",
"url": "https://hooks.example.com/nimbus",
"description": "Ops alerts",
"enabled_events": ["message.created", "run.failed"],
"status": "enabled",
"api_version": "2026-09-25",
"created": 1758792000,
"secret": "whsec_8Fq2Lm7Rt4Vx9Kb1Nc6Wz3Hd5Pj0Ys2E"
}
An organization can have up to 16 webhook endpoints. Creating a 17th fails with 400 webhook_endpoint_limit.
Unknown event types fail with 400 parameter_invalid and param set to enabled_events. Listing * alongside other types is the same as ["*"].
URL rules {#url-rules}
Because Nimbus sends requests to the URL you choose, it must be a public HTTPS address. A URL that breaks these rules fails with 400 url_invalid, and the message says which rule:
- It uses
https://. - It has no username or password in it.
- Its host isn't
localhostor a name ending in.localhostor.internal. - Its host resolves in public DNS, and every address it resolves to is public: no private, loopback, link-local, or other internal network addresses.
The URL is checked when you save it and again, against fresh DNS, before every delivery. A delivery to a host that has started resolving to an internal address fails and is retried like any other failure.
List webhook endpoints
Returns the organization's webhook endpoints, newest first. The signing secrets aren't included.
GET /v1/webhook_endpoints
Scope: webhooks:read
| Parameter | In | Type | Description |
|---|---|---|---|
limit |
query | integer, optional | How many objects to return, 1–100. Default 20. |
cursor |
query | string, optional | The next_cursor from the previous page. See Pagination. |
::: code-group
curl https://api.gonimbus.ai/v1/webhook_endpoints \
-H "Authorization: Bearer $NIMBUS_API_KEY"
const response = await fetch('https://api.gonimbus.ai/v1/webhook_endpoints', {
headers: { Authorization: `Bearer ${process.env.NIMBUS_API_KEY}` },
})
const { data: endpoints } = await response.json()
response = requests.get(
"https://api.gonimbus.ai/v1/webhook_endpoints",
headers={"Authorization": f"Bearer {os.environ['NIMBUS_API_KEY']}"},
)
endpoints = response.json()["data"]
:::
Returns a list object of WebhookEndpoint objects.
Retrieve a webhook endpoint
GET /v1/webhook_endpoints/{webhook_endpoint}
Scope: webhooks:read
| Parameter | In | Type | Description |
|---|---|---|---|
webhook_endpoint |
path | string | The endpoint's ID (we_…). |
::: code-group
curl https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp \
-H "Authorization: Bearer $NIMBUS_API_KEY"
const response = await fetch('https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp', {
headers: { Authorization: `Bearer ${process.env.NIMBUS_API_KEY}` },
})
const endpoint = await response.json()
response = requests.get(
"https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp",
headers={"Authorization": f"Bearer {os.environ['NIMBUS_API_KEY']}"},
)
endpoint = response.json()
:::
Returns the WebhookEndpoint object, without its secret.
Update a webhook endpoint
Changes the URL, event types, or description, or disables and re-enables the endpoint. Send only the fields you want to change.
PATCH /v1/webhook_endpoints/{webhook_endpoint}
Scope: webhooks:write
| Parameter | In | Type | Description |
|---|---|---|---|
webhook_endpoint |
path | string | The endpoint's ID (we_…). |
The request body takes these fields:
| Body field | Type | Description |
|---|---|---|
url |
string, optional | A new URL. The URL rules apply. |
enabled_events |
string[], optional | A new list of event types, replacing the old one, or ["*"]. |
description |
string or null, optional | A new description, up to 200 characters, or null to clear it. |
disabled |
boolean, optional | true to stop deliveries, false to start them again. |
::: code-group
curl -X PATCH https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp \
-H "Authorization: Bearer $NIMBUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"disabled": true}'
const response = await fetch('https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.NIMBUS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ disabled: true }),
})
const endpoint = await response.json()
response = requests.patch(
"https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp",
headers={"Authorization": f"Bearer {os.environ['NIMBUS_API_KEY']}"},
json={"disabled": True},
)
endpoint = response.json()
:::
Returns the updated WebhookEndpoint object.
Disabling an endpoint stops deliveries to it: while its status is disabled, new events aren't sent to it, and retries still waiting for it are dropped. Events keep being recorded, so when you re-enable it with "disabled": false, use List events to fetch anything that happened in between. Disable an endpoint during maintenance on your receiver, rather than letting deliveries fail.
Delete a webhook endpoint
Deletes the endpoint. No more events are sent to it, including retries still waiting. This can't be undone.
DELETE /v1/webhook_endpoints/{webhook_endpoint}
Scope: webhooks:write
| Parameter | In | Type | Description |
|---|---|---|---|
webhook_endpoint |
path | string | The endpoint's ID (we_…). |
::: code-group
curl -X DELETE https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp \
-H "Authorization: Bearer $NIMBUS_API_KEY"
const response = await fetch('https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp', {
method: 'DELETE',
headers: { Authorization: `Bearer ${process.env.NIMBUS_API_KEY}` },
})
const deleted = await response.json()
response = requests.delete(
"https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp",
headers={"Authorization": f"Bearer {os.environ['NIMBUS_API_KEY']}"},
)
deleted = response.json()
:::
{
"id": "we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp",
"object": "webhook_endpoint",
"deleted": true
}
Roll the signing secret
Replaces the endpoint's signing secret. The response includes the new secret, and it takes effect immediately: from this moment every delivery, including retries, is signed with the new secret only. There's no period when both secrets are valid.
POST /v1/webhook_endpoints/{webhook_endpoint}/roll_secret
Scope: webhooks:write
| Parameter | In | Type | Description |
|---|---|---|---|
webhook_endpoint |
path | string | The endpoint's ID (we_…). |
::: code-group
curl -X POST https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp/roll_secret \
-H "Authorization: Bearer $NIMBUS_API_KEY"
const response = await fetch('https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp/roll_secret', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.NIMBUS_API_KEY}` },
})
const { secret } = await response.json()
response = requests.post(
"https://api.gonimbus.ai/v1/webhook_endpoints/we_3Kd8Qm2Rt7Vx1Lb9Nc4Wz6Hp/roll_secret",
headers={"Authorization": f"Bearer {os.environ['NIMBUS_API_KEY']}"},
)
secret = response.json()["secret"]
:::
Returns the WebhookEndpoint object with the new secret.
To roll with little disruption:
- Have your deployment ready to take a new secret.
- Roll the secret, and store the new one.
- Deploy it to your receiver straight away.
Deliveries that arrive between steps 2 and 3 fail your signature check. As long as your receiver answers them with a non-2xx status, as the examples above do, Nimbus retries them, signed with the new secret, starting about a minute later. Once the new secret is live, those retries succeed.
List events
Returns your organization's events from the last 30 days, newest first. Use it to catch up after downtime, or to build a first version of an integration before setting up an endpoint.
GET /v1/events
Scope: webhooks:read
| Parameter | In | Type | Description |
|---|---|---|---|
limit |
query | integer, optional | How many objects to return, 1–100. Default 20. |
cursor |
query | string, optional | The next_cursor from the previous page. See Pagination. |
type |
query | string, optional | Only events of this type. |
::: code-group
curl "https://api.gonimbus.ai/v1/events?type=run.failed" \
-H "Authorization: Bearer $NIMBUS_API_KEY"
const response = await fetch('https://api.gonimbus.ai/v1/events?type=run.failed', {
headers: { Authorization: `Bearer ${process.env.NIMBUS_API_KEY}` },
})
const { data: events } = await response.json()
response = requests.get(
"https://api.gonimbus.ai/v1/events",
params={"type": "run.failed"},
headers={"Authorization": f"Bearer {os.environ['NIMBUS_API_KEY']}"},
)
events = response.json()["data"]
:::
Returns a list object of Event objects.
Retrieve an event
GET /v1/events/{event}
Scope: webhooks:read
| Parameter | In | Type | Description |
|---|---|---|---|
event |
path | string | The event's ID (evt_…). |
::: code-group
curl https://api.gonimbus.ai/v1/events/evt_Qm7Rt2Vx9Lb4Nc8Kp1Wz6Hd3 \
-H "Authorization: Bearer $NIMBUS_API_KEY"
const response = await fetch('https://api.gonimbus.ai/v1/events/evt_Qm7Rt2Vx9Lb4Nc8Kp1Wz6Hd3', {
headers: { Authorization: `Bearer ${process.env.NIMBUS_API_KEY}` },
})
const event = await response.json()
response = requests.get(
"https://api.gonimbus.ai/v1/events/evt_Qm7Rt2Vx9Lb4Nc8Kp1Wz6Hd3",
headers={"Authorization": f"Bearer {os.environ['NIMBUS_API_KEY']}"},
)
event = response.json()
:::
Returns the Event object, or 404 resource_missing if no event has that ID.
Errors
| Status | Code | When |
|---|---|---|
400 |
url_invalid |
The URL breaks one of the URL rules. |
400 |
parameter_invalid |
An unknown event type, or another field out of range. |
400 |
webhook_endpoint_limit |
The organization already has 16 endpoints. |
403 |
insufficient_scope |
The key lacks webhooks:read or webhooks:write. |
404 |
resource_missing |
No endpoint or event has that ID. |
503 |
webhook_secrets_unavailable |
Signing secrets can't be created right now. Retry later. |
FAQ
I lost the signing secret. Can I see it again?
No. It's only returned when the endpoint is created or its secret rolled. Roll the secret to get a new one, and deploy it straight away.
Why am I receiving the same event twice?
Delivery is at least once: if Nimbus didn't get your 2xx in time, it sends the event again. Record the event ids you've processed and skip repeats.
Can I test my endpoint locally?
Not directly: Nimbus only delivers to public HTTPS URLs, so localhost is refused. Expose your local server through a tunnelling service that gives it a public HTTPS URL, and register that URL.
Why aren't I receiving loop.execution.completed events?
It's one of the reserved types that aren't sent yet. You can stay subscribed; it will start arriving when the feature ships. Until then, list a loop's executions.
Do webhook deliveries count against my rate limit?
No. Rate limits apply to requests your keys send to the API. Deliveries are requests Nimbus sends to you.