Receiving webhooks
An inbound endpoint is a URL you hand to something outside the platform. When that system POSTs to it, the delivery is stored and then dispatched to whatever the endpoint is bound to: an agent, a data pipeline, or an existing agent session. This page also carries the signature verifier for both directions of webhook traffic.
The hookId in the URL is the secret
Deliveries arrive at POST https://api.superagnt.com/webhooks/ingest/:hookId. The route is unauthenticated on purpose: the hookId is an opaque UUID that does not leak your workspace id and survives renaming the endpoint, and knowing it is what authorizes the POST. Treat the URL the way you would treat a credential, and layer signature verification on top when the sender can sign.
There is a second form with a session id appended: POST /webhooks/ingest/:hookId/:sessionId. That variant resumes an existing agent session instead of starting a new one, and the platform checks the session belongs to the endpoint’s bound agent before it resumes anything. This is how an agent hands out a genuine call-me-back URL: it asks for its own inbound URL, appends its session id, and reacts when the answer arrives instead of polling for it.
200 first, work afterwards
An accepted delivery answers 200 { received: true, deliveryId }. Dispatch to the bound target happens after that response is on the wire, so a slow agent never holds the sender open and never trips its timeout.
The consequence to plan for: a dispatch failure does not change the HTTP response. The sender already saw a 200. The failure is recorded on the delivery row instead, which is why the delivery log is the place you debug “the webhook fired but nothing happened”.
| Binding | What a delivery does |
|---|---|
| session-targeted URL | Highest priority. Resumes that session with the payload as a new turn. |
| data pipeline | Enqueues one pipeline item with the body as its payload. The right binding for high-volume, non-conversational ingestion. |
| deployed agent | Starts a fresh agent session and auto-acknowledges the delivery on success. |
| unbound | Stored only. Read it back from the delivery log when you are ready. |
An endpoint targets an agent or a pipeline, never both: binding one clears the other.
The agnt_ HMAC scheme
Verification is optional per endpoint. When it is on, a signed request carries two headers: x-agnt-webhook-timestamp (unix seconds) and x-agnt-webhook-signature (v1=<hex>). The signed string is v1:${timestamp}:${rawBody}, hashed with HMAC-SHA256 under the endpoint’s signing secret and hex encoded. The receiver compares in constant time and rejects anything more than 300 seconds away from now in either direction.
POST /webhooks/ingest/<hookId> HTTP/1.1
Host: api.superagnt.com
Content-Type: application/json
X-Agnt-Webhook-Timestamp: 1758326400
X-Agnt-Webhook-Signature: v1=9f2c… (hex hmac-sha256)
{"order_id":"ord_123","total":4200}hash the raw bytes
import { createHmac, timingSafeEqual } from "node:crypto";
const REPLAY_WINDOW_SECONDS = 300;
/**
* Verify an agnt_ inbound webhook signature.
* rawBody MUST be the exact bytes as received. Parse the JSON afterwards,
* never re-serialize before hashing.
*/
export function verifyAgntWebhook(secret, rawBody, headers) {
const timestamp = headers["x-agnt-webhook-timestamp"];
const signature = headers["x-agnt-webhook-signature"];
if (!timestamp || !signature) return { ok: false, reason: "missing_headers" };
const ts = Number.parseInt(timestamp, 10);
if (!Number.isFinite(ts)) return { ok: false, reason: "bad_timestamp" };
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > REPLAY_WINDOW_SECONDS) {
return { ok: false, reason: "replay" };
}
const base = "v1:" + timestamp + ":" + rawBody;
const expected = "v1=" + createHmac("sha256", secret).update(base).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length) return { ok: false, reason: "mismatch" };
return timingSafeEqual(a, b) ? { ok: true } : { ok: false, reason: "mismatch" };
}A failed verification is 401 with a reason of missing_headers, bad_timestamp, replay or mismatch, and nothing is recorded: a request that fails the signature check leaves no delivery row, because a bad signature is presumed adversarial. If you are debugging a sender and see no deliveries at all, the signature is the first thing to check.
Turning verification on is a property of the endpoint, set when it is created or by rotating a secret onto it. An endpoint created over REST takes only a name and a description, so it starts unsigned; creating it with agnt_webhooks_create_endpoint generates a secret by default for a user-controlled sender and always for an agent sender, and agnt_webhooks_rotate_secret mints a new secret and switches verification on. The plaintext secret is returned once, at the moment it is generated.
One caveat for third-party SaaS senders: most ship their own signing scheme rather than this one. Leave verification off for those and rely on the opaque URL, or the receiver will reject every delivery.
When the receiver says no
| Status | Cause | What comes back |
|---|---|---|
413 | Payload over 1 MiB. | Nothing stored. Send a reference instead of the blob. |
404 | Unknown hookId, or an endpoint that no longer ingests. | Nothing stored. |
402 | The organization is over its webhook-event allowance for the billing period. | used and limit in the body, Retry-After: 3600, and the payload is not stored. |
401 | Signature verification failed on a signed endpoint. | A reason string. Nothing stored. |
a 402 drops the payload
Body, headers, source IP
A JSON content type with a non-empty body is parsed and stored as JSON. Any other content type is stored as a raw wrapper, so the body is still there as text. A JSON body that does not parse is stored with a parse-error marker beside the raw text, which is how a malformed sender shows up as a readable delivery rather than a silent drop.
Request headers are stored too, minus the ones that carry secrets or proxy noise: authorization, cookie, and the cf- and x-forwarded- families are stripped. The source IP is recorded separately.
Over REST
Endpoints are create, list, read and delete under /v1/webhook-endpoints, authenticated with Authorization: Bearer <key>.
curl -X POST https://api.superagnt.com/v1/webhook-endpoints \
-H "Authorization: Bearer $AGNTDATA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"orders-created","description":"One delivery per new order"}'
# 201 → { "success": true, "data": { "id": "...", "name": "orders-created",
# "url": "https://api.superagnt.com/webhooks/ingest/<hookId>", "signingSecret": null } }| Route | Does |
|---|---|
POST /v1/webhook-endpoints | Creates one. Body is a name and an optional description. 409 when the name is taken in this workspace. |
GET /v1/webhook-endpoints | Every endpoint in the workspace. |
GET /v1/webhook-endpoints/:id | One endpoint. 404 when it is not yours. |
DELETE /v1/webhook-endpoints/:id | Deactivates it — the soft delete used throughout. The URL stops ingesting and the endpoint leaves the list; agnt_webhooks_set_active can bring it back. |
GET /v1/webhook-endpoints/deliveries | The delivery log. Filter with endpointId and unacknowledged; page with limit and cursor. |
POST /v1/webhook-endpoints/deliveries/:id/ack | Acknowledges one delivery. |
POST /v1/webhook-endpoints/deliveries/ack | Acknowledges a non-empty array of ids in one call. |
curl "https://api.superagnt.com/v1/webhook-endpoints/deliveries?unacknowledged=true&limit=50" \
-H "Authorization: Bearer $AGNTDATA_API_KEY"
# page forward by passing the returned cursor back
curl "https://api.superagnt.com/v1/webhook-endpoints/deliveries?cursor=<cursor>" \
-H "Authorization: Bearer $AGNTDATA_API_KEY"pagination
From an agent
The agnt_webhooks_* tools cover the same ground from a connected MCP client, and go further than REST does: create_endpoint (with signing and an optional binding in one call), list_endpoints, link_agent and link_data_job to bind or detach a target, set_active to disable an endpoint without deleting it, rotate_secret, and list_deliveries / get_delivery for the audit log (the list omits the payload and headers; get_delivery returns them).
Two more live on the always-on utility surface: agnt_webhooks_receive_recent for the newest inbound deliveries, and agnt_webhooks_inbound_url, which returns the agent’s own ingest URL plus the session URL template it should hand out as a callback.
Slug style, unique per workspace
An endpoint name is 3 to 50 characters of lowercase letters, digits and hyphens, and must start and end with a letter or digit. Anything else is a 400. Names are unique within a workspace, so a duplicate is a 409 rather than a silent second endpoint. The same rule applies to outbound endpoints, where the name is also what an agent passes when it sends.
Sending webhooks
The other direction. Same signature scheme, so the verifier above covers both.
Data pipelines
Bind an inbound endpoint to a pipeline and every delivery becomes one queued item.
API reference
Every data source and endpoint, with parameters, schemas and per-call pricing.