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 receiver, shared across the platform, REST, and MCP.

Configure a receiver in XY Settings, or manage it through REST/MCP with scoped API-key or OAuth access. All three use the same organization-owned subscriptions.

Access

Settings access or explicit API scopes

  • Open Settings → Webhooks. STAFF and PARTNER inherit access; other roles need an individual Webhooks permission. No API key is needed in this page.
  • 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, service keys, and MCP OAuth work within their explicit scopes and live access.
  • The receiver signing secret verifies deliveries. It has no scopes and cannot call XY APIs.

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. For request/build lifecycle updates, ignore lower resource_version values.
#    For usage, upsert by request and period, keeping the latest as_of.
#    For workflow reports/alerts, use the event ID and the period/transition time.
# 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. For request/build lifecycle updates, ignore lower resource_version values. Usage snapshots use their period and as_of; workflow reports and review alerts use their event ID and period or transition time.

Workflow notifications

Daily results, run outcomes, and review alerts.

Choose specific workflows in your organization. Notifications are signed and retried through the same receiver; they do not run or change those workflows.

Read GET /webhooks/configuration or MCP webhook_configurationfor supported event names and workflow/queue-state choices. Pass those workflow IDs inworkflow_notifications.workflows when creating or updating a receiver. Select up to 25 workflows. For review alerts, set each workflow'sreview_statuses to the actual queue states requiring a person's attention. An error is not automatically a review requirement.

Daily reports use timezone and daily_send_time(defaults: UTC and 09:00) and cover the previous local calendar day. Theirdata.attributes.details contains run outcomes, items created, distinct items entering each declared status, and current queue counts at as_of. New items are not necessarily completed work. An item can enter several statuses, so do not sum those counts. Queue counts show the current backlog, not a historical snapshot.

These are recorded lifecycle summaries, not raw business records or a universal processed-item KPI. Test runs/items are excluded. A workflow without queue support still reports run totals; unavailable item counts are null. Run and review alerts start from subscription activation, not earlier history. Daily reporting catches up the latest due day after downtime; it does not automatically resend every missed day or revise an already-sent daily report.

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, failed, deployed, usage_updatedA custom-agent request moves through intake, clarification, approval, Planner fulfillment, and delivery. REST, MCP, and the hosted form all produce the same events.
workflow.run.*completed, failedA selected workflow run finishes. Partial failure, cancellation, and termination are explicit failed outcomes, not clean completions.
workflow.daily_resultsdaily_resultsA selected workflow's daily report is due: run totals, new items, recorded status changes, and current queue counts. No customer records are included.
workflow.human_review_requiredhuman_review_requiredAn item enters one of the workflow queue states explicitly selected as requiring human review.
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"