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.

  1. In the Workspace navigation, under Automation, open Agents, then select Configure on the agent.
  2. Under Deployments, select the deployment the webhook should run.
  3. Open the Webhooks tab and select New Webhook.
  4. Enter a Name (required). Description, Default instructions for the agent and IP restrictions are optional. See IP restrictions before filling in the last.
  5. 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

text
POST https://api.horizonplatform.ai/webhooks/agent/{token}
  • The token in the path is the only credential. Send no Authorization header 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 returns 404.
  • Only POST is served.
  • Only the body reaches the agent. Horizon reads your Content-Type and 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.

#CheckFails with
1Body size, and Content-Encoding if the body is compressed413, 415 or 400
2Requests from your IP address in the current minute429 or 503
3Token contains only letters, digits, _ and -404
4A JSON body has no charset or a utf- one, parses, and its top level is an object or array415 or 400
5Token matches a webhook401
6Webhook is enabled401
7Your IP address is in the webhook’s IP restrictions, if it has any403
8Requests to this webhook in the last 60 seconds429
9The webhook’s creator is an active member of the organization, if the webhook has a creator403 or 503
10A prompt is available400

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": [...]}.

bash
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:

FieldTypeEffect
initial_promptstringThe message the run starts with. Overrides the webhook’s Default instructions for the agent
contextobjectThe payload. A value that is not an object is passed as {"payload": <value>}
synchronousbooleantrue returns the run’s result instead of 202. Any other value, including the string "true", runs asynchronously
callback_urlstringNot available for external endpoints. Horizon sends a callback only to its own API host, never to your server
user_idanyIgnored. 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.

bash
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:

json
{
  "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:

json
{
  "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-level status. The top level says completed whenever the run returns. result.status is one of completed, errored or max_turns.
  • result.files appears only when the run created files. It is an array of objects with name, mime_type, size, row_count (tables only), download_url and storage_path.
  • A run that throws returns 500 with {"conversation_id": "…", "status": "failed", "error": "Agent execution failed."}.
Caution

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.

json
{"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.

StatusBodyCause
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.

LimitCounted perWindow429 bodyRetry hint
120 requestsYour IP address, across every webhook it callsFixed, 60 s{"error": {"code": "RATE_LIMITED", …}}Retry-After header: the seconds left in the window
60 requestsWebhookSliding, 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.7 or 203.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.