Incoming webhooks
Reference article: Part of the API reference. Incoming webhooks belong to an App install.
An incoming webhook is the simplest way for an App to post into its room: sign a small JSON body with the install's signing secret and POST it to the install's hook_url. There's no API key and no SDK, so it works from a CI job, a cron script, or any system that can send an HTTPS request.
The message appears in the room as the App, with its name and icon, and everyone in the room sees it live.
POST /v1/apps/hooks/{install}
Authentication: the Nimbus-Signature header, made with the install's signing secret (whsec_…). No Authorization header is needed. Each install has its own URL; copy it from the install's hook_url.
Request
| Parameter | In | Type | Description |
|---|---|---|---|
install |
path | string | The install's ID (ins_…). |
Nimbus-Signature |
header | string | t=<unix seconds>,v1=<hex signature>. See Signing the request. |
The JSON body takes these fields. Send text, frame, or both:
| Body field | Type | Description |
|---|---|---|
text |
string, optional | The message in Markdown, 1–16,000 characters. |
frame |
object, optional | A Frame: a structured layout with sections, tables, and buttons. |
reply_to |
string, optional | The ID of a message in this room. The new message is posted in its thread. |
As a shortcut, the body can also be a bare JSON string, which is posted as text:
"Nightly build **passed** on `main`."
Signing the request {#signing-the-request}
Incoming webhooks use the same signature scheme as the webhooks Nimbus sends you, in the other direction:
- Take the current time in Unix seconds,
t. - Compute the HMAC-SHA256 of
t, a., and the exact body you'll send, keyed with the whole signing secret, includingwhsec_. - Send it hex-encoded as
Nimbus-Signature: t=<t>,v1=<hex>.
Nimbus refuses a signature whose timestamp is more than 5 minutes away from its clock, so sign each request just before you send it, and never reuse a signature.
::: code-group
SECRET='whsec_3Tq9Vx2Lm8Rb5Nc1Kw7Hd4Pj6Ys0Fz2A'
HOOK_URL='https://api.gonimbus.ai/v1/apps/hooks/ins_8Wd3Kp6Rm1Vt4Lx9Qb2Nh7Cz'
BODY='{"text":"Deploy **v2.14** finished on production."}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl "$HOOK_URL" \
-H "Content-Type: application/json" \
-H "Nimbus-Signature: t=$TS,v1=$SIG" \
--data-raw "$BODY"
import { createHmac } from 'node:crypto'
async function postToNimbus(hookUrl: string, secret: string, message: object) {
const body = JSON.stringify(message) // sign and send these exact bytes
const t = Math.floor(Date.now() / 1000)
const signature = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')
const response = await fetch(hookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Nimbus-Signature': `t=${t},v1=${signature}`,
},
body,
})
if (!response.ok) throw new Error(`Nimbus ${response.status}: ${await response.text()}`)
return response.json()
}
const message = await postToNimbus(
process.env.NIMBUS_HOOK_URL!,
process.env.NIMBUS_SIGNING_SECRET!,
{ text: 'Deploy **v2.14** finished on production.' },
)
import hashlib
import hmac
import json
import os
import time
import requests
def post_to_nimbus(hook_url, secret, message):
body = json.dumps(message).encode("utf-8") # sign and send these exact bytes
t = str(int(time.time()))
signature = hmac.new(secret.encode("utf-8"), t.encode() + b"." + body, hashlib.sha256).hexdigest()
response = requests.post(
hook_url,
headers={"Content-Type": "application/json", "Nimbus-Signature": f"t={t},v1={signature}"},
data=body,
)
response.raise_for_status()
return response.json()
message = post_to_nimbus(
os.environ["NIMBUS_HOOK_URL"],
os.environ["NIMBUS_SIGNING_SECRET"],
{"text": "Deploy **v2.14** finished on production."},
)
:::
Tip: Serialize the body once and use that same string both to sign and to send. Letting your HTTP client re-encode the JSON (for example Python's
json=argument) can change the bytes, and the signature won't match.
Response
Returns 201 with the Message object, plus a frame field: the Frame you sent, or null.
{
"id": "2c7e9a41-5b3d-4f8e-a6c1-9d0b4e2f7a58",
"object": "message",
"workstream": "3f6c2a1e-8b4d-4e2f-9a7c-5d1b0e6f2a94",
"text": "Deploy **v2.14** finished on production.",
"author": { "type": "app", "id": "app_5Hc2Lq9Rv4Tx7Nb1Mz8Kw3Pd", "name": "Deploy Bot" },
"reply_to": null,
"edited": false,
"created": 1758792310,
"updated": null,
"frame": null
}
When you send only a frame, the message's text is a plain-text rendering of it, which Nimbus uses for notifications and search. See Fallback text.
Rate limits
Each install has its own allowance, separate from your API keys', and reports it in the same RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers. Over it, requests fail with 429 rate_limited and a Retry-After header. See Rate limits.
Errors
| Status | Code | When |
|---|---|---|
400 |
frame_invalid |
The frame breaks a Frame rule. param points at the problem, such as frame.nodes.0.type. |
400 |
parameter_invalid |
Neither text nor frame was sent, a field is too long, or the body has an unknown field. |
400 |
invalid_json |
The body isn't valid JSON. |
401 |
invalid_signature |
The Nimbus-Signature header is missing, doesn't match the body, or its timestamp is more than 5 minutes old. |
401 |
install_revoked |
The App was uninstalled from this room, or deleted. |
401 |
install_not_found |
No install has the ID in the URL. |
429 |
rate_limited |
The install sent too many requests. Wait Retry-After seconds. |
Errors have the usual error body.
FAQ
Why do I get invalid_signature when the secret is right?
Usually the bytes you signed aren't the bytes you sent: the body was re-serialized, or a trailing newline was added. The other common cause is a server clock more than 5 minutes out. Check that you sign the exact string you send, and that your clock is synchronized.
Do I need an API key as well?
No. The signature is the authentication. An incoming webhook can only post as its App, into its one room.
Is the request retried if it fails?
Not by Nimbus: you're the one sending it. Retry 429 and 5xx responses yourself with backoff, and sign each retry afresh so its timestamp is current.
Can I edit a message I posted through the incoming webhook?
Not through the hook, which only posts. Use the MCP messages_update tool with the same install, or reply to an interaction with an update.
What happens if the App is uninstalled?
The hook stops working at once, and requests fail with 401 install_revoked. Reinstalling the App creates a new install with a new URL and new credentials.