Webhooks bundle
Inherits hard rules from SKILL.md §Hard rules.
Use this bundle when your API publishes events to subscriber-provided URLs (webhooks) or fetches resources from client-supplied URLs (callbacks, image-import, link-preview). Both patterns share the same threat surface: your service makes outbound HTTP requests to a URL the caller chose, which is the classic SSRF setup.
This bundle covers:
- Subscription resource shape.
- Outbound delivery contract (what your service POSTs to the subscriber).
- Signature, timestamp, and replay protection.
- SSRF defense (mandatory).
- Retry and back-off semantics.
- Subscriber verification handshake.
Subscription resource
A subscription is a first-class resource. Treat it like any other API entity: bounded fields, mass-assignment defense, conditional updates.
/** Event types the API can publish. Add to the union; never remove without a deprecation cycle. */
union EventType {
WidgetCreated: "widget.created",
WidgetUpdated: "widget.updated",
WidgetDeleted: "widget.deleted",
}
/** A webhook subscription. */
model WebhookSubscription {
@visibility(Lifecycle.Read)
@maxLength(64)
@pattern("^[a-zA-Z0-9_-]+$")
id: string;
/** Subscriber-provided HTTPS callback URL. Server enforces host allow-list and refuses private IPs. */
callbackUrl: url;
/** Events the subscriber wants to receive. */
@minItems(1)
@maxItems(32)
events: EventType[];
/** Active or paused. Paused subscriptions accumulate no retries. */
status: "Active" | "Paused" | "Disabled";
/** Subscriber-supplied label for their own bookkeeping. */
@maxLength(120)
label?: string;
@visibility(Lifecycle.Read)
createdAt: utcDateTime;
@visibility(Lifecycle.Read)
updatedAt: utcDateTime;
/** Reason status moved to Disabled. Read-only diagnostic. */
@visibility(Lifecycle.Read)
disabledReason?: "SignatureRejected" | "RepeatedDeliveryFailure" | "AdminAction";
}
/** Subscription created response. Includes the shared secret EXACTLY ONCE. */
model WebhookSubscriptionCreateResponse {
subscription: WebhookSubscription;
/**
* HMAC signing secret. Returned ONLY on creation; never on read. If lost,
* the subscriber must rotate via the rotate-secret operation.
*/
signingSecret: string;
}signingSecret deliberately appears only on the create response, not on WebhookSubscription. This pattern prevents accidental leakage through list/read responses, log lines, or cached payloads. Document the one-shot disclosure in @doc so subscribers know to capture it on creation.
Outbound delivery contract
When your service publishes an event, it POSTs a fixed-shape envelope to the subscriber. The shape is part of your API contract - publish it as a TypeSpec model even though your service is the one calling, not receiving.
/** Envelope your service POSTs to subscriber callback URLs. */
model WebhookDelivery {
/** Event ID. Unique per delivery; same value across retries of the same event. */
id: string;
/** Subscription that triggered the delivery. */
subscriptionId: string;
/** Event type matching one of the subscription's subscribed events. */
type: EventType;
/** UTC timestamp when the event was generated (not when delivered). */
occurredAt: utcDateTime;
/** Monotonically increasing delivery attempt counter, starting at 1. */
attempt: int32;
/** Event payload. Shape depends on the event type. */
data: Record<unknown>;
}data: Record<unknown> is shown here for brevity, but it conflicts with the agent-readiness baseline (SKILL.md) and review-checklist item on typed webhook envelopes: agents and SDK generators cannot reason about an untyped map. For any API that may be consumed by Copilot/MCP/an SDK, replace Record<unknown> with a discriminated union keyed on type - one payload model per event type - so the delivery shape is fully typed:
@discriminated(#{ envelope: "none", discriminatorPropertyName: "type" })
union WebhookEventData {
"widget.created": WidgetCreatedData,
"widget.updated": WidgetUpdatedData,
"widget.deleted": WidgetDeletedData,
}Reserve Record<unknown> for internal-only webhooks that no agent or generated SDK will consume.
Outbound request headers:
| Header | Value | Notes |
|---|---|---|
Content-Type |
application/json; charset=utf-8 |
Always JSON. |
User-Agent |
Contoso-Webhooks/1.0 (+https://docs.example.com/webhooks) |
Stable identifier for subscriber allow-listing. |
X-Webhook-Id |
Delivery ID | Same as body id; lets subscribers deduplicate without parsing. |
X-Webhook-Timestamp |
Unix epoch seconds at signing time | Subscriber rejects deliveries > 5 minutes skewed. |
X-Webhook-Signature |
v1=<hex-hmac-sha256> |
See "Signing" below. |
X-Webhook-Attempt |
Attempt number | Same as body attempt. |
Signing
Sign every delivery with HMAC-SHA256 keyed on the subscription's signingSecret. The signed payload is the concatenation of timestamp and body - never just the body, because signing only the body lets an attacker replay an old delivery.
signing_input = X-Webhook-Timestamp + "." + raw_request_body
signature = hex( HMAC-SHA256(signing_secret, signing_input) )
X-Webhook-Signature = "v1=" + signatureThe v1= prefix is a version tag. When you rotate the signing algorithm (e.g., move to HMAC-SHA512 or Ed25519), add v2= and send both during a transition window so subscribers can migrate without downtime.
Secret strength. Generate signingSecret from a cryptographically secure RNG with at least 256 bits (32 bytes) of entropy; encode as hex or base64url. Do not derive it from subscriber-supplied input or a low-entropy counter - a guessable secret defeats the signature.
Replay window. Subscribers MUST reject deliveries where X-Webhook-Timestamp is more than 5 minutes from the subscriber's clock (in either direction). Document this requirement; subscribers who skip it expose themselves to indefinite replay.
Constant-time comparison. Document that subscribers MUST use a constant-time comparison (e.g., hmac.compare_digest, crypto.timingSafeEqual) when verifying the signature. Naive == comparison leaks signature bytes via timing.
SSRF defense (mandatory)
Every callback URL is attacker-controlled. Without enforcement, your service is a proxy: the attacker registers a callback like http://169.254.169.254/latest/meta-data/iam/security-credentials/ and your service helpfully fetches cloud-metadata credentials. The TypeSpec contract documents the policy; the runtime must enforce it.
Mandatory checks (enforce all of them, server-side, on every outbound delivery):
- HTTPS-only. Reject
http://callbacks. Theurlscalar does not enforce scheme; do it at registration time. - Host allow-list or block-list. Block-list at minimum: localhost / 127.0.0.0/8, RFC 1918 (
10/8,172.16/12,192.168/16), link-local (169.254/16,fe80::/10), unique-local (fc00::/7), loopback (::1), broadcast / multicast, and all known cloud-metadata IPs (169.254.169.254AWS/Azure/GCP IMDS,metadata.google.internal, etc.). Resolve the hostname to a concrete IP and check the IP - not the hostname string. - DNS rebinding defense. Resolve once, connect to the resolved IP, do NOT follow a second DNS lookup mid-request. Naive
requests.get(callbackUrl)is vulnerable. - Port allow-list. Allow 443 only (and 80 only if explicitly required for an internal use case). Reject 22, 25, 6379, 3306, 5432, etc.
- Redirect policy. Either disable redirects entirely, or follow at most 1 redirect and re-run the host/IP allow-list against the redirect target.
- Response size cap. Stop reading after 64 KB. Webhooks should return small acknowledgments; large bodies signal misuse.
- Connection and total timeouts. Hard limits, e.g., 5-second connect, 10-second total. Long-lived connections are not the subscriber's privilege.
- Egress through a dedicated, low-privilege proxy or VPC. The delivery worker should not have ambient access to internal services or cloud-credential endpoints.
Document the policy in the API docs and in the callbackUrl @doc:
model WebhookSubscription {
/**
* Subscriber-provided HTTPS callback URL. Enforced at registration and at
* each delivery: HTTPS only, public host (no RFC 1918, link-local, loopback,
* or cloud-metadata endpoints), port 443, max 1 redirect, 10s total timeout,
* 64 KB response cap.
*/
callbackUrl: url;
// ...
}Retry and back-off
Make retry semantics explicit so subscribers can plan idempotent handlers.
| Concern | Recommended default |
|---|---|
| Success codes | 2xx (any) treated as delivered. |
| Retry triggers | Any non-2xx, connection errors, timeouts. |
| Schedule | Exponential back-off with jitter: 1m, 5m, 30m, 2h, 6h, 24h. |
| Max attempts | 6 attempts over ~24 hours. |
| After max attempts | Subscription moves to status: "Disabled" with disabledReason: "RepeatedDeliveryFailure". Operator action or rotate-secret + reactivate to resume. |
| Idempotency | Same id across all retries of the same event. Subscribers MUST deduplicate by id. |
Document this schedule in the API docs and in the subscription create response so subscribers know exactly when to expect retries.
Verification handshake
When a subscriber registers a new callback URL, your service should verify they actually own that URL before sending real events. Two viable patterns:
Pattern A - challenge/response on registration. On POST /webhook-subscriptions, your service sends a one-time GET (or POST) to the callback URL with a random challenge token. The URL must return the token verbatim within 10 seconds.
Pattern B - first-delivery confirmation. Send a synthetic webhook.verification event as the first delivery; require a 2xx response before activating the subscription. Simpler, but consumes one event slot.
Pick one and document it. Without verification, anyone can register https://example.com/admin/... as a callback and force your service to send signed POSTs to URLs they don't control - a useful primitive for an attacker.
Operations interface
@route("/webhook-subscriptions")
interface WebhookSubscriptions {
/** List subscriptions for the calling principal. */
@get list(@query @maximum(100) top?: int32): Page<WebhookSubscription> | ErrorResponse;
/** Get a subscription. Does not return the signing secret. */
@get read(@path @maxLength(64) @pattern("^[a-zA-Z0-9_-]+$") id: string): WebhookSubscription | ErrorResponse;
/** Create a subscription. Returns the signing secret EXACTLY ONCE. */
@post create(
@header("Idempotency-Key") @maxLength(64) idempotencyKey?: string,
@body subscription: WebhookSubscription,
): WebhookSubscriptionCreateResponse | ErrorResponse;
/** Replace a subscription. ETag required. */
@put update(
@path @maxLength(64) @pattern("^[a-zA-Z0-9_-]+$") id: string,
@header("If-Match") ifMatch: string,
@body subscription: WebhookSubscription,
): WebhookSubscription | ErrorResponse;
/** Delete a subscription. */
@delete delete(
@path @maxLength(64) @pattern("^[a-zA-Z0-9_-]+$") id: string,
@header("If-Match") ifMatch: string,
): void | ErrorResponse;
/** Rotate the signing secret. Returns the new secret EXACTLY ONCE. */
@route("/{id}:rotateSecret")
@post rotateSecret(
@path @maxLength(64) @pattern("^[a-zA-Z0-9_-]+$") id: string,
): WebhookSubscriptionCreateResponse | ErrorResponse;
}Common rejections at review
- Plain-body signing (no timestamp in the signing input) - replay attack.
- No replay window enforced at the subscriber - replay attack.
- Hostname allow-listed without IP resolution - DNS rebinding.
- Following redirects without re-running the egress policy on the redirect target.
- Signing secret returned on read/list responses - leakage surface multiplies.
- No subscription verification handshake - attacker registers victim's URL.
- Retries without idempotency ID - duplicate side effects on the subscriber side.
User-Agentnot stable or not documented - subscribers can't allow-list your egress safely.
See also
bundles/generic-rest/guide.md"URL inputs and SSRF" - covers the same SSRF surface for inbound URL parameters (e.g., image-fetch APIs).bundles/lro-pagination-versioning/guide.md- for evolving theEventTypeunion with@added/@removedinstead of breaking subscribers.