Webhook integration
Start a neuron from another neuron or from your own application. Check a call's status, and receive signed results at your own address.
Webhooks connect Neuron Workflows to your own systems in both directions. An inbound webhook starts a neuron or an agent with an HTTP call. An outbound webhook, the API destination in a neuron's SEND tab, posts the neuron's result to an address. Outbound calls are signed, tried again when they fail, and listed in the SEND tab.
Two ways to start a neuron
Each way has its own address and its own kind of sign-in, so the platform always knows who is calling.
| Caller | Address | Sign-in |
|---|---|---|
| Another neuron on this platform | /api/webhook/neuron/:neuronId | Signed by the platform with an internal key. You set nothing up. |
| Your own application | /api/webhook/external/:neuronId | A signed call with an external API key (nk_ext_…). |
A key of the wrong kind for an address is refused with status 403. An external key can't call a neuron address, and a neuron's internal key can't call an external address.
Endpoint reference
| Method and address | Used for | Sign-in |
|---|---|---|
POST /api/webhook/neuron/:neuronId | A neuron's SEND › API calling this neuron. There is no status call: these are fire-and-forget. | Internal key, signed by the platform |
POST /api/webhook/external/:neuronId | Your application starting a neuron. | External API key, signed |
POST /api/webhook/external/:neuronId/status | Checking a call made to the external address. | External API key, signed |
GET /api/webhook/health | Checking that the webhook service is up. | None |
POST /api/webhook/test/:neuronId | Sending a neuron's outbound webhooks once, to test them. | Session token |
GET /api/webhook/validate/:neuronId | Checking that a target neuron exists and is active. | Session token |
POST /api/webhook/validate-bulk | Checking several target neurons at once. | Session token |
Where the webhook panel is
Inbound webhooks are set up in the webhook panel. It is in two places:
- A neuron: on the canvas, open the neuron's START tab and choose Webhook.
- An agent: open the setup from My agents, click Settings, and find From a webhook under When it runs. Its switch turns the setup's webhook on or off.
The panel has three tabs: Setup (keys, the address, and which field of the call fills each input), Test and Calls. The line at the top says whether the webhook is ready, for example "Not ready: no key." or "Ready" with its keys and how many inputs the call fills.
Start a neuron from your system
- In the webhook panel, under Keys, click New key, then copy its Key id and Secret. See API keys.
- Under Request, copy the address shown after POST with Copy. It ends in
/api/webhook/external/followed by the neuron's ID. Copy curl copies a signed example call. - Under Fill inputs from the call, choose which field of your JSON fills each input (below).
- From your system, send a
POSTto the address with a JSON body and two headers:X-Api-Key: the key ID.X-Webhook-Signature: the hex-encoded HMAC-SHA256 of the exact request body, using the key's secret.
- The answer comes back right away (HTTP 201) with a
requestId. The neuron runs in the background.
An agent setup works the same way, from the panel in its Settings.
Here is a complete call:
BODY='{"getNodeInput":"Your input text here","getNodeInputType":"text","sourceId":"external-caller","sourceName":"My App"}'
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$KEY_SECRET" | sed 's/^.* //')
curl -X POST "$BASE_URL/api/webhook/external/$NEURON_ID" \
-H "Content-Type: application/json" \
-H "X-Api-Key: $KEY_ID" \
-H "X-Webhook-Signature: $SIGNATURE" \
--data-raw "$BODY"
Fill inputs from the call
Your system can send any JSON body. Under Fill inputs from the call, pick for each input where its value comes from:
- A field of the body, by name. A field inside another one is named with dots, for example
customer.email. A number or a true/false value is turned into text; a list or an object is sent as JSON text. - Whole body (JSON): the whole body as text.
- Fixed value: the same value on every call. Type it in the box under the input (Value used on every call). Until you type something, it fills nothing: the line at the top and the count of filled inputs don't count it, and a required input is still unfilled.
- No field: the input keeps the value it has; it is left as it is on webhook runs.
To see the field names, choose Fields from: Last call reads the last call the webhook received, and Paste a sample lets you paste a body your app sends and click Read fields. Click Auto-match to match fields to inputs by name.
- An input marked REQ is required. Every call is refused until a field fills it, and a call that lacks that field is refused with a 422; no run starts.
- Only text inputs can take a field. A file or a table can't come from a webhook field, and is left as it is on webhook runs.
- "Not in the sample. Calls must include it." means the field you chose isn't in the sample body.
- A value from a call is used for that run only. The input keeps the value you gave it on the canvas, also when a run that waited for an answer continues.
When no field is chosen for any input, a call's getNodeInput field fills the first text input, as in the body below:
{
"getNodeInput": "Your input text here",
"getNodeInputType": "text",
"sourceId": "external-caller",
"sourceName": "My App"
}
Every field is optional. The external address takes any JSON: a field that isn't in this table is ignored unless you fill an input from it (below). The neuron address takes only these fields (and documents, which another neuron's SEND › API adds) and refuses anything else with status 400.
| Field | Type | Description |
|---|---|---|
getNodeInput | string | The input for the neuron's GET. Leave it out, or send an empty string, to run the neuron with the inputs it already has. |
getNodeInputType | string | The kind of input: text, image, audio or table. |
sourceId | string | Who is calling, for your own records. It comes back as sourceNeuronId in the first answer. |
sourceName | string | A readable name for the caller. It labels the input in the neuron. |
timestamp | string | When you sent the call, as an ISO 8601 date. |
targetNeuronStateOverride | object | For this run only: do.value replaces the DO instructions and make.type the MAKE type. Nothing is saved to the neuron. |
Test the webhook
On the Test tab:
- Listen for a real call: click Listen for a call, then send a call from your app. The panel shows "Waiting for a call to this webhook…" until it arrives, checks it against your fields, and keeps its body as the sample. Click Cancel to stop listening. It needs a key first.
- Send a test call: edit the body and click Send test call. With Only check which fields fill the inputs ticked, no run starts and no credits are used; the answer shows Accepted and what each input would get. Untick it to start a real run, which uses credits like any other call.
The Calls log
The Calls tab lists the newest 50 calls the webhook received in the last 90 days, including refused ones; when there are more, the tab says it shows only the newest. Calls refused for going over a key's rate limit aren't listed. Each shows Started, Accepted (a check-only test) or Rejected with its status code, the key that made it (or "No valid key"), how long it took and when. Test calls are marked TEST.
Click a call to see why it was refused, and to switch between:
- Request: the request as it was received, with its secrets left out.
- Inputs: what each input got from the call.
- Response: the answer your system got.
Click Copy request to copy it, Use as the sample to read its fields under Setup, or Replay to send it again as a real run.
A call refused at the key check (a missing or wrong key, or a wrong signature) shows "Not read. The key check failed first." under Inputs, and can't be replayed. Its body is kept only when it named one of this webhook's keys.
Fill inputs from your own JSON
Your system doesn't have to use these field names. On the Setup tab, Fill inputs from the call picks which field of the incoming JSON fills each text input, such as lead.email. Auto-match fills it from the last call. A required input whose field is missing refuses the call with status 422, and nothing runs. Tables and files can't come from a field.
The Calls tab lists each call as it arrived and what each input got. Test sends a call from the panel without a key.
Sign the call
The signature covers the exact bytes you send. Sign the body as a string and send that same string. If you parse and re-write the JSON before sending, the spaces change and the signature no longer matches.
const crypto = require("crypto");
const body = JSON.stringify(payload);
const signature = crypto
.createHmac("sha256", apiSecret)
.update(body)
.digest("hex");
// send `body` with headers X-Api-Key: keyId, X-Webhook-Signature: signature
There are two kinds of key:
- External keys (
nk_ext_…) are the ones you create under START › Webhook. They call the external address. - Internal keys (
nk_int_…) are made and used by the platform for calls between your own neurons. Their secret is never shown. They call the neuron address only.
The first answer
{
"status": "received",
"requestId": "wrq_abc123…",
"targetNeuronId": "neuron-id",
"sourceNeuronId": "external-caller",
"timestamp": "2026-01-15T10:30:00.000Z"
}
If the neuron has an input set to Ask when run, the run waits for your answer on the canvas; the reply to the webhook then has no output yet.
Check a call's status
Send a POST to the same address with /status added at the end, signed with the same key, and the requestId in the body:
{ "requestId": "wrq_abc123…" }
The answer gives the status, not the result:
{
"requestId": "wrq_abc123…",
"targetNeuronId": "neuron-id",
"status": "completed",
"updatedAt": "2026-01-15T10:30:05.000Z"
}
- queued: received, waiting to run.
- processing: the neuron is running.
- waiting: the neuron is paused and waiting for an answer from a person. Open the workflow to answer it; the run then continues.
- completed: the run finished.
- failed: the run ended with an error.
- timed_out: the run didn't finish within 1 minute. The run itself may still finish; only the status stops waiting.
To get the result itself, add an outbound webhook to the neuron (below); its call carries this requestId, so you can match the result to your call. Or send the result on with SEND › Email, Telegram or Document.
Call one of your neurons from another
To have one neuron start another when it finishes, paste the second neuron's address into the first neuron's SEND › API Endpoint URL. Neuron signs these calls itself; you don't need a key. Connections on the canvas do the same job without an address; see Connections.
- Open the second neuron's START tab, go to Webhook, and copy the neuron-to-neuron address under Request. It ends in
/api/webhook/neuron/followed by the neuron's ID. - The first neuron's result becomes the second neuron's input: it arrives as
getNodeInputandgetNodeInputType, and the first neuron's name labels the input. - The call is fire-and-forget. There is nothing to poll.
- Each call carries an
X-Neuron-Traceheader, for exampleX-Neuron-Trace: root=wrq_abc123…;parent=3f1c2a9e-…;hop=2.rootis therequestIdof the first call in the chain,parentis therunIdof the run that made this call, andhopcounts the neuron-to-neuron calls so far. The second neuron keeps the trace with its run, and its own results carry its ownrequestIdand the chain'srootRequestId. It is a header, not a body field, because the neuron address refuses fields it doesn't know. - A chain of neurons calling each other can go at most 10 hops deep. The next call is refused with an error, so a loop of neurons that call one another stops by itself.
- With Attach the documents this run works on, the first neuron's tables, docs and slides go by reference, not as files. The second neuron reads them for that run with the first neuron's access; they aren't added to its inputs. Uploaded files aren't passed.
- The external address can't be used here: it needs an external key, which a neuron doesn't have. SEND › API shows a warning if you paste one.
Send results to your address (outbound)
- Open the neuron's SEND tab and choose API.
- Type your address under Endpoint URL; adding it turns API on. It must start with https://, http:// or /. It is saved when you leave the field; clearing it turns API off.
- When the neuron finishes, it sends a signed
POSTwith a JSON body to your address. When the whole run ends, it sends one more call to say so (see The run.finished event).
Your address must be reachable from the internet; addresses on a private network are refused, and a redirect is not followed. Answer with any 2xx status to confirm you have the call. See Delivery and retries for what happens when you don't.
The result: neuron.result
{
"event": "neuron.result",
"sourceId": "neuron-id",
"sourceName": "Summarise",
"makeNodeOutput": "The neuron's result",
"makeNodeOutputType": "text",
"timestamp": "2026-01-15T10:30:05.000Z",
"requestId": "wrq_abc123…",
"rootRequestId": "wrq_abc123…",
"runId": "3f1c2a9e-5b7d-4c1e-9a64-0d2b8f7e6a11"
}
| Field | Description |
|---|---|
event | Always neuron.result for a result. |
makeNodeOutput | The result the neuron produced. |
makeNodeOutputType | The kind of result: text, image, audio or table. |
sourceId | The ID of the neuron that sent it. |
sourceName | The neuron's name. |
timestamp | When it was sent, as an ISO 8601 date. |
requestId | The requestId your call got in the first answer, so you can match the result to your call. null when no call from outside started this run (Play, a schedule). |
rootRequestId | The requestId of the first call in the chain. It equals requestId unless another neuron started this one; see Call one of your neurons from another. |
runId | The run this result belongs to. A run that is started again after a restart keeps its requestId and gets a new runId. |
row | Only when the neuron runs once per row: { "number": 3, "of": 400 }, the row this result is for. |
The fields from before are unchanged; the others are new. If your code rejects fields it doesn't know, let it accept these.
If the neuron attaches documents, the body also has an attachments list: each document, and the image, audio or PDF the neuron made, as a base64 file. If the documents are too big to send, the send fails with a message; an uploaded file that doesn't fit is left out instead, and attachmentNotes says which and why.
A neuron that runs once per row sends one neuron.result for each row, each with its row, and then the single run.finished call when the pass ends.
Each call also has these headers:
| Header | Value |
|---|---|
X-Neuron-Delivery-Id | An ID for this delivery, like dlv_3b1f…. It stays the same for every retry of the same call. Use it to avoid handling a call twice. |
X-Neuron-Event | neuron.result or run.finished, the same as event in the body. |
X-Neuron-Request-Id | The requestId, repeated so a proxy or log can show it. Only sent when requestId is not null. |
X-Neuron-Delivery-Attempt | Which try this is, from 1 to 4. |
X-Neuron-Signature | Proves the call came from Neuron and wasn't changed. See Check the signature. |
The run.finished event
When a run ends, each API destination that took part in that run gets one more call, once per run, for every kind of run: a webhook call, Play, a schedule, a pass over rows or an agent. It is signed and retried like a result.
{
"event": "run.finished",
"sourceId": "neuron-id",
"sourceName": "Summarise",
"requestId": "wrq_abc123…",
"rootRequestId": "wrq_abc123…",
"runId": "3f1c2a9e-5b7d-4c1e-9a64-0d2b8f7e6a11",
"status": "completed",
"startedAt": "2026-01-15T10:30:00.000Z",
"finishedAt": "2026-01-15T10:30:05.000Z",
"timestamp": "2026-01-15T10:30:05.500Z"
}
| Field | Description |
|---|---|
status | completed, failed or stopped. |
startedAt, finishedAt | When the run started and ended, as ISO 8601 dates. |
requestId, rootRequestId, runId | The same as in a result. |
sourceId, sourceName, timestamp | The neuron the call is sent for, and when it was sent. |
rows | Only for a pass over rows: { "succeeded": 398, "failed": 2, "total": 400 }. |
Use it as the "all done" signal for a run. Results and the run.finished call are delivered separately and can arrive in any order, so match them by runId and don't count on run.finished coming last.
Check the signature
Every call from SEND › API to another application is signed with a secret that belongs to that destination. Under Endpoint URL, the Signing secret row has Reveal, Copy and Rotate. The secret starts with whsec_; use the whole string as the key.
For an agent you set up, an address you typed under Where results go is signed with that setup's own secret: the same Signing secret row and Recent deliveries show under the address in the agent's Settings.
The X-Neuron-Signature header looks like this:
X-Neuron-Signature: t=1768473005,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
tis the time the call was signed, in Unix seconds.v1is the hex-encoded HMAC-SHA256, with the signing secret as the key, oft, a dot and the exact body you received:t + "." + rawBody.
To check a call, recompute v1 from the raw body, compare it with the one in the header, and refuse the call if they differ. Also refuse it if t is more than five minutes from your own clock, so an old call can't be replayed. Use the body as received: parsing the JSON and writing it back changes the spaces and breaks the match.
const crypto = require("crypto");
// rawBody: the request body exactly as received (a string or Buffer), before any JSON parsing.
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
# raw_body: the request body exactly as received, before any JSON parsing.
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if not t or abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Keep the secret on your server, like an API key's secret. The SEND › API panel shows the same two snippets under How to verify.
Rotate the secret
Rotate in the SEND › API panel makes a new secret. The old one stops working the moment you confirm: there is no overlap and a call is never signed with both. A call that is waiting to be retried is signed with the new secret when it goes out, with a fresh time in t on every attempt. Update your server as soon as you rotate: until you do, it will refuse every call. Rotate a secret if you think it has leaked, or when someone who could see it leaves your team.
Delivery and retries
Delivery is at least once. A result is saved when the run produces it and sent separately, so the neuron never waits for your server. A call is tried again when your server doesn't confirm it, and your server may have handled a call whose confirmation was lost. The same call can therefore arrive more than once. Dedupe on X-Neuron-Delivery-Id: keep the IDs you have handled, and when one comes again, answer 2xx and do nothing. A neuron that runs once per row sends each row's result once per pass: if a row runs again, its first result is the one you get.
- Confirmed: any 2xx status.
- Tried again: no answer in time, a network error, and the statuses 408, 429 and 5xx.
- Not tried again: any other status, such as 400, 401, 403, 404 or 410. These count as a failed delivery straight away.
- How many times: four attempts in all: the first send, then retries about one minute, five minutes and thirty minutes after each failure. A call that keeps failing is given up on about thirty-six minutes after it was first sent. A redirect (3xx) is not followed and counts as a failure that is not tried again.
- Kept: a delivery's record is kept for seven days.
If your address stays down for longer than the retries last, that call is given up on and is shown as failed in Recent deliveries. If you need every result, also save results to a table with SEND › Document, or check the call's status (Check a call's status).
Recent deliveries
The SEND › API panel lists the latest deliveries to your address: when, the event (neuron.result or run.finished), the HTTP status your server answered with, the number of attempts and the final state. Use it to see why a result didn't arrive.
Postman templates
Download a ready-made Postman collection from the neuron's START › Webhook: Postman under Request for the external address, and next to the neuron-to-neuron address for the reference. It is filled in for that neuron and this platform's address. Import it into Postman. In the external collection, set key_id and key_secret in the collection's variables; a script in it signs the exact body of each request. The same collection has a Receive result request that sends a sample result to your own address, signed the way Neuron signs it, so you can test your receiver before a neuron sends anything. The neuron-to-neuron one is a reference only and has no key to set.
Error codes
An error answer has this shape. Branch on error.code:
{
"error": {
"message": "Invalid API key or signature",
"code": "WEBHOOK_API_KEY_INVALID"
},
"statusCode": 401,
"path": "/api/webhook/external/neuron-id",
"timestamp": "2026-01-15T10:30:00.000Z"
}
For the external and neuron addresses and the status call:
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | A field has the wrong type, or the body has a name the platform doesn't know. The answer lists each field. |
| 400 | TARGET_NEURON_ID_REQUIRED | The address has no neuron ID. |
| 401 | WEBHOOK_API_KEY_REQUIRED | The X-Api-Key header is missing. |
| 401 | WEBHOOK_SIGNATURE_MISSING | The X-Webhook-Signature header is missing. |
| 401 | WEBHOOK_RAW_BODY_MISSING | The body couldn't be read to check the signature. Send JSON with a Content-Type of application/json. |
| 401 | WEBHOOK_API_KEY_INVALID | The key is wrong, revoked or expired, or the signature doesn't match the body. |
| 401 | WEBHOOK_API_KEY_TARGET_MISMATCH | The key was made for a different neuron. |
| 403 | WEBHOOK_CALLER_TYPE_MISMATCH | The key is the wrong kind for this address. |
| 403 | WEBHOOK_OFF | The webhook is switched off. Nothing ran. |
| 404 | WEBHOOK_REQUEST_NOT_FOUND | A status call named a requestId that doesn't exist, belongs to another key, or was a Test call from the panel. |
| 413 | The body is too large. | |
| 422 | WEBHOOK_MISSING_FIELD | A required input is filled from a field the call doesn't have. Nothing ran. |
| 423 | WORKFLOW_PAUSED | The neuron's workflow is paused. Nothing ran, and the call isn't kept to run later. |
| 423 | WORKFLOW_DELETED | The neuron's workflow was deleted. Nothing ran. |
| 429 | WEBHOOK_RATE_LIMIT_EXCEEDED | The key went over its own rate limit. The answer has a Retry-After header. |
| 429 | Too many calls from one client across the whole platform. Wait and retry. | |
| 5xx | A server error. Retry later. |
A call to the wrong address for its key is refused with 403, and it still counts against that key's rate limit.
Limits and good practice
- Status wait: a call's status turns
timed_outafter 1 minute. - Retries of your calls: retry only after a 429 or a 5xx. A 400, 401, 403, 404, 422 or 423 won't change on its own, so fix the call (or the neuron's settings) first.
- Duplicates: use the
requestIdto avoid starting the same neuron twice. For results sent to you, useX-Neuron-Delivery-Id: delivery is at least once. - Results to you: confirm with a 2xx status quickly, and do the slow work afterwards. A call your server doesn't answer in time is sent again.
- Size: a body can be up to 32 MB.
- Secrets: keep key secrets on your server, never in a browser or an app others can open.
Common problems
X-Api-Key header is required
The call has no X-Api-Key header. Add it with the key ID.
Invalid API key or signature
The key ID is wrong, the key is revoked or expired, or the signature doesn't match. Sign the exact string you send, with the key's secret, and send the signature as hex.
API key not authorized for this neuron
The key was made for a different neuron or agent. Use a key created in this neuron's or setup's webhook panel.
This endpoint requires an external API key
You called the neuron address with an external key, or the external address with an internal key. Your own application calls /api/webhook/external/. Only another neuron's SEND › API calls /api/webhook/neuron/.
Rate limit exceeded for this API key
The key went over its rate limit. This reply has status 429 and a Retry-After header with the number of seconds to wait before trying again; most HTTP clients and integration tools retry a 429 automatically. A wrong key or signature is a 401 instead. Send calls less often, or raise the limit on the API Keys page.
A field is refused
The neuron address got a field that isn't in the body above. Send only those fields. The external address ignores such a field instead, so an input your call should fill stays empty: send it as getNodeInput, or fill the input from your own field on the Setup tab.
A required input was missing
The call didn't include the field chosen for a required input. It gets a 422 and no run starts; the Calls log says which input. Add the field to your call, or choose another field under Fill inputs from the call.
This webhook is switched off
The setup's From a webhook switch is off, so calls get a 403 and no run starts. Switch it on in the setup's Settings.
This workflow's triggers are paused
The workflow's triggers are paused ("This workflow's triggers are paused. Nothing was run."), or the workflow was deleted ("This workflow was deleted. Nothing was run."). The call gets a 423 and nothing runs, and the Calls log shows the same reason. To take calls again, click Resume in the canvas's Triggers paused bar; see When triggers are paused.
My address receives nothing
Check that SEND › API is on, that the Endpoint URL is reachable from the internet, and that the neuron's run finished without an error. Recent deliveries in the SEND › API panel shows each call, the status your server answered with and whether it will be tried again.
Every call is refused for a bad signature
Check the signature against the raw body, not a parsed copy, and use the whole signing secret, whsec_ included. If you rotated the secret, the old one stopped working at once: copy the new one with Reveal. Also check that your server's clock is correct, because a call is refused when t is more than five minutes away from it.
The same result arrived twice
Delivery is at least once. Keep the X-Neuron-Delivery-Id values you have handled and skip a repeat. See Delivery and retries.
Keep calls safe. Keep key secrets and signing secrets on your server, never in a browser or app others can open. Use https addresses. Check the signature on every result you receive, use the requestId and X-Neuron-Delivery-Id to avoid handling the same call twice, set rate limits on your keys, and watch the API Keys page for unusual use.