XY Logo
Developer hub

Webhooks

Get told when work finishes, without polling.

Long-running XY operations finish minutes or hours after you start them. A webhook subscription tells your system the moment a build, execution, or custom request changes state, signed so you can trust it, retried on failure, and inspectable when a delivery does not get through.

Before you start

One scoped key and one public HTTPS receiver.

You need two things: an API key that is allowed to manage webhooks, and a public HTTPS endpoint that can receive them.

The key

Grant the webhook scopes on purpose

  • An organization admin creates or edits a key in XY under Organization settings → API Keys.
  • Tick webhooks:write to create, test, change, rotate, redeliver, or delete subscriptions.
  • Tick webhooks:read to list subscriptions and delivery history.
  • Neither scope is on by default, and neither is included in a key's default scope set.
  • Personal keys and service keys both work, within that identity's role ceiling.

The receiver

Public HTTPS, port 443, fast 2xx

  • The URL must be https:// on port 443 and resolve to a public IP address.
  • Private, loopback, link-local, and cloud-metadata addresses are rejected, and DNS is re-checked at delivery time.
  • XY does not follow redirects. Point the subscription at the final URL.
  • Answer 2xx as soon as you have verified and durably accepted the event. Do the real work afterwards.

Subscribe

Two calls and you are receiving events.

Create the subscription, store the secret, then prove your receiver works with a test event before any real transition happens.
  1. Create the subscription and save the signing secret
    # Requires webhooks:write. The signing secret is returned exactly once.
    : "${XY_WEBHOOK_KEY:?Use a key that was granted webhooks:write}"
    curl --fail-with-body --request POST \
      --header "Authorization: Bearer ${XY_WEBHOOK_KEY}" \
      --header "Idempotency-Key: planner-notifications-v1" \
      --header "Content-Type: application/json" \
      --dump-header subscription-headers.txt \
      "${XY_API_BASE}/webhooks" \
      --data '{
        "url": "https://hooks.example.com/xy",
        "event_types": [
          "custom_request.approved",
          "planner.build.succeeded",
          "planner.build.failed"
        ],
        "description": "Notify the intake system when XY finishes a build"
      }'
    
    
    # 201 Created. Store "signing_secret" from this response in your secret manager now.
    # Later reads return only "secret_hint". Keep the quoted ETag for updates.
  2. Send a signed test event to your receiver
    # Requires webhooks:write. Proves your receiver validates a signed request.
    curl --fail-with-body --request POST \
      --header "Authorization: Bearer ${XY_WEBHOOK_KEY}" \
      --header "Idempotency-Key: planner-notifications-test-1" \
      "${XY_API_BASE}/webhooks/whsub_REPLACE/test"
    
    
    # 202 Accepted. Follow the Location header to the delivery record and
    # expect one signed webhook.test request at your endpoint.

Verify

Check the signature, then deduplicate.

Every delivery is an HTTPS POST with an exact JSON body and three XY headers. Treat a request as genuine only after the signature check passes.
What arrives at your endpoint
POST /xy HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
X-XY-Webhook-Id: whevt_3f9c…
X-XY-Webhook-Timestamp: 1789516800
X-XY-Webhook-Signature: v1=8c1a…
Example event body
{
  "id": "whevt_example",
  "type": "planner.build.succeeded",
  "schema_version": 1,
  "resource_version": 7,
  "created_at": "2026-09-15T18:00:00+00:00",
  "organization_id": "org_example",
  "data": {
    "resource_type": "planner_build",
    "resource_id": "pbld_example",
    "attributes": { "status": "completed" }
  }
}
Verify the signature over the exact bytes you received
import hashlib
import hmac
import time


TOLERANCE_SECONDS = 300




def verify(raw_body: bytes, headers: dict[str, str], signing_secret: str) -> bool:
    # A forged or truncated request must be rejected, not raise. Header names are
    # case-insensitive on the wire, so normalize before reading them.
    received = {name.lower(): value for name, value in headers.items()}
    try:
        timestamp = received["x-xy-webhook-timestamp"]
        signature = received["x-xy-webhook-signature"]
        sent_at = int(timestamp)
    except (KeyError, TypeError, ValueError):
        return False
    if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(
        signing_secret.encode(),
        timestamp.encode() + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(signature, f"v1={expected}")




# After verify() passes:
# 1. Deduplicate on the x-xy-webhook-id header; retries reuse the same ID.
# 2. Keep the highest resource_version per (data.resource_type, data.resource_id)
#    and ignore anything lower, even if its ID is new.
# 3. Return 2xx immediately, then do the real work asynchronously.

Sign over raw bytes

The HMAC covers the timestamp, a dot, and the body exactly as sent. Do not parse and re-serialize the JSON before verifying; frameworks that pretty-print will break the check.

Same ID on every retry

X-XY-Webhook-Id and the body never change between attempts; only the timestamp and signature do. Store the ID and drop repeats.

Order is not guaranteed

Delivery is at least once and may arrive out of order. Keep the highest resource_version per resource and ignore anything lower.

Events

What you can subscribe to.

Event names are dotted and stable. The type tells you what happened; data.resource_type and data.resource_id tell you what to read next.
Webhook event families
FamilyEventsFires when
custom_request.*created, submitted, needs_information, partner_replied, approved, planner_queued, planner_started, ready_for_review, available, rejected, failedA custom-agent request moves through intake, clarification, approval, Planner fulfillment, and delivery. REST, MCP, and the hosted form all produce the same events.
planner.build.*started, awaiting_input, succeeded, failed, cancelledA Planner agent build changes state. awaiting_input means a plan or step decision is waiting for a person.
agent_execution.*completed, failedA catalog agent execution reaches a terminal state.
integration_factory.*completed, failedAn Integration Factory run finishes generating or validating a capability.
queue_ui.*completed, failedA Queue UI Designer run finishes.
webhook.testtestYou called the test endpoint. Use it to prove signature verification before real traffic.

Operate

Inspect, redeliver, rotate, disable, delete.

Delivery state lives in XY and is inspectable. Retries are automatic; only exhausted or rejected deliveries need a human.
Inspect delivery history
# Requires webhooks:read.
curl --fail-with-body \
  --header "Authorization: Bearer ${XY_API_KEY}" \
  "${XY_API_BASE}/webhook-deliveries?subscription_id=whsub_REPLACE&limit=50"


curl --fail-with-body \
  --header "Authorization: Bearer ${XY_API_KEY}" \
  "${XY_API_BASE}/webhook-deliveries/whdel_REPLACE"


# status is pending, delivering, succeeded, or dead_lettered.
# attempt_count, last_response_status, and next_attempt_at explain why.
Redeliver a dead-lettered event
# Requires webhooks:write. Only dead-lettered deliveries can be redelivered.
curl --fail-with-body --request POST \
  --header "Authorization: Bearer ${XY_WEBHOOK_KEY}" \
  --header "Idempotency-Key: redeliver-whdel_REPLACE-1" \
  "${XY_API_BASE}/webhook-deliveries/whdel_REPLACE/redeliver"


# 202 Accepted. The same event ID and body are sent again with a fresh signature.
Disable, rotate the secret, or delete
# Read the subscription first and reuse its quoted ETag in If-Match.
curl --fail-with-body --request PATCH \
  --header "Authorization: Bearer ${XY_WEBHOOK_KEY}" \
  --header "Idempotency-Key: pause-planner-notifications-1" \
  --header 'If-Match: "3"' \
  --header "Content-Type: application/json" \
  "${XY_API_BASE}/webhooks/whsub_REPLACE" \
  --data '{ "status": "disabled" }'


# Rotate the secret. The new value is returned exactly once.
curl --fail-with-body --request POST \
  --header "Authorization: Bearer ${XY_WEBHOOK_KEY}" \
  --header "Idempotency-Key: rotate-planner-notifications-1" \
  --header 'If-Match: "4"' \
  "${XY_API_BASE}/webhooks/whsub_REPLACE/rotate-secret"


# Delete. Irreversible through the API; delivery history is retained.
curl --fail-with-body --request DELETE \
  --header "Authorization: Bearer ${XY_WEBHOOK_KEY}" \
  --header 'If-Match: "5"' \
  "${XY_API_BASE}/webhooks/whsub_REPLACE"