All skills
lukemurraynz avatar

/typespec-api-design

@2cc2455

Design new API-first, contract-first contracts with TypeSpec, OpenAPI/swagger output, Azure data-plane and ARM patterns, versioning, pagination, LROs, CI validation, and agent-ready API boundaries. WHEN: create API spec, API-first design, contract-first, scaffold TypeSpec project, generate OpenAPI or swagger, define REST contract, add API versioning, define LRO, migrate OpenAPI to TypeSpec, Azure API review, SpecKit API.

Use this Skill: https://skilld.dev/gh/lukemurraynz/hve-agent-skills/typespec-api-design

This session only. Nothing lands on disk.

bundleswebhooksguide.md

≈3.1k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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=" + signature

The 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):

  1. HTTPS-only. Reject http:// callbacks. The url scalar does not enforce scheme; do it at registration time.
  2. 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.254 AWS/Azure/GCP IMDS, metadata.google.internal, etc.). Resolve the hostname to a concrete IP and check the IP - not the hostname string.
  3. 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.
  4. Port allow-list. Allow 443 only (and 80 only if explicitly required for an internal use case). Reject 22, 25, 6379, 3306, 5432, etc.
  5. Redirect policy. Either disable redirects entirely, or follow at most 1 redirect and re-run the host/IP allow-list against the redirect target.
  6. Response size cap. Stop reading after 64 KB. Webhooks should return small acknowledgments; large bodies signal misuse.
  7. Connection and total timeouts. Hard limits, e.g., 5-second connect, 10-second total. Long-lived connections are not the subscriber's privilege.
  8. 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-Agent not 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 the EventType union with @added/@removed instead of breaking subscribers.

Source: SKILL.md on GitHub

No alerts8d3 checks · Risk SAFE
  • Gen Agent Trust Hub8d

    This skill is a comprehensive and legitimate tool for designing API-first contracts using TypeSpec and Azure patterns. It includes extensive security best practices, particularly regarding SSRF defense, PII handling, and idempotency. The only detected concern is a standard vulnerability surface for indirect prompt injection when processing external OpenAPI specifications for migration.

  • Socket8d

    No alerts

  • Snyk8d

    Risk: LOW · No issues

Signed by skilld at 2cc2455. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last month.

Steadyupdated last month
metadata
{
  "last_verified": "2026-08-26",
  "version": "1.3.1"
}

README badge

README badge for lukemurraynz/hve-agent-skills/typespec-api-design