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
POSTrequests; 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 10
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
| Header | Meaning |
|---|---|
Content-Type | application/cloudevents+json |
User-Agent | Y2-Webhook-Delivery/3.0 |
X-Y2-Timestamp | Attempt time as Unix seconds |
X-Y2-Event-Id | Stable logical event ID; matches the body id |
X-Y2-Attempt-Id | Unique transport-attempt ID |
X-Y2-Attempt | One-based attempt number |
Idempotency-Key | Stable logical event ID |
X-Y2-Signature | sha256=<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.
Each event has at most five automatic attempts, with a 10-second timeout and delays of 5 seconds, 30 seconds, 5 minutes, and 30 minutes. Network errors, timeouts, HTTP 408/429, and 5xx responses are retried. Other HTTP rejections pause the destination immediately. Five consecutive failures across a destination also pause it; successful delivery resets that counter. Retries reuse the original payload and event ID and do not restart report generation.
The configuration owner receives one warning email on the first temporary failure and one email when delivery is paused. Paused destinations receive no further automatic attempts. Reports, configuration, and delivery history remain available; other destinations continue independently.
GET /webhooks exposes status and delivery health, including the failure reason/status, last
success, suspension time, and last successful test. To recover:
- Save repaired configuration with
PUT, keepingisActive: false. - Call
POST /webhooks/{webhookId}/test; disabled destinations can be tested. A successful response includes the configuration's newETag, because testing updates its delivery health. - Within 15 minutes,
PUTthe same configuration withisActive: true, using the test response'sETaginIf-Match. Include the same secret and headers: changing the endpoint, signing secret, or headers invalidates the test. - In Delivery history and replay in webhook settings, explicitly replay retained failed events. Enabling delivery does not automatically replay the backlog.
Enabling without a current successful test returns 409 WEBHOOK_TEST_REQUIRED. A test failure
does not consume the production retry budget.
Replace or delete a configuration
PUT /webhooks/{webhookId} replaces the complete mutable configuration. name and url are
required. Omitted secret and headers are cleared; omitted isActive preserves the current
status. 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.