Y2 Elite workspaces are rolling out for teams
Y2Y2Docs

Configure Report Webhooks

Create a signed webhook, attach it to a subscription, and process CloudEvents safely

Use a webhook when your application should react as soon as Y2 publishes a report. This guide covers configuration and consumption through the public API. For the delivery product overview, see Webhook delivery.

Before you begin

You need:

  • a paid workspace plan with webhook delivery;
  • an API key with webhooks:manage;
  • a public HTTPS endpoint that accepts POST requests; and
  • a secret generated and stored by your application if you want signed deliveries.

Y2 rejects loopback, private, link-local, cloud metadata, multicast, and other reserved network destinations. In production, webhook URLs must use HTTPS. Custom headers must have string values; Host, Content-Length, and Content-Type cannot be configured.

1. Create a webhook configuration

Send a stable Idempotency-Key when a client may retry the request:

curl --request POST "https://api.y2.dev/api/v1/webhooks" \
  --header "Authorization: Bearer $Y2_API_KEY" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: create-primary-webhook-01" \
  --data '{
    "name": "Primary report receiver",
    "url": "https://example.com/webhooks/y2",
    "secret": "replace-with-a-random-secret",
    "headers": {
      "X-Integration": "intelligence-pipeline"
    }
  }'

A successful request returns 201, a canonical whk_... ID, Location, and ETag. Store the ID; you need it to test the endpoint and attach it to a subscription.

Secrets and headers are write-only

List and mutation responses reveal whether signing is configured and return custom header names, but never return the secret or custom header values.

2. Test the receiver

curl --request POST "https://api.y2.dev/api/v1/webhooks/$WEBHOOK_ID/test" \
  --header "Authorization: Bearer $Y2_API_KEY"

The test request is a CloudEvents 1.0 event with type dev.y2.webhook.test.v1. Y2 waits up to five seconds. A 2xx response from your receiver produces 200; a timeout, network error, or non-2xx response produces 422.

The test verifies connectivity and signing, but it does not attach the configuration to a report subscription.

3. Attach the webhook to a subscription

Use the subscription's canonical sub_... ID, not its profile ID:

curl --request PATCH \
  "https://api.y2.dev/api/v1/subscriptions/$SUBSCRIPTION_ID/delivery" \
  --header "Authorization: Bearer $Y2_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{\"deliveryMethod\":\"webhook\",\"webhookConfigId\":\"$WEBHOOK_ID\"}"

The webhook must be active and belong to the same user or workspace scope as the subscription. Changing a profile does not change its subscription delivery settings.

4. Process the report event

Production deliveries use structured CloudEvents with media type application/cloudevents+json and event type dev.y2.report.generated.v1:

{
  "specversion": "1.0",
  "id": "y2mexamplelogicalevent01",
  "source": "https://api.y2.dev/api/v1/profiles/prf_0123456789abcdef01234567",
  "type": "dev.y2.report.generated.v1",
  "subject": "reports/rpt_0123456789abcdef01234567",
  "time": "2026-07-21T18:00:00.000Z",
  "datacontenttype": "application/json",
  "dataschema": "https://api.y2.dev/schemas/events/report-generated-v1.json",
  "data": {
    "report": {
      "id": "rpt_0123456789abcdef01234567",
      "profileId": "prf_0123456789abcdef01234567",
      "summary": "A concise report summary.",
      "status": "published",
      "generatedAt": "2026-07-21T17:59:42.000Z",
      "language": "en",
      "intelligence": { "signalCount": 3, "graphNodeCount": 12 },
      "audio": { "status": "available", "durationSeconds": 284 }
    },
    "subscription": { "id": "sub_0123456789abcdef01234567" },
    "links": {
      "report": "https://api.y2.dev/api/v1/reports/rpt_0123456789abcdef01234567",
      "markdown": "https://api.y2.dev/api/v1/reports/rpt_0123456789abcdef01234567?format=markdown",
      "sources": "https://api.y2.dev/api/v1/reports/rpt_0123456789abcdef01234567?include=sources",
      "signals": "https://api.y2.dev/api/v1/reports/rpt_0123456789abcdef01234567/signals",
      "graph": "https://api.y2.dev/api/v1/reports/rpt_0123456789abcdef01234567/graph",
      "audio": "https://api.y2.dev/api/v1/reports/rpt_0123456789abcdef01234567/audio"
    }
  }
}

The event is intentionally compact. Use the supplied links and a key with the relevant Reports scope to retrieve full Markdown, sources, signals, graph, or audio.

Delivery headers

HeaderMeaning
Content-Typeapplication/cloudevents+json
User-AgentY2-Webhook-Delivery/3.0
X-Y2-TimestampAttempt time as Unix seconds
X-Y2-Event-IdStable logical event ID; matches the body id
X-Y2-Attempt-IdUnique transport-attempt ID
X-Y2-AttemptOne-based attempt number
Idempotency-KeyStable logical event ID
X-Y2-Signaturesha256=<hex> when a secret is configured

Configured custom headers are sent first. Y2's delivery headers take precedence if names collide.

Verify the signature

Compute HMAC-SHA256 over the exact raw request bytes. Do not parse and reserialize the JSON before verification.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyY2Webhook(
  rawBody: Buffer,
  signatureHeader: string | null,
  secret: string,
): boolean {
  if (!signatureHeader) return false;

  const expected = Buffer.from(
    `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`,
  );
  const actual = Buffer.from(signatureHeader);

  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

Also compare X-Y2-Timestamp with your current time and reject values outside your replay window; five minutes is a practical default. Only return 2xx after the event has been durably accepted.

Handle attempts and failures

The event body and X-Y2-Event-Id remain stable if the same logical delivery is attempted again. X-Y2-Attempt-Id and X-Y2-Attempt identify the transport attempt. Deduplicate on the body id, X-Y2-Event-Id, or Idempotency-Key before starting downstream work.

Production delivery times out after 10 seconds. Y2 does not run an independent HTTP retry loop for a failed attempt, although a replayed delivery workflow can attempt the same persisted logical event again. Five consecutive failed attempts automatically disable the webhook; a successful attempt resets the failure counter.

GET /webhooks exposes status, deliveryHealth.consecutiveFailures, and deliveryHealth.lastUsedAt. To re-enable a destination, replace its complete configuration with PUT and set isActive to true, then test it again.

Replace or delete a configuration

PUT /webhooks/{webhookId} replaces the complete mutable configuration. name and url are required, and omitted optional fields reset to defaults. Send the latest returned ETag in If-Match when you have one to prevent overwriting a concurrent change.

Before deleting a webhook, move every attached subscription to another delivery method or webhook. Deletion returns 409 WEBHOOK_IN_USE while any subscription still references the configuration; a successful deletion returns 204 with no body.