Incoming webhooks

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:

  1. Take the current time in Unix seconds, t.
  2. Compute the HMAC-SHA256 of t, a ., and the exact body you'll send, keyed with the whole signing secret, including whsec_.
  3. 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.