Signed Webhook Triggers

A webhook trigger starts your agent when another system sends a request to the trigger's URL. Signing makes sure that only the system you set up can do that.

This page covers the signing options available in the trigger's Security section: Apps with a signing secret (the Standard Webhooks format) and Slack. It also covers rotating the secret, testing your setup, and troubleshooting.

📘

Prefer signing whenever the sending system supports it

Signing is optional, and existing unsigned triggers keep working as they do today. But it matters most when the agent takes actions, and when the trigger continues conversations.

1. What signing does, and when to use it

Every webhook trigger has a Webhook URL. By default, the trigger is set to Anyone with the URL: any request to that URL starts the agent. If the URL leaks into a log, a forwarded message, or a shared config file, whoever has it can start your agent and send it whatever content they want.

With signing turned on:

  • NinjaCat and the sending system share a signing secret.
  • The sender signs each request with that secret.
  • NinjaCat checks the signature before anything runs. A request without a valid signature is refused, and the agent does not start.
  • NinjaCat also rejects old requests and recognizes repeats of the same delivery, so a captured request can't simply be sent again later.

When to use it. Use signing whenever the sending system supports it. It matters most when the agent takes actions, and when the trigger continues conversations. If the trigger is set to Anyone with the URL and you choose Ongoing conversation or Grouped conversations as the conversation type, the form shows this note:

Anyone who has this webhook's URL can add to these conversations. We recommend changing Who can trigger this agent, under Security.

2. Choosing an option

You choose the option in the trigger's Security section, under Who can trigger this agent.

Option in the formUse it whenWhat the sender must support
Anyone with the URLThe sender can't sign requests, and the trigger does nothing sensitive.Nothing.
Apps with a signing secretYour own code, or any tool that supports the Standard Webhooks format.Sending three headers — webhook-id, webhook-timestamp, and webhook-signature — with an HMAC-SHA256 signature. NinjaCat generates the secret.
SlackA Slack app you control, sending Events API events.Slack's request signing (X-Slack-Signature and X-Slack-Request-Timestamp). You paste the app's signing secret into NinjaCat.

3. Set up

All signing options start the same way:

  1. Open the agent in the Agent Builder.
  2. Choose Add Trigger, then select Webhook ("Runs when an external system posts to a generated URL."). To change an existing webhook trigger, open it instead.
  3. On the Configure tab, under Connect, find the Webhook URL. Copy it; the sender needs it.
  4. Under Security, open Who can trigger this agent and choose your option. Then follow the section below for that option.
  5. Choose Apply.
  6. Save the agent. Nothing is stored until you do. The form reminds you: "Changes are saved when you save the agent."
🚧

Copy the secret before you save

A generated secret is shown only until you save the agent. After that it is masked and can't be shown again. If you lose it, replace it (see Rotating the secret).

3a. Apps with a signing secret (Standard Webhooks)

In NinjaCat

  1. Under Who can trigger this agent, choose Apps with a signing secret ("Only apps that have this trigger's secret can trigger this agent.").
  2. Choose Generate secret. NinjaCat creates a secret that starts with whsec_.
  3. Choose Copy next to the secret, and store it somewhere safe, such as your sender's secret store. The form warns: "Copy it now. You won't be able to see it again after you save the agent."
  4. Choose Apply, then save the agent.

In the sending system

Send each request as a POST to the Webhook URL, with these headers:

HeaderValue
webhook-idA unique ID for this delivery: 1 to 128 characters, using only letters, digits, _, ., :, and -. Reuse the same ID when you retry the same delivery.
webhook-timestampThe current time as whole Unix seconds, for example 1761220800.
webhook-signaturev1, followed by the base64-encoded HMAC-SHA256 signature (see below).
Content-Typeapplication/json or application/x-www-form-urlencoded. An optional ; charset=utf-8 is allowed.

How to compute the signature:

  1. Take the secret and remove the whsec_ prefix. Base64-decode the rest. The result is the signing key, as raw bytes. Don't use the text of the secret itself as the key.
  2. Build the signed content: {webhook-id}.{webhook-timestamp}.{body}. Here {body} is the exact bytes you send as the request body.
  3. Compute HMAC-SHA256 of the signed content with the key.
  4. Base64-encode the result and put v1, in front of it.

The body must be sent exactly as it was signed. If you sign a JSON string and your HTTP client then re-serializes it — adding or removing spaces or reordering keys — the signature won't match. Don't compress the body: Content-Encoding must be absent or identity.

Node.js example (built-in crypto, Node 18 or later; save as an .mjs file)

import { createHmac, randomUUID } from "node:crypto";

function signStandardWebhook(secret, body) {
  const id = `msg_${randomUUID()}`; // keep this ID if you retry the same delivery
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const signature = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${body}`)
    .digest("base64");
  return {
    "webhook-id": id,
    "webhook-timestamp": timestamp,
    "webhook-signature": `v1,${signature}`,
  };
}

const webhookUrl = process.env.NINJACAT_WEBHOOK_URL;   // the trigger's Webhook URL
const secret = process.env.NINJACAT_WEBHOOK_SECRET;    // whsec_...

const body = JSON.stringify({ event: "order.created", order: { id: "A-1001" } });
const response = await fetch(webhookUrl, {
  method: "POST",
  headers: { "Content-Type": "application/json", ...signStandardWebhook(secret, body) },
  body, // send the same string you signed
});
console.log(response.status, await response.json());

Python example (standard library, Python 3.9 or later)

import base64, hashlib, hmac, json, os, time, uuid
import urllib.request

def sign_standard_webhook(secret: str, body: bytes) -> dict:
    msg_id = f"msg_{uuid.uuid4()}"  # keep this ID if you retry the same delivery
    timestamp = str(int(time.time()))
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = msg_id.encode() + b"." + timestamp.encode() + b"." + body
    signature = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return {
        "webhook-id": msg_id,
        "webhook-timestamp": timestamp,
        "webhook-signature": f"v1,{signature}",
    }

webhook_url = os.environ["NINJACAT_WEBHOOK_URL"]   # the trigger's Webhook URL
secret = os.environ["NINJACAT_WEBHOOK_SECRET"]     # whsec_...

body = json.dumps({"event": "order.created", "order": {"id": "A-1001"}}).encode()
request = urllib.request.Request(
    webhook_url,
    data=body,  # send the same bytes you signed
    headers={"Content-Type": "application/json", **sign_standard_webhook(secret, body)},
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())

Any library that implements the Standard Webhooks format will also work, if you give it the whsec_ secret.

3b. Slack

Use this option for a Slack app that you own and that sends events to the trigger.

In Slack

  1. Open your app at api.slack.com/apps.
  2. On the app's Basic Information page, under App Credentials, copy the Signing Secret.

In NinjaCat

  1. Under Who can trigger this agent, choose Slack ("Only your Slack app can trigger this agent.").
  2. Paste the secret into Slack signing secret. The help text says: "Paste Slack's Signing Secret from the app's Basic Information page, under App Credentials."
    • Slack signing secrets are 32 to 64 hexadecimal characters. If you paste anything else, the form says "That doesn't look like a Slack signing secret. Copy it again from your Slack app's Basic Information page."
  3. Choose Apply, then save the agent before the next step. NinjaCat can't answer Slack's URL check until the secret is saved.

Back in Slack

  1. Open Event Subscriptions and turn on Enable Events.
  2. In Request URL, paste the trigger's Webhook URL. Slack sends a URL verification request. NinjaCat checks its signature and answers the challenge, and Slack shows the URL as verified.
  3. Under Subscribe to bot events, add only the events you want the agent to act on, for example app_mention.
  4. Save your changes, and reinstall the app if Slack asks you to.

NinjaCat checks X-Slack-Signature (v0= followed by an HMAC-SHA256 hex digest of v0:{timestamp}:{body}) and X-Slack-Request-Timestamp.

📘

Note on Slack retries

Slack signs each retry again, with a new timestamp. NinjaCat therefore sees a Slack retry as a new delivery, and it can start another run. NinjaCat recognizes a repeat only when the exact same signed request arrives again.

💡

Tip

With Grouped conversations, the Replies in a Slack thread option keeps one conversation per thread. The message that starts a thread isn't a reply, so it runs in a conversation of its own.

4. Rotating the secret

Rotate the secret if it might have leaked, or as part of a regular schedule.

  1. Open the trigger. Under Security, the saved secret is shown masked, with a line that says when it was set ("Set …").
  2. Choose Replace secret.
    • Apps with a signing secret: a new secret is generated right away. Copy it.
    • Slack: first get a new signing secret from your Slack app's Basic Information page. Then paste it into New Slack signing secret.
  3. Under What happens to the current secret?, choose one option:
    • Keep it working for 24 hours. "Gives you time to update the sender." Both the old and the new secret work for 24 hours after you save the agent.
    • Stop accepting it right away. "Apps still using it stop working when you save the agent." Choose this if the old secret may have leaked.
  4. Choose Apply, then save the agent.
  5. Update the sender with the new secret. During the overlap, the form shows "Your previous secret also works until …" with the exact time.

To back out before saving, choose Cancel to keep the current secret.

Good to know:

  • One previous secret at a time. If you replace the secret again during an overlap, the oldest secret stops working at once. Only the secret you just replaced keeps its 24 hours.
  • Changing the option replaces the secret immediately. Switching between Apps with a signing secret and Slack needs a new secret, and the old one stops working when you save. There is no overlap.
  • Turning signing off. Choosing Anyone with the URL and saving deletes the stored secrets. The URL then accepts unsigned requests again.
  • Copied triggers need their own secret. The form says: "This trigger was copied, so it needs its own secret. Choose Replace secret."
  • Deleting the trigger, or the agent, deletes its secrets.

5. Testing it

This test uses curl and openssl, which come with macOS and most Linux systems. It sends one signed request to an Apps with a signing secret trigger.

WEBHOOK_URL='<paste the trigger's Webhook URL>'
SECRET='whsec_<your secret>'

BODY='{"event":"test","message":"hello from curl"}'
ID="msg_$(date +%s)_$RANDOM"
TS="$(date +%s)"

# Key = the secret without "whsec_", base64-decoded, as hex for openssl.
KEY_HEX="$(printf '%s' "${SECRET#whsec_}" | base64 --decode | xxd -p -c 256)"
SIG="$(printf '%s' "$ID.$TS.$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary \
  | base64)"

curl -sS -X POST "$WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -H "webhook-id: $ID" \
  -H "webhook-timestamp: $TS" \
  -H "webhook-signature: v1,$SIG" \
  --data-binary "$BODY"

What a correct response looks like. HTTP 200 with:

{"workflowId":"…","message":"Workflow started successfully"}

The run then appears on the trigger's Run History tab.

Further checks:

  • Repeat check. Run the curl command again with the same ID, TS, and SIG, within 5 minutes. The answer is {"status":"duplicate"}, and no second run starts.
  • Rejection check. Change one character of the body without re-signing. The answer is HTTP 401 {"error":"unauthorized"}.

6. Troubleshooting

NinjaCat always answers with one of the short, fixed responses below. Error responses never say which check failed. For example, a wrong signature and a stale timestamp both get the same 401.

Response or symptomLikely causeFix
200 {"workflowId": …, "message": "Workflow started successfully"}Success. A run started.Nothing to do.
200 {"status":"duplicate"}This delivery was already accepted: same webhook-id (Standard Webhooks) or byte-identical Slack request. No new run started.Expected for retries. To send a new event, use a new webhook-id.
200 {"status":"filtered"}The request was authentic but didn't match the trigger's run condition. Nothing ran.Check the trigger's run condition against the payload. A filtered request isn't remembered, so a retry is checked again.
401 {"error":"unauthorized"}Missing signature headers; wrong secret; body changed after signing; key not base64-decoded; webhook-id with characters other than letters, digits, _ . : - or longer than 128; signature without the v1, prefix; more than 8 signatures in the header; or a timestamp more than 5 minutes off.Check the secret (after a rotation, make sure the sender uses the new one). Sign the exact bytes you send. Check the sender's clock (see clock skew below).
401 only on some machines, or only some of the timeClock skew. The sender's clock is more than 5 minutes away from the real time, in either direction. A queue that delays sending can cause the same problem.Sync the sender's clock with NTP. Sign right before you send, not when you queue the message.
401 right after you saved a rotationYou chose Stop accepting it right away, or the 24-hour overlap has ended, and the sender still uses the old secret.Update the sender with the new secret.
401 from Slack's URL verificationThe signing secret isn't saved yet, or it's the wrong secret.Paste the app's current Signing Secret, save the agent, then retry the Request URL in Slack.
400 {"error":"bad_request"}Unsupported Content-Type (only application/json and application/x-www-form-urlencoded are accepted, with UTF-8 only); compressed body (Content-Encoding other than identity); body isn't valid JSON for application/json; or the URL doesn't belong to a webhook trigger.Send uncompressed JSON or form data with a supported Content-Type. Check that you copied the right URL.
403 {"error":"organization_inactive"}The NinjaCat organization that owns the trigger isn't active.Contact your NinjaCat administrator.
404 {"error":"not_found"}No trigger exists at this URL. It may have been deleted, or the URL was copied wrongly.Copy the Webhook URL again from the trigger.
409 {"error":"paused"}The trigger is paused or has ended. Requests sent while it is paused are not run and are not queued.Switch the trigger back to Active, then resend.
413 {"error":"payload_too_large"}The body is larger than 1 MiB (signed triggers) or 100 KiB (unsigned triggers).Send a smaller payload, for example IDs the agent can look up instead of full records.
500 {"error":"internal_error"}An unexpected error on NinjaCat's side.Retry later with the same webhook-id. If it continues, contact support.
503 {"error":"unavailable"}A temporary problem on NinjaCat's side. Also returned when you resend a delivery whose first run failed.Retry later. If a resend of the same webhook-id keeps getting 503, send the event again with a new webhook-id.

7. Limits and notes

  • Timestamp window: 5 minutes. A signed request is accepted only if its timestamp is within 300 seconds of NinjaCat's clock, before or after.
  • Body size. Up to 1 MiB for signed triggers, and 100 KiB for unsigned triggers.
  • Body handling. NinjaCat checks the signature against the exact bytes it receives, before it reads them. Only then is the body parsed: JSON becomes the payload the agent sees, and form data becomes a set of named fields.
  • Retries and duplicates. For Apps with a signing secret, the webhook-id identifies a delivery. A retry with the same webhook-id is answered {"status":"duplicate"} and doesn't start a second run. Retries must still arrive within the 5-minute timestamp window. A sender that retries later must re-sign with a fresh timestamp and the same webhook-id. For Slack, see the note on Slack retries in section 3b.
  • Paused triggers refuse deliveries. A paused or ended trigger answers 409 {"error":"paused"}, and nothing runs or queues. On a signed trigger, this answer goes only to requests with a valid signature. Everyone else gets 401. Slack's URL verification still works while the trigger is paused.
  • Unknown URLs. A URL that doesn't belong to any trigger answers 404 {"error":"not_found"}.
  • The secret is never shown again. NinjaCat stores it in a secure vault and never displays it, returns it, or logs it after you save.
  • Unsigned triggers. Triggers set to Anyone with the URL keep working as before. Anyone who has the URL can start them.

Did this page help you?