// automation

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.

hmac-sha256300s replay windowfire and forget dispatch
01// the ingest url

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.

02// what happens on delivery

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

BindingWhat a delivery does
session-targeted URLHighest priority. Resumes that session with the payload as a new turn.
data pipelineEnqueues one pipeline item with the body as its payload. The right binding for high-volume, non-conversational ingestion.
deployed agentStarts a fresh agent session and auto-acknowledges the delivery on success.
unboundStored 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.

03// verifying signatures

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.

a signed requesthttp
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

Sign and verify the body exactly as it travelled. Parsing the JSON and re-serializing it changes key order and whitespace, and the signature stops matching for reasons that look like a bug in the scheme. Read the raw body first, verify, then parse.
verify-agnt-webhook.jsjavascript
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.

04// refusals

When the receiver says no

StatusCauseWhat comes back
413Payload over 1 MiB.Nothing stored. Send a reference instead of the blob.
404Unknown hookId, or an endpoint that no longer ingests.Nothing stored.
402The 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.
401Signature verification failed on a signed endpoint.A reason string. Nothing stored.

a 402 drops the payload

The event limit is enforced before the body is read, so an over-allowance delivery is lost rather than queued. If your sender does not retry, that data is gone. Watch the allowance the same way you watch a credit balance.
05// what is stored

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.

06// managing endpoints and deliveries

Over REST

Endpoints are create, list, read and delete under /v1/webhook-endpoints, authenticated with Authorization: Bearer <key>.

create an endpointbash
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 } }
RouteDoes
POST /v1/webhook-endpointsCreates one. Body is a name and an optional description. 409 when the name is taken in this workspace.
GET /v1/webhook-endpointsEvery endpoint in the workspace.
GET /v1/webhook-endpoints/:idOne endpoint. 404 when it is not yours.
DELETE /v1/webhook-endpoints/:idDeactivates 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/deliveriesThe delivery log. Filter with endpointId and unacknowledged; page with limit and cursor.
POST /v1/webhook-endpoints/deliveries/:id/ackAcknowledges one delivery.
POST /v1/webhook-endpoints/deliveries/ackAcknowledges a non-empty array of ids in one call.
read the delivery logbash
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

Webhook deliveries are the exception on the public API: the inbound and outbound delivery lists are the only two surfaces that page with a cursor. Everything else pages with limit and offset, and the curated data sources use each upstream’s own continuation operation.

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.

07// endpoint naming rules

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.

08// related