Agent webhooks
Start an agent run by sending an HTTP POST to its webhook URL. Getting the URL, the request body, who the run acts as and what happens when that person leaves, synchronous and asynchronous responses, limits and every status code.
An agent webhook is a URL that starts a run of one agent when another system sends it an HTTP POST. This page is the contract for that request.
Get a trigger URL
You create webhooks in the dashboard. No API key can do it: the routes behind the Webhooks tab
accept only a signed-in session, and refuse an API key with 403.
- In the Workspace navigation, under Automation, open Agents, then select Configure on the agent.
- Under Deployments, select the deployment the webhook should run.
- Open the Webhooks tab and select New Webhook.
- Enter a Name (required). Description, Default instructions for the agent and IP restrictions are optional. See IP restrictions before filling in the last.
- Select Create Webhook. The Webhook Created dialog shows the full URL once. Select Copy URL and store it as a secret.
A webhook is tied to one agent deployment, and every new webhook starts enabled. You need the Workspace module and write access to agent deployments. Every run the webhook starts acts as you, the person who created it, and the webhook runs only while you are an active member of the organization. See Who the run acts as.
The webhook card
Each webhook has a card on the Webhooks tab.
- The URL is masked, and the masked URL does not work. The full URL is shown only once, in the dialog after Create Webhook or Rotate Token. If you did not save it, select Rotate Token to get a new one. The old URL stops working immediately.
- Runs as names the member whose runs the webhook starts: its creator. A webhook with no creator on record shows Runs as no one.
- Created shows the date the webhook was created.
- Paused: creator is no longer an active member appears when the creator has been deactivated, removed or has left. A creator who is no longer in the organization at all is not named on the card. See When the creator leaves.
Test runs the agent with a prompt and JSON payload you enter. It does not go through the webhook URL, so it does not check the token, the IP restrictions, the rate limits, whether the creator is still a member, or how your sender’s body is read. It also does not act as the webhook’s creator: a test run has no user, so on an agent whose memory belongs to each person it has no memory. To test the real path, POST to the URL.
Trigger a webhook
The trigger URL
POST https://api.horizonplatform.ai/webhooks/agent/{token}- The token in the path is the only credential. Send no
Authorizationheader and no API key. Anyone who has the URL can run the agent. Each run spends from your organization’s wallet like any other agent run. - The token is
hzn_wh_followed by 96 lowercase hex characters. Horizon stores only its SHA-256 hash, so it cannot show you the token again. - The path is
/webhooks/agent/, not under/api/v2./api/webhooks/agent/on this host returns404. - Only
POSTis served. - Only the body reaches the agent. Horizon reads your
Content-Typeand the IP address the request comes from, and ignores every other header, including any signature your sender adds.
Horizon checks a request in this order. The first check that fails sets the response.
| # | Check | Fails with |
|---|---|---|
| 1 | Body size, and Content-Encoding if the body is compressed | 413, 415 or 400 |
| 2 | Requests from your IP address in the current minute | 429 or 503 |
| 3 | Token contains only letters, digits, _ and - | 404 |
| 4 | A JSON body has no charset or a utf- one, parses, and its top level is an object or array | 415 or 400 |
| 5 | Token matches a webhook | 401 |
| 6 | Webhook is enabled | 401 |
| 7 | Your IP address is in the webhook’s IP restrictions, if it has any | 403 |
| 8 | Requests to this webhook in the last 60 seconds | 429 |
| 9 | The webhook’s creator is an active member of the organization, if the webhook has a creator | 403 or 503 |
| 10 | A prompt is available | 400 |
Request body
Send Content-Type: application/json, with a body of up to 2 MB. A request with no Content-Type
is read as JSON. With any other content type, Horizon accepts the request but ignores the body
and runs the agent on the webhook’s Default instructions for the agent alone, with no payload.
A body sent as JSON must parse, and its top level must be an object or an array. Otherwise the
request fails with 400 before the token is checked, and nothing runs. Send charset=utf-8 or no
charset: a charset that does not start with utf-, such as iso-8859-1, latin1 or utf8 without
the hyphen, fails with 415.
You can compress the body with Content-Encoding: gzip, deflate or br. Any other
Content-Encoding fails with 415, and a body that does not decompress fails with 400.
What Horizon reads from the body depends on whether it contains an initial_prompt or a context
key.
Neither key: the whole body is the payload. This lets you point a CRM, form builder or other
sender at the URL without reshaping what it sends. The prompt comes from the webhook’s Default
instructions for the agent. Keys named user_id, synchronous or callback_url stay in the
payload and are not treated as settings. If the body is a JSON array, the agent receives it as
{"payload": [...]}.
curl -X POST "https://api.horizonplatform.ai/webhooks/agent/$HORIZON_WEBHOOK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"order_id": "10482", "customer": "Acme Ltd", "total": 1840.00}'If the webhook has no default instructions, this form fails with 400 and you must use the
envelope below.
Either key: the body is an envelope. These keys are settings:
| Field | Type | Effect |
|---|---|---|
initial_prompt | string | The message the run starts with. Overrides the webhook’s Default instructions for the agent |
context | object | The payload. A value that is not an object is passed as {"payload": <value>} |
synchronous | boolean | true returns the run’s result instead of 202. Any other value, including the string "true", runs asynchronously |
callback_url | string | Not available for external endpoints. Horizon sends a callback only to its own API host, never to your server |
user_id | any | Ignored. It does not choose who the run acts as, and it is not passed to the agent. See Who the run acts as |
Any other top-level key is added to the payload. If a key appears both at the top level and inside
context, the value in context wins.
A request that sends a callback_url succeeds as usual, and no callback reaches your server. To get
a run’s result, use "synchronous": true.
curl -X POST "https://api.horizonplatform.ai/webhooks/agent/$HORIZON_WEBHOOK_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"initial_prompt": "Draft a welcome email for this new customer.",
"context": { "customer": "Acme Ltd", "plan": "annual" },
"synchronous": true
}'How the agent sees it. The prompt becomes the run’s first message. The payload is added to the
agent’s instructions as JSON under the heading Webhook Payload. If neither the body nor the
webhook’s default instructions supply a string initial_prompt, the request fails with 400.
Asynchronous response
By default Horizon creates the conversation, starts the run in the background and returns 202:
{
"conversation_id": "3f0c1e9a-5b7d-4c2e-9a41-8d2f6b0e7c13",
"status": "accepted",
"message": "Webhook received. Agent execution started."
}202 means the run has started, not that it succeeded. A run that fails is not retried. You can see
the outcome in the deployment’s Conversations tab, which owners and admins can open. There is no
API to poll for the result.
Synchronous response
With "synchronous": true, the connection stays open until the run finishes, then returns 200:
{
"conversation_id": "3f0c1e9a-5b7d-4c2e-9a41-8d2f6b0e7c13",
"status": "completed",
"result": {
"status": "completed",
"final_message": "Here is a draft welcome email for Acme Ltd: ...",
"total_turns": 2,
"total_tokens_used": 4120
}
}- Read
result.status, not the top-levelstatus. The top level sayscompletedwhenever the run returns.result.statusis one ofcompleted,erroredormax_turns. result.filesappears only when the run created files. It is an array of objects withname,mime_type,size,row_count(tables only),download_urlandstorage_path.- A run that throws returns
500with{"conversation_id": "…", "status": "failed", "error": "Agent execution failed."}.
Horizon waits 30 seconds, and the run does not stop when it gives up. A synchronous run that
takes longer returns 504 ENGINE_TIMEOUT, and the agent keeps working until it finishes. Many
senders retry a 5xx automatically, and each retry starts a second run that is billed separately.
Configure your sender not to retry a 504 from this URL, or use the asynchronous form.
Who the run acts as
A webhook run acts as the person who created the webhook. Horizon records that person when they select Create Webhook. Editing the webhook, Rotate Token and anything in a request do not change it. Take over and Move webhooks to me do: see When the creator leaves.
- Memory. On an agent with Agent memory on and Who the memories belong to set to Each person, the run reads and writes the creator’s memories on that agent. With Shared across the organization, it uses the shared memories, as any run does.
- Time. The agent’s clock uses the creator’s timezone preference, or UTC if they have not set one.
- Instructions. A webhook run is unattended: whoever holds the URL writes the payload, not the creator. So the agent is not offered the tools that change its own instructions, even when Agent can update its instructions is on, and neither is any agent it hands work to.
A user_id in the request is ignored. Requests that send one still succeed.
Anyone who holds the URL can start runs that read and write the creator’s memories on that agent, with a payload they choose. If the URL leaks, rotate the token or delete the webhook.
Webhooks created before 24 September 2026 have no creator on record, and their runs act as no one. On an agent whose memory belongs to each person, those runs have no memory, and the agent’s clock is in UTC. Their cards show Runs as no one. Such a webhook is never paused and cannot be taken over. To give it a creator, create a new webhook, point your sender at the new URL, and delete the old one.
When the creator leaves
A webhook runs only while the person who created it is an active member of the organization. If they
are deactivated, removed from the organization or leave it, the webhook is paused: every request
gets 403 with this body, and no run starts.
{"error": "This webhook is paused because the person who created it is no longer an active member of the organization."}Pausing changes nothing on the webhook: its URL, token and Enabled setting stay as they were. The webhook’s card shows Paused: creator is no longer an active member. Horizon checks on every request, after the per-webhook rate limit, so a refused request still counts toward that limit.
Three things keep a webhook running, or resume a paused one from its next request. The URL and token stay the same in each case.
- Move it before you deactivate someone. In Settings > Users, Deactivate opens a confirmation that lists the agent webhooks the person created and their API keys. An owner or admin can select Move webhooks to me before confirming, and those webhooks run as them.
- Take it over. On a paused webhook’s card, an owner or admin can select Take over, then Confirm. The webhook runs as them from then on.
- Reactivate the person, or invite them back. The webhook runs as them again.
A webhook moves only to the owner or admin who selects Take over or Move webhooks to me. No one can move a webhook to anyone else, because a webhook reads and writes the per-person memories of whoever it runs as. If you leave the organization yourself, the Leave organization confirmation in Settings > General lists the webhooks you created. Ask an owner or admin to move them to themselves before you leave if they should keep running.
If Horizon cannot confirm the creator’s membership, the request gets 503 with
{"error": "Could not verify this webhook's creator. Try again later."} and nothing runs. It is safe
to retry.
Status codes
Two body shapes appear below. The size limit, Content-Encoding failures, the per-IP rate limit
and delivery failures use {"error": {"code": "…", "message": "…"}}. In every other error, error is a plain sentence with no
code.
| Status | Body | Cause |
|---|---|---|
| 200 | {"conversation_id", "status": "completed", "result"} | Synchronous run finished |
| 202 | {"conversation_id", "status": "accepted", "message"} | Asynchronous run started |
| 400 | {"error": {"code": "INVALID_BODY_ENCODING", "message": "Request body could not be decompressed."}} | The body is labelled gzip, deflate or br but does not decompress |
| 400 | {"error": "Request body is not valid JSON."} | A body sent as JSON does not parse |
| 400 | {"error": "Request body must be a JSON object or array."} | A body sent as JSON is a string, number, boolean or null at the top level |
| 400 | {"error": "initial_prompt is required."} | Neither the body nor the webhook’s default instructions supply a prompt |
| 401 | {"error": "Invalid webhook token."} | No webhook has this token: mistyped, rotated or deleted |
| 401 | {"error": "Webhook is disabled."} | The webhook is switched off |
| 403 | {"error": "IP address not allowed."} | Your address is not in the webhook’s IP restrictions. See IP restrictions |
| 403 | {"error": "This webhook is paused because the person who created it is no longer an active member of the organization."} | The webhook’s creator is deactivated, has been removed or has left. See When the creator leaves |
| 404 | (empty) | The token contains characters other than letters, digits, _ and - |
| 413 | {"error": {"code": "PAYLOAD_TOO_LARGE", "message": "Request body is larger than this endpoint accepts."}} | Body over 2 MB |
| 415 | {"error": {"code": "UNSUPPORTED_MEDIA_TYPE", "message": "unsupported content encoding \"…\""}} | A Content-Encoding other than gzip, deflate or br |
| 415 | {"error": "Content-Type charset must be utf-8."} | A JSON body whose Content-Type charset does not start with utf-, such as iso-8859-1, latin1 or utf8 |
| 429 | {"error": {"code": "RATE_LIMITED", "message": "Too many requests"}} | Over 120 requests a minute from your IP address |
| 429 | {"error": "Rate limit exceeded.", "retry_after": 60} | Over 60 requests to this webhook in 60 seconds |
| 500 | {"error": "Failed to create conversation."} | The conversation could not be created |
| 500 | {"conversation_id", "status": "failed", "error": "Agent execution failed."} | A synchronous run threw |
| 500 | {"error": "Internal server error."} | Any other failure while starting the run |
| 502 | {"error": {"code": "ENGINE_UNREACHABLE", "message": "Agent engine did not respond"}} | Horizon’s agent service could not be reached |
| 503 | {"error": {"code": "RATE_LIMIT_UNAVAILABLE", "message": "Rate limiting is temporarily unavailable. Please retry shortly."}} | The per-IP rate limiter is unavailable. Horizon refuses requests until it recovers |
| 503 | {"error": "Could not verify this webhook's creator. Try again later."} | Horizon could not check whether the webhook’s creator is still a member. Nothing ran |
| 504 | {"error": {"code": "ENGINE_TIMEOUT", "message": "Agent engine did not respond within 30s. …"}} | No response within 30 seconds. The run may still be going |
Rate limits
Two limits apply, and neither can be configured per webhook.
| Limit | Counted per | Window | 429 body | Retry hint |
|---|---|---|---|---|
| 120 requests | Your IP address, across every webhook it calls | Fixed, 60 s | {"error": {"code": "RATE_LIMITED", …}} | Retry-After header: the seconds left in the window |
| 60 requests | Webhook | Sliding, 60 s | {"error": "Rate limit exceeded.", "retry_after": 60} | Retry-After: 60 header, and retry_after in the body |
A sender calling one webhook reaches the per-webhook limit first. A sender calling three webhooks shares its 120 across all three.
Requests refused by the per-webhook limit still count toward that webhook’s window. A sender that
retries immediately stays blocked. Wait the full Retry-After before trying again.
Every response that gets past the per-IP limit carries X-RateLimit-Limit: 120 and
X-RateLimit-Remaining. Both describe your IP address’s budget, not the webhook’s. If the per-IP
limiter is unavailable, requests get 503 with Retry-After: 60.
Security
- No request signing. Horizon does not verify a signature on incoming requests, and there is no secret to configure. The token is the whole of the authentication.
- The token travels in the URL path. Any system that logs full request URLs, including proxies between your sender and Horizon, can record it.
- To revoke a URL, use Rotate Token (a new token is issued and the old one stops working
immediately), clear Enabled (requests get
401 Webhook is disabled.), or Delete it.
IP restrictions
A webhook with entries under IP restrictions runs only for a sender whose address is on the
list. Any other sender gets 403 {"error": "IP address not allowed."}. The check comes after the
token and Enabled checks and before the per-webhook rate limit, so a refused call does not use
the webhook’s budget. Leave the field empty to accept any address.
- The address checked is the public IP address your request arrives from. If your sender goes out through a proxy or NAT gateway, list that address.
- An entry is one address or an IPv4 CIDR range, one per line, such as
203.0.113.7or203.0.113.0/24. A single address is compared as text. There are no IPv6 ranges, so to allow a sender that connects over IPv6, list its exact address. Nothing validates the entries when you save them: a malformed one matches nothing. - The token is still the credential. The list is a second condition on a caller that already holds the URL.