diff --git a/docs/apis/openapi.yaml b/docs/apis/openapi.yaml
index f8da40f68..6f414f8e1 100644
--- a/docs/apis/openapi.yaml
+++ b/docs/apis/openapi.yaml
@@ -11,6 +11,8 @@ security:
- AdminApiKey: []
- TenantJwt: []
servers:
+ - url: https://api.outpost.hookdeck.com/2025-07-01
+ description: Outpost API (production)
- url: http://localhost:3333/api/v1
description: Local development server base path
components:
@@ -1763,10 +1765,6 @@ components:
additionalProperties:
type: string
example: { "source": "crm" }
- status:
- type: string
- enum: [success, failed]
- example: "success"
data:
type: object
description: Freeform JSON data of the event.
@@ -2375,12 +2373,6 @@ paths:
schema:
type: string
description: Filter events by tenant ID. If not provided, returns events from all tenants.
- - name: destination_id
- in: query
- required: false
- schema:
- type: string
- description: Filter events by destination ID.
- name: topic
in: query
required: false
diff --git a/docs/pages/guides/upgrade-v0.13.mdx b/docs/pages/guides/upgrade-v0.13.mdx
index b7615adde..4b9f84dfb 100644
--- a/docs/pages/guides/upgrade-v0.13.mdx
+++ b/docs/pages/guides/upgrade-v0.13.mdx
@@ -245,6 +245,67 @@ Key differences:
- New `status` field mirrors the HTTP status code
- Validation errors in `data` changed from a `{ field: tag }` object to an array of human-readable messages
+## SDK Migration
+
+The official SDKs (TypeScript, Go, Python) are generated from the OpenAPI spec. When you upgrade to SDK versions that target v0.13, the following breaking changes apply. This section shows the migration from the pre–v0.13 SDK API to the v0.13 API.
+
+### List response shape
+
+All list endpoints return `{ models, pagination }` instead of `{ data, next, prev }`.
+
+**Before (v0.12-style SDK):**
+
+```ts
+const response = await outpost.events.listByDestination({ tenantId, destinationId });
+const events = response.data ?? [];
+const nextCursor = response.next;
+```
+
+**After (v0.13 SDK):**
+
+```ts
+const response = await outpost.events.list({ tenantId });
+const events = response.models ?? [];
+const nextCursor = response.pagination?.next;
+```
+
+Use `response.models` for the array of items and `response.pagination` for `next`, `prev`, `order_by`, `dir`, and `limit`.
+
+### Events API
+
+| Before (v0.12-style) | After (v0.13) |
+| --- | --- |
+| `sdk.events.listByDestination({ tenantId, destinationId })` | `sdk.events.list({ tenantId, destinationId })` — or list without `destinationId` for all tenant events |
+| `sdk.events.getByDestination({ tenantId, destinationId, eventId })` | `sdk.events.get({ eventId })` |
+| Tenant-scoped list/get by destination | Use `sdk.events.list({ tenantId })` and filter client-side, or use attempts API for destination-scoped data |
+
+**Note:** `GET /events?destination_id=...` is documented but currently returns 500 in some environments; prefer listing without `destination_id` until that is fixed. See [GitHub issue #688](https://github.com/hookdeck/outpost/issues/688) for details.
+
+### Attempts (formerly deliveries)
+
+| Before (v0.12-style) | After (v0.13) |
+| --- | --- |
+| Delivery-focused endpoints / types | `sdk.attempts.list()`, `sdk.attempts.get()`, `sdk.attempts.retry()` |
+| Destination-scoped deliveries | `sdk.destinations.listAttempts()`, `sdk.destinations.getAttempt()` |
+| Retry via event/destination path | `sdk.attempts.retry({ eventId, destinationId })` |
+
+### Schemas and topics
+
+| Before (v0.12-style) | After (v0.13) |
+| --- | --- |
+| Tenant-scoped destination types / schemas | `sdk.schemas.listDestinationTypes()`, `sdk.schemas.get()` (unscoped) |
+| Tenant-scoped topics | `sdk.topics.list()` (unscoped) |
+
+### Summary checklist for SDK users
+
+1. Replace any use of `response.data` with `response.models` and `response.next`/`response.prev` with `response.pagination?.next` / `response.pagination?.prev`.
+2. Switch events access to `sdk.events.list()` and `sdk.events.get({ eventId })`; remove use of event-by-destination list/get if present.
+3. Switch delivery/retry usage to `sdk.attempts.*` and `sdk.destinations.listAttempts()` / `getAttempt()`.
+4. Use `sdk.attempts.retry({ eventId, destinationId })` for retries.
+5. Use `sdk.schemas.listDestinationTypes()` and `sdk.topics.list()` without tenant scope.
+
+TypeScript SDK v0.13.0 and later reflect these changes. Go and Python SDKs will align with the same API once regenerated from the v0.13 OpenAPI spec.
+
## Empty `custom_headers` Rejected
Webhook and standard webhook destinations no longer accept an empty `custom_headers` object. Previously, passing `"custom_headers": {}` was silently accepted. In v0.13, this returns a validation error.
diff --git a/docs/zudoku.config.ts b/docs/zudoku.config.ts
index 28de9c605..7132e9159 100644
--- a/docs/zudoku.config.ts
+++ b/docs/zudoku.config.ts
@@ -199,6 +199,16 @@ const config: ZudokuConfig = {
label: "Schema Migration",
id: "guides/migration",
},
+ {
+ type: "doc",
+ label: "Upgrade to v0.12",
+ id: "guides/upgrade-v0.12",
+ },
+ {
+ type: "doc",
+ label: "Upgrade to v0.13",
+ id: "guides/upgrade-v0.13",
+ },
],
},
{
diff --git a/examples/demos/dashboard-integration/INTEGRATION_DETAILS.md b/examples/demos/dashboard-integration/INTEGRATION_DETAILS.md
index 114de9756..d8d15ec3a 100644
--- a/examples/demos/dashboard-integration/INTEGRATION_DETAILS.md
+++ b/examples/demos/dashboard-integration/INTEGRATION_DETAILS.md
@@ -425,10 +425,9 @@ export async function getTenantOverview(
});
const recentEvents = eventsResponse.items.map(transformEvent);
- // Calculate statistics
+ // Calculate statistics (events list is seek-paginated with no total count, so we only expose destination count)
const stats = {
totalDestinations: destinations.length,
- totalEvents: eventsResponse.totalCount || 0,
};
return {
@@ -520,13 +519,12 @@ import { Badge } from "@/components/ui/Badge";
interface OverviewStatsProps {
stats: {
totalDestinations: number;
- totalEvents: number;
};
}
export default function OverviewStats({ stats }: OverviewStatsProps) {
return (
-
+
@@ -541,16 +539,6 @@ export default function OverviewStats({ stats }: OverviewStatsProps) {
-
-
- Total Events
-
-
- {stats.totalEvents}
- Events processed
-
-
-
System Status
@@ -1175,7 +1163,6 @@ export interface DashboardOverview {
recentEvents: Event[];
stats: {
totalDestinations: number;
- totalEvents: number;
};
}
diff --git a/examples/demos/dashboard-integration/package-lock.json b/examples/demos/dashboard-integration/package-lock.json
index eac4b61aa..202ecc014 100644
--- a/examples/demos/dashboard-integration/package-lock.json
+++ b/examples/demos/dashboard-integration/package-lock.json
@@ -9,7 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@auth/pg-adapter": "^1.10.0",
- "@hookdeck/outpost-sdk": "^0.3.0",
+ "@hookdeck/outpost-sdk": "file:../../../sdks/outpost-typescript",
"@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.15.5",
"bcryptjs": "^3.0.2",
@@ -36,6 +36,30 @@
"typescript": "^5"
}
},
+ "../../../sdks/outpost-typescript": {
+ "name": "@hookdeck/outpost-sdk",
+ "version": "0.7.3",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.26.0",
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "bin": {
+ "mcp": "bin/mcp-server.js"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.26.0",
+ "@stricli/core": "^1.1.1",
+ "@types/express": "^4.17.21",
+ "bun": "1.2.17",
+ "bun-types": "1.2.17",
+ "eslint": "^9.26.0",
+ "express": "^4.21.2",
+ "globals": "^15.14.0",
+ "tshy": "^2.0.0",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.26.0"
+ }
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -285,32 +309,8 @@
}
},
"node_modules/@hookdeck/outpost-sdk": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@hookdeck/outpost-sdk/-/outpost-sdk-0.3.0.tgz",
- "integrity": "sha512-+8YCNeO1tqY5FanGCNyEi0LYEO437DjODyHnYPVBI+y5m2AGkcJw3NFbeijYnRTSTluyRvPZMI+Ehs+HuqOI+g==",
- "dependencies": {
- "zod": "^3.20.0"
- },
- "bin": {
- "mcp": "bin/mcp-server.js"
- },
- "peerDependencies": {
- "@modelcontextprotocol/sdk": ">=1.5.0 <1.10.0"
- },
- "peerDependenciesMeta": {
- "@modelcontextprotocol/sdk": {
- "optional": true
- }
- }
- },
- "node_modules/@hookdeck/outpost-sdk/node_modules/zod": {
- "version": "3.25.76",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
- "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
+ "resolved": "../../../sdks/outpost-typescript",
+ "link": true
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
@@ -1436,6 +1436,7 @@
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1502,6 +1503,7 @@
"integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.41.0",
"@typescript-eslint/types": "8.41.0",
@@ -2019,6 +2021,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2957,6 +2960,7 @@
"integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3131,6 +3135,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5268,6 +5273,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@@ -5454,6 +5460,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -5526,6 +5533,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5535,6 +5543,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -6324,6 +6333,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -6482,6 +6492,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/examples/demos/dashboard-integration/package.json b/examples/demos/dashboard-integration/package.json
index 885485664..82a367015 100644
--- a/examples/demos/dashboard-integration/package.json
+++ b/examples/demos/dashboard-integration/package.json
@@ -10,7 +10,7 @@
},
"dependencies": {
"@auth/pg-adapter": "^1.10.0",
- "@hookdeck/outpost-sdk": "^0.3.0",
+ "@hookdeck/outpost-sdk": "file:../../../sdks/outpost-typescript",
"@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.15.5",
"bcryptjs": "^3.0.2",
diff --git a/examples/demos/dashboard-integration/src/app/api/topics/route.ts b/examples/demos/dashboard-integration/src/app/api/topics/route.ts
index 3f068c8fc..ca612ce25 100644
--- a/examples/demos/dashboard-integration/src/app/api/topics/route.ts
+++ b/examples/demos/dashboard-integration/src/app/api/topics/route.ts
@@ -32,8 +32,8 @@ export async function GET(request: NextRequest) {
}
try {
- // Fetch topics using the Outpost SDK
- const topics = await outpostClient.topics.list({ tenantId });
+ // Fetch topics using the Outpost SDK (list is instance-wide; no tenant filter in API)
+ const topics = await outpostClient.topics.list();
logger.info("Topics fetched successfully", {
tenantId,
diff --git a/examples/demos/dashboard-integration/src/app/auth/login/page.tsx b/examples/demos/dashboard-integration/src/app/auth/login/page.tsx
index deb56ba9b..0821ba57f 100644
--- a/examples/demos/dashboard-integration/src/app/auth/login/page.tsx
+++ b/examples/demos/dashboard-integration/src/app/auth/login/page.tsx
@@ -1,5 +1,10 @@
+import { Suspense } from "react";
import LoginForm from "@/components/auth/LoginForm";
export default function LoginPage() {
- return ;
+ return (
+ Loading... }>
+
+
+ );
}
diff --git a/examples/demos/dashboard-integration/src/components/dashboard/OverviewStats.tsx b/examples/demos/dashboard-integration/src/components/dashboard/OverviewStats.tsx
index ff703827d..c50ac3c39 100644
--- a/examples/demos/dashboard-integration/src/components/dashboard/OverviewStats.tsx
+++ b/examples/demos/dashboard-integration/src/components/dashboard/OverviewStats.tsx
@@ -5,13 +5,12 @@ import Link from "next/link";
interface OverviewStatsProps {
stats: {
totalDestinations: number;
- totalEvents: number;
};
}
export default function OverviewStats({ stats }: OverviewStatsProps) {
return (
-
+
@@ -26,16 +25,6 @@ export default function OverviewStats({ stats }: OverviewStatsProps) {
-
-
- Total Events
-
-
- {stats.totalEvents}
- Events processed
-
-
-
Status
diff --git a/examples/demos/dashboard-integration/src/lib/outpost.ts b/examples/demos/dashboard-integration/src/lib/outpost.ts
index 0cd3720b9..24846b6dd 100644
--- a/examples/demos/dashboard-integration/src/lib/outpost.ts
+++ b/examples/demos/dashboard-integration/src/lib/outpost.ts
@@ -66,63 +66,25 @@ export async function getTenantOverview(tenantId: string) {
const tenant = await outpost.tenants.get({ tenantId });
logger.debug(`Tenant found`, { tenantId, tenant });
- // Get destinations for this tenant
- const destinationsResponse = await outpost.destinations.list({ tenantId });
- logger.debug(`Destinations found`, {
- tenantId,
- count: destinationsResponse?.length,
- });
-
- // Transform destinations to match our interface
- const destinations = Array.isArray(destinationsResponse)
- ? destinationsResponse.map((dest: any) => ({
+ // Get destinations for this tenant (SDK returns array)
+ const destinationsList = await outpost.destinations.list({ tenantId });
+ const destinations = Array.isArray(destinationsList)
+ ? destinationsList.map((dest: any) => ({
...dest,
- enabled: dest.disabledAt === null, // Convert disabledAt to enabled boolean
+ enabled: dest.disabledAt === null,
}))
: [];
+ logger.debug(`Destinations found`, { tenantId, count: destinations.length });
- // Get recent events with proper error handling
+ // Get recent events (SDK v0.13: list returns { models, pagination })
let recentEvents: any[] = [];
- let totalEvents = 0;
-
try {
const eventsResponse = await outpost.events.list({ tenantId });
- // Handle paginated API response structure
- if (eventsResponse && typeof eventsResponse === "object") {
- // Check if it's a paginated response with data array
- if ("data" in eventsResponse && Array.isArray(eventsResponse.data)) {
- recentEvents = eventsResponse.data.slice(0, 10);
- totalEvents =
- (eventsResponse as any).count || eventsResponse.data.length;
- } else if (Array.isArray(eventsResponse)) {
- // Direct array response
- recentEvents = eventsResponse.slice(0, 10);
- totalEvents = eventsResponse.length;
- }
- }
- logger.debug(`Events found`, { tenantId, totalEvents });
+ const models = eventsResponse?.models ?? [];
+ recentEvents = models.slice(0, 10);
+ logger.debug(`Events found`, { tenantId, count: recentEvents.length });
} catch (error) {
- // Handle SDK validation error - the API response is valid but SDK expects different format
- if (error && typeof error === "object" && "rawValue" in error) {
- const rawResponse = (error as any).rawValue;
- if (
- rawResponse &&
- typeof rawResponse === "object" &&
- "data" in rawResponse &&
- Array.isArray(rawResponse.data)
- ) {
- recentEvents = rawResponse.data.slice(0, 10);
- totalEvents = rawResponse.count || rawResponse.data.length;
- logger.debug(`Events extracted from validation error`, {
- tenantId,
- totalEvents,
- });
- } else {
- logger.warn("Could not fetch events", { error, tenantId });
- }
- } else {
- logger.warn("Could not fetch events", { error, tenantId });
- }
+ logger.warn("Could not fetch events", { error, tenantId });
}
const overview = {
@@ -133,14 +95,12 @@ export async function getTenantOverview(tenantId: string) {
totalDestinations: Array.isArray(destinations)
? destinations.length
: 0,
- totalEvents,
},
};
logger.debug(`Complete overview retrieved for tenant: ${tenantId}`, {
tenantId,
destinationCount: overview.stats.totalDestinations,
- eventCount: overview.stats.totalEvents,
});
return overview;
} catch (error) {
diff --git a/examples/demos/dashboard-integration/src/types/dashboard.ts b/examples/demos/dashboard-integration/src/types/dashboard.ts
index 5b2dfc523..96285c944 100644
--- a/examples/demos/dashboard-integration/src/types/dashboard.ts
+++ b/examples/demos/dashboard-integration/src/types/dashboard.ts
@@ -6,7 +6,6 @@ export interface DashboardOverview {
recentEvents: Event[];
stats: {
totalDestinations: number;
- totalEvents: number;
};
}
diff --git a/sdks/schemas/pagination-fixes-overlay.yaml b/sdks/schemas/pagination-fixes-overlay.yaml
index 335216fac..10ea80b14 100644
--- a/sdks/schemas/pagination-fixes-overlay.yaml
+++ b/sdks/schemas/pagination-fixes-overlay.yaml
@@ -2,82 +2,90 @@ overlay: 1.0.0
x-speakeasy-jsonpath: rfc9535
info:
title: Pagination Field Fixes
- version: 0.0.1
+ version: 0.0.2
x-speakeasy-metadata:
type: pagination-fixes
- description: "Fixes pagination field conflicts with Python built-ins"
-extends: file:///Users/leggetter/hookdeck/git/outpost/docs/apis/openapi.yaml
+ description: "Fixes pagination field conflicts with Python built-ins; v0.13 uses SeekPagination and models/pagination envelope"
+extends: ../../docs/apis/openapi.yaml
actions:
- # Fix 'next' field conflict with Python's built-in next() function
- - target: $["components"]["schemas"]["PaginatedResponse"]["properties"]["next"]
+ # Fix 'next' field in SeekPagination to avoid Python built-in next() conflict
+ - target: $["components"]["schemas"]["SeekPagination"]["properties"]["next"]
update:
x-speakeasy-name-override: "next_cursor"
x-speakeasy-metadata:
type: field-name-override
description: "Rename 'next' to 'next_cursor' to avoid Python built-in conflict"
-
- # Fix 'prev' field for consistency
- - target: $["components"]["schemas"]["PaginatedResponse"]["properties"]["prev"]
+
+ # Fix 'prev' field for consistency
+ - target: $["components"]["schemas"]["SeekPagination"]["properties"]["prev"]
update:
x-speakeasy-name-override: "prev_cursor"
x-speakeasy-metadata:
type: field-name-override
description: "Rename 'prev' to 'prev_cursor' for consistency"
- # Fix 'next' query parameter in /tenants endpoint (index 2: limit=0, order=1, next=2, prev=3)
- - target: $["paths"]["/tenants"]["get"]["parameters"][2]
+ # GET /tenants: parameters limit=0, order_by=1, dir=2, created_at[gte]=3, created_at[lte]=4, next=5, prev=6
+ - target: $["paths"]["/tenants"]["get"]["parameters"][5]
update:
x-speakeasy-name-override: "next_cursor"
x-speakeasy-metadata:
type: parameter-name-override
- description: "Rename 'next' query parameter to 'next_cursor' to avoid Python built-in conflict"
+ description: "Rename 'next' query parameter to 'next_cursor'"
- # Fix 'prev' query parameter in /tenants endpoint
- - target: $["paths"]["/tenants"]["get"]["parameters"][3]
+ - target: $["paths"]["/tenants"]["get"]["parameters"][6]
update:
x-speakeasy-name-override: "prev_cursor"
x-speakeasy-metadata:
type: parameter-name-override
- description: "Rename 'prev' query parameter to 'prev_cursor' for consistency"
+ description: "Rename 'prev' query parameter to 'prev_cursor'"
- # Fix 'next' query parameter in /tenants/{tenant_id}/events endpoint
- # Parameters: destination_id=0, status=1, next=2, prev=3, limit=4, start=5, end=6
- - target: $["paths"]["/tenants/{tenant_id}/events"]["get"]["parameters"][2]
+ # GET /events: next=6, prev=7
+ - target: $["paths"]["/events"]["get"]["parameters"][6]
update:
x-speakeasy-name-override: "next_cursor"
x-speakeasy-metadata:
type: parameter-name-override
- description: "Rename 'next' query parameter to 'next_cursor' to avoid Python built-in conflict"
+ description: "Rename 'next' query parameter to 'next_cursor'"
- # Fix 'prev' query parameter in /tenants/{tenant_id}/events endpoint
- - target: $["paths"]["/tenants/{tenant_id}/events"]["get"]["parameters"][3]
+ - target: $["paths"]["/events"]["get"]["parameters"][7]
update:
x-speakeasy-name-override: "prev_cursor"
x-speakeasy-metadata:
type: parameter-name-override
- description: "Rename 'prev' query parameter to 'prev_cursor' for consistency"
+ description: "Rename 'prev' query parameter to 'prev_cursor'"
- # Fix 'next' query parameter in /tenants/{tenant_id}/destinations/{destination_id}/events endpoint
- # Parameters: status=0, next=1, prev=2, limit=3, start=4, end=5
- - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/events"]["get"]["parameters"][1]
+ # GET /attempts: next=8, prev=9
+ - target: $["paths"]["/attempts"]["get"]["parameters"][8]
update:
x-speakeasy-name-override: "next_cursor"
x-speakeasy-metadata:
type: parameter-name-override
- description: "Rename 'next' query parameter to 'next_cursor' to avoid Python built-in conflict"
+ description: "Rename 'next' query parameter to 'next_cursor'"
- # Fix 'prev' query parameter in /tenants/{tenant_id}/destinations/{destination_id}/events endpoint
- - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/events"]["get"]["parameters"][2]
+ - target: $["paths"]["/attempts"]["get"]["parameters"][9]
update:
x-speakeasy-name-override: "prev_cursor"
x-speakeasy-metadata:
type: parameter-name-override
- description: "Rename 'prev' query parameter to 'prev_cursor' for consistency"
-
- # Add pagination configuration for events endpoint
- # Note: The pagination config references the original parameter name 'next' (not 'next_cursor')
- # because x-speakeasy-name-override only affects generated code, not the OpenAPI spec
- - target: $["paths"]["/tenants/{tenant_id}/events"]["get"]
+ description: "Rename 'prev' query parameter to 'prev_cursor'"
+
+ # GET /tenants/{tenant_id}/destinations/{destination_id}/attempts: next=6, prev=7
+ - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/attempts"]["get"]["parameters"][6]
+ update:
+ x-speakeasy-name-override: "next_cursor"
+ x-speakeasy-metadata:
+ type: parameter-name-override
+ description: "Rename 'next' query parameter to 'next_cursor'"
+
+ - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/attempts"]["get"]["parameters"][7]
+ update:
+ x-speakeasy-name-override: "prev_cursor"
+ x-speakeasy-metadata:
+ type: parameter-name-override
+ description: "Rename 'prev' query parameter to 'prev_cursor'"
+
+ # Pagination config: GET /tenants (v0.13 envelope: models + pagination.next/prev)
+ - target: $["paths"]["/tenants"]["get"]
update:
x-speakeasy-pagination:
type: cursor
@@ -89,16 +97,52 @@ actions:
in: parameters
type: limit
outputs:
- nextCursor: $.next
- results: $.data
+ nextCursor: $.pagination.next
+ results: $.models
+ x-speakeasy-metadata:
+ type: pagination-config
+ description: "Configure cursor-based pagination for tenants listing"
+
+ # Pagination config: GET /events
+ - target: $["paths"]["/events"]["get"]
+ update:
+ x-speakeasy-pagination:
+ type: cursor
+ inputs:
+ - name: next
+ in: parameters
+ type: cursor
+ - name: limit
+ in: parameters
+ type: limit
+ outputs:
+ nextCursor: $.pagination.next
+ results: $.models
x-speakeasy-metadata:
type: pagination-config
description: "Configure cursor-based pagination for events listing"
-
- # Add pagination configuration for destination events endpoint
- # Note: The pagination config references the original parameter name 'next' (not 'next_cursor')
- # because x-speakeasy-name-override only affects generated code, not the OpenAPI spec
- - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/events"]["get"]
+
+ # Pagination config: GET /attempts
+ - target: $["paths"]["/attempts"]["get"]
+ update:
+ x-speakeasy-pagination:
+ type: cursor
+ inputs:
+ - name: next
+ in: parameters
+ type: cursor
+ - name: limit
+ in: parameters
+ type: limit
+ outputs:
+ nextCursor: $.pagination.next
+ results: $.models
+ x-speakeasy-metadata:
+ type: pagination-config
+ description: "Configure cursor-based pagination for attempts listing"
+
+ # Pagination config: GET /tenants/.../destinations/.../attempts
+ - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/attempts"]["get"]
update:
x-speakeasy-pagination:
type: cursor
@@ -110,8 +154,8 @@ actions:
in: parameters
type: limit
outputs:
- nextCursor: $.next
- results: $.data
+ nextCursor: $.pagination.next
+ results: $.models
x-speakeasy-metadata:
type: pagination-config
- description: "Configure cursor-based pagination for destination events listing"
\ No newline at end of file
+ description: "Configure cursor-based pagination for destination attempts listing"
diff --git a/sdks/schemas/speakeasy-modifications-overlay.yaml b/sdks/schemas/speakeasy-modifications-overlay.yaml
index cfd81a889..0a99ffc3f 100644
--- a/sdks/schemas/speakeasy-modifications-overlay.yaml
+++ b/sdks/schemas/speakeasy-modifications-overlay.yaml
@@ -17,21 +17,84 @@ actions:
created_at: 1745611620644
reviewed_at: 1745611624395
type: method-name
- - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}"]["get"]
+ - target: $["paths"]["/events"]["get"]
+ update:
+ x-speakeasy-name-override: list
+ x-speakeasy-metadata:
+ after: sdk.events.list()
+ before: sdk.Events.adminListEvents()
+ created_at: 1745611620645
+ reviewed_at: 1745611624395
+ type: method-name
+ - target: $["paths"]["/events/{event_id}"]["get"]
update:
x-speakeasy-name-override: get
x-speakeasy-metadata:
- after: sdk.destinations.get()
- before: sdk.Destinations.getTenantDestination()
- created_at: 1745611620644
+ after: sdk.events.get()
+ before: sdk.Events.getEvent()
+ created_at: 1745611620645
+ reviewed_at: 1745611624395
+ type: method-name
+ - target: $["paths"]["/attempts"]["get"]
+ update:
+ x-speakeasy-name-override: list
+ x-speakeasy-metadata:
+ after: sdk.attempts.list()
+ before: sdk.Attempts.adminListAttempts()
+ created_at: 1745611620645
+ reviewed_at: 1745611624395
+ type: method-name
+ - target: $["paths"]["/attempts/{attempt_id}"]["get"]
+ update:
+ x-speakeasy-name-override: get
+ x-speakeasy-metadata:
+ after: sdk.attempts.get()
+ before: sdk.Attempts.getAttempt()
+ created_at: 1745611620645
+ reviewed_at: 1745611624395
+ type: method-name
+ - target: $["paths"]["/retry"]["post"]
+ update:
+ x-speakeasy-name-override: retry
+ x-speakeasy-metadata:
+ after: sdk.attempts.retry()
+ before: sdk.Attempts.retryEvent()
+ created_at: 1745611620645
+ reviewed_at: 1745611624395
+ type: method-name
+ - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/attempts"]["get"]
+ update:
+ x-speakeasy-name-override: listAttempts
+ x-speakeasy-metadata:
+ after: sdk.destinations.listAttempts()
+ before: sdk.Destinations.listTenantDestinationAttempts()
+ created_at: 1745611620645
reviewed_at: 1745611624395
type: method-name
- - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/events/{event_id}"]["get"]
+ - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/attempts/{attempt_id}"]["get"]
update:
- x-speakeasy-name-override: getByDestination
+ x-speakeasy-name-override: getAttempt
x-speakeasy-metadata:
- after: sdk.events.getByDestination()
- before: sdk.Events.getTenantEventByDestination()
+ after: sdk.destinations.getAttempt()
+ before: sdk.Destinations.getTenantDestinationAttempt()
+ created_at: 1745611620645
+ reviewed_at: 1745611624395
+ type: method-name
+ - target: $["paths"]["/topics"]["get"]
+ update:
+ x-speakeasy-name-override: list
+ x-speakeasy-metadata:
+ after: sdk.topics.list()
+ before: sdk.Topics.listTopics()
+ created_at: 1745611620645
+ reviewed_at: 1745611624395
+ type: method-name
+ - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}"]["get"]
+ update:
+ x-speakeasy-name-override: get
+ x-speakeasy-metadata:
+ after: sdk.destinations.get()
+ before: sdk.Destinations.getTenantDestination()
created_at: 1745611620644
reviewed_at: 1745611624395
type: method-name
@@ -98,15 +161,6 @@ actions:
created_at: 1745611620645
reviewed_at: 1745611624395
type: method-name
- - target: $["paths"]["/tenants/{tenant_id}/destination-types"]["get"]
- update:
- x-speakeasy-name-override: listTenantDestinationTypes
- x-speakeasy-metadata:
- after: sdk.schemas.listTenantDestinationTypes()
- before: sdk.Schemas.listTenantDestinationTypeSchemas()
- created_at: 1745611620645
- reviewed_at: 1745611624395
- type: method-name
- target: $["paths"]["/tenants/{tenant_id}/destinations"]["post"]
update:
x-speakeasy-name-override: create
@@ -116,42 +170,6 @@ actions:
created_at: 1745611620645
reviewed_at: 1745611624395
type: method-name
- - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/events/{event_id}/retry"]["post"]
- update:
- x-speakeasy-name-override: retry
- x-speakeasy-metadata:
- after: sdk.events.retry()
- before: sdk.Events.retryTenantEvent()
- created_at: 1745611620645
- reviewed_at: 1745611624395
- type: method-name
- - target: $["paths"]["/tenants/{tenant_id}/events/{event_id}/deliveries"]["get"]
- update:
- x-speakeasy-name-override: listDeliveries
- x-speakeasy-metadata:
- after: sdk.events.listDeliveries()
- before: sdk.Events.listTenantEventDeliveries()
- created_at: 1745611620645
- reviewed_at: 1745611624395
- type: method-name
- - target: $["paths"]["/tenants/{tenant_id}/topics"]["get"]
- update:
- x-speakeasy-name-override: list
- x-speakeasy-metadata:
- after: sdk.topics.list()
- before: sdk.Topics.listTenantTopics()
- created_at: 1745611620645
- reviewed_at: 1745611624395
- type: method-name
- - target: $["paths"]["/tenants/{tenant_id}/events"]["get"]
- update:
- x-speakeasy-name-override: list
- x-speakeasy-metadata:
- after: sdk.events.list()
- before: sdk.Events.listTenantEvents()
- created_at: 1745611620645
- reviewed_at: 1745611624395
- type: method-name
- target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}"]["patch"]
update:
x-speakeasy-name-override: update
@@ -161,24 +179,6 @@ actions:
created_at: 1745611620645
reviewed_at: 1745611624395
type: method-name
- - target: $["paths"]["/tenants/{tenant_id}/events/{event_id}"]["get"]
- update:
- x-speakeasy-name-override: get
- x-speakeasy-metadata:
- after: sdk.events.get()
- before: sdk.Events.getTenantEvent()
- created_at: 1745611620645
- reviewed_at: 1745611624395
- type: method-name
- - target: $["paths"]["/tenants/{tenant_id}/destination-types/{type}"]["get"]
- update:
- x-speakeasy-name-override: get
- x-speakeasy-metadata:
- after: sdk.schemas.get()
- before: sdk.Schemas.getTenantDestinationTypeSchema()
- created_at: 1745611620645
- reviewed_at: 1745611624395
- type: method-name
- target: $["paths"]["/publish"]["post"]
update:
x-speakeasy-name-override: event
@@ -206,24 +206,6 @@ actions:
created_at: 1745611620645
reviewed_at: 1745611624395
type: method-name
- - target: $["paths"]["/topics"]["get"]
- update:
- x-speakeasy-name-override: listJwt
- x-speakeasy-metadata:
- after: sdk.topics.listJwt()
- before: sdk.Topics.listTopicsJwt()
- created_at: 1745611620645
- reviewed_at: 1745611624395
- type: method-name
- - target: $["paths"]["/tenants/{tenant_id}/destinations/{destination_id}/events"]["get"]
- update:
- x-speakeasy-name-override: listByDestination
- x-speakeasy-metadata:
- after: sdk.events.listByDestination()
- before: sdk.Events.listTenantEventsByDestination()
- created_at: 1745611620645
- reviewed_at: 1745611624395
- type: method-name
- target: $["paths"]["/tenants/{tenant_id}"]["get"]
update:
x-speakeasy-name-override: get
diff --git a/spec-sdk-tests/.env.example b/spec-sdk-tests/.env.example
index 630f56dce..62bc56941 100644
--- a/spec-sdk-tests/.env.example
+++ b/spec-sdk-tests/.env.example
@@ -10,8 +10,14 @@ TENANT_ID=test-tenant
# Test Topics Configuration (REQUIRED)
# Comma-separated list of topics that exist on the Outpost server
# These topics MUST already exist on the backend before running tests
-# Example: TEST_TOPICS=user.created,user.updated,user.deleted
-TEST_TOPICS=
+# Example (matches common Outpost instance topics):
+# TEST_TOPICS=user.created,user.updated,order.created,heartbeat
+TEST_TOPICS=user.created,user.updated,order.created,heartbeat
+
+# Health check (optional)
+# Set to true when running against managed Outpost (e.g. api.outpost.hookdeck.com)
+# because the /healthz endpoint is not available there. Default: false (health check runs).
+SKIP_HEALTH_CHECK=false
# API Authentication (REQUIRED)
# This API key must match the API_KEY environment variable in your Outpost server
@@ -24,6 +30,8 @@ DEBUG_API_REQUESTS=false
# Test Configuration
TEST_TIMEOUT=10000
TEST_RETRY_ATTEMPTS=3
+# Delay (ms) between each test to avoid API rate limits (e.g. 429). Set to 50 or higher if you see Too Many Requests.
+TEST_DELAY_MS=0
# Prism Configuration
PRISM_PORT=9000
diff --git a/spec-sdk-tests/.mocharc.json b/spec-sdk-tests/.mocharc.json
index d8570c0d0..ce98071b1 100644
--- a/spec-sdk-tests/.mocharc.json
+++ b/spec-sdk-tests/.mocharc.json
@@ -1,5 +1,5 @@
{
- "require": ["ts-node/register"],
+ "require": ["ts-node/register", "./test-setup.ts"],
"extensions": ["ts"],
"spec": ["tests/**/*.test.ts"],
"timeout": 10000,
diff --git a/spec-sdk-tests/README.md b/spec-sdk-tests/README.md
index 80ed72732..d81a59851 100644
--- a/spec-sdk-tests/README.md
+++ b/spec-sdk-tests/README.md
@@ -16,7 +16,7 @@ Because the SDK's models and methods are a direct representation of the OpenAPI
## Quick Start
-The recommended way to run the tests is using the provided script, which ensures the API is healthy before executing the test suite.
+The recommended way to run the tests is using the provided script, which validates configuration and optionally checks API health before running the test suite.
```bash
# 1. Ensure all prerequisites are met (see below)
@@ -33,6 +33,8 @@ npm install
./scripts/run-tests.sh
```
+**What `run-tests.sh` does:** Loads `.env` from this directory, requires `API_KEY` to be set, then optionally checks that the Outpost API is reachable at `API_BASE_URL` (default `http://localhost:3333`) via `GET /healthz`. For managed Outpost (e.g. api.outpost.hookdeck.com) where `/healthz` is not available, set `SKIP_HEALTH_CHECK=true` in `.env` to skip the health check. Finally it runs `npm test` and reports pass/fail.
+
## Prerequisites
Before running the tests, ensure you have the following:
@@ -73,6 +75,8 @@ The server should now be running and accessible at `http://localhost:3333`.
If you are running tests against a remote Outpost server, you must configure the `API_BASE_URL` in the test suite's `.env` file to point to your server's address.
+**Managed Outpost (e.g. api.outpost.hookdeck.com):** The `/healthz` endpoint is not available on managed Outpost. Set `SKIP_HEALTH_CHECK=true` in your `.env` so the run script skips the health check and proceeds to run tests.
+
## Test Suite Configuration
The test suite requires its own `.env` file, located within this directory (`spec-sdk-tests`).
@@ -90,13 +94,14 @@ cp .env.example .env
The following variables are **mandatory** and must be set in your `.env` file:
- `API_KEY`: The API key for authenticating with the Outpost API. **This key must match the API key configured on the target Outpost instance.**
-- `TEST_TOPICS`: A comma-separated list of topics that already exist on your Outpost instance (e.g., `user.created,user.updated`). The tests will fail if these topics do not exist.
+- `TEST_TOPICS`: A comma-separated list of topics that already exist on your Outpost instance (e.g., `user.created,user.updated,order.created,heartbeat`). The tests will fail if these topics do not exist.
Optional variables:
- `API_BASE_URL`: The base URL of the Outpost API (default: `http://localhost:3333/api/v1`). **Set this if you are targeting a remote instance.**
- `TENANT_ID`: The tenant ID to use for the tests (default: `default`).
- `DEBUG_API_REQUESTS`: Set to `true` to enable detailed request logging (default: `false`).
+- `TEST_DELAY_MS`: Delay in milliseconds before each test (default: `0`). Set to e.g. `50` to reduce 429 (Too Many Requests) when running against rate-limited APIs.
## SDK Regeneration
@@ -131,6 +136,7 @@ The following scripts are available to run, lint, and format the tests:
| `npm run format` | Formats all TypeScript files using Prettier. |
| `npm run format:check` | Checks for formatting issues without modifying files. |
| `npm run type-check` | Runs TypeScript type-checking without compiling. |
+| `npm run test:failures` | Runs only the tests that were previously failing (Events + Topics). |
## Test Structure
diff --git a/spec-sdk-tests/package.json b/spec-sdk-tests/package.json
index 727e43aff..7efe6e8a2 100644
--- a/spec-sdk-tests/package.json
+++ b/spec-sdk-tests/package.json
@@ -6,6 +6,7 @@
"scripts": {
"test": "npm run test:validation",
"test:validation": "mocha --require ts-node/register --extensions ts --timeout 10000 'tests/**/*.test.ts'",
+ "test:failures": "mocha --require ts-node/register --extensions ts --timeout 15000 'tests/**/*.test.ts' --grep \"configured instance topics when TEST_TOPICS is set\"",
"test:watch": "mocha --require ts-node/register --extensions ts --watch --watch-files 'tests/**/*.ts' 'tests/**/*.test.ts'",
"test:coverage": "nyc npm run test:validation",
"lint:spec": "spectral lint ../docs/apis/openapi.yaml",
diff --git a/spec-sdk-tests/scripts/run-tests.sh b/spec-sdk-tests/scripts/run-tests.sh
index 582dce44d..bf5289ee0 100755
--- a/spec-sdk-tests/scripts/run-tests.sh
+++ b/spec-sdk-tests/scripts/run-tests.sh
@@ -45,21 +45,28 @@ fi
echo -e "${GREEN}✓ API_KEY is configured${NC}"
echo ""
-# Check if API is running
+# Check if API is running (skip when using managed Outpost where /healthz is not available)
echo -e "${YELLOW}Checking if Outpost API is running...${NC}"
-API_URL=${API_BASE_URL:-http://localhost:3333}
-
-if ! curl -s -f -o /dev/null "$API_URL/healthz" 2>/dev/null; then
- echo -e "${RED}Error: Outpost API is not running at $API_URL${NC}"
- echo "Please start Outpost before running tests."
- echo ""
- echo "Example:"
- echo " cd /path/to/outpost"
- echo " go run ./cmd/api"
- exit 1
+SKIP_HEALTH_CHECK_VAL="${SKIP_HEALTH_CHECK:-false}"
+if [ "$SKIP_HEALTH_CHECK_VAL" = "true" ] || [ "$SKIP_HEALTH_CHECK_VAL" = "1" ] || [ "$SKIP_HEALTH_CHECK_VAL" = "yes" ]; then
+ echo -e "${YELLOW}Skipping health check (SKIP_HEALTH_CHECK=true / managed Outpost)${NC}"
+else
+ API_URL=${API_BASE_URL:-http://localhost:3333}
+ # Strip /api/v1 or similar path if present for healthz
+ API_URL="${API_URL%%/api/*}"
+ if ! curl -s -f -o /dev/null "$API_URL/healthz" 2>/dev/null; then
+ echo -e "${RED}Error: Outpost API is not running at $API_URL${NC}"
+ echo "Please start Outpost before running tests."
+ echo ""
+ echo "Example:"
+ echo " cd /path/to/outpost"
+ echo " go run ./cmd/api"
+ echo ""
+ echo "For managed Outpost (api.outpost.hookdeck.com), set SKIP_HEALTH_CHECK=true in .env"
+ exit 1
+ fi
+ echo -e "${GREEN}✓ Outpost API is running${NC}"
fi
-
-echo -e "${GREEN}✓ Outpost API is running${NC}"
echo ""
echo -e "${GREEN}Running contract tests...${NC}"
diff --git a/spec-sdk-tests/test-setup.ts b/spec-sdk-tests/test-setup.ts
new file mode 100644
index 000000000..d5acee625
--- /dev/null
+++ b/spec-sdk-tests/test-setup.ts
@@ -0,0 +1,17 @@
+/**
+ * Mocha root hook plugin: optional delay between tests to avoid API rate limits (e.g. 429).
+ * Set TEST_DELAY_MS in .env (e.g. 50) to add that many milliseconds before each test.
+ * When unset or 0, no delay is applied.
+ */
+const delayMs = Math.max(0, Number(process.env.TEST_DELAY_MS) || 0);
+
+export const mochaHooks = {
+ beforeEach:
+ delayMs > 0
+ ? [
+ async function (this: Mocha.Context) {
+ await new Promise((r) => setTimeout(r, delayMs));
+ },
+ ]
+ : [],
+};
diff --git a/spec-sdk-tests/tests/destinations/aws-kinesis.test.ts b/spec-sdk-tests/tests/destinations/aws-kinesis.test.ts
index 1abd37a92..949d59ff9 100644
--- a/spec-sdk-tests/tests/destinations/aws-kinesis.test.ts
+++ b/spec-sdk-tests/tests/destinations/aws-kinesis.test.ts
@@ -64,14 +64,21 @@ describe('AWS Kinesis Destinations - Contract Tests (SDK-based validation)', ()
expect(destination.config.region).to.equal(destinationData.config.region);
});
- it('should create an AWS Kinesis destination with array of topics', async () => {
+ it('should create an AWS Kinesis destination with array of topics', async function () {
+ const sdk = client.getSDK();
+ const instanceTopics = await sdk.topics.list();
+ if (instanceTopics.length < 2) {
+ this.skip();
+ return;
+ }
+ const topicsToUse = instanceTopics.slice(0, 2);
const destinationData = createAwsKinesisDestination({
- topics: TEST_TOPICS,
+ topics: topicsToUse,
});
const destination = await client.createDestination(destinationData);
- expect(destination.topics).to.have.lengthOf(TEST_TOPICS.length);
- TEST_TOPICS.forEach((topic) => {
+ expect(destination.topics).to.have.lengthOf(topicsToUse.length);
+ topicsToUse.forEach((topic: string) => {
expect(destination.topics).to.include(topic);
});
diff --git a/spec-sdk-tests/tests/destinations/aws-s3.test.ts b/spec-sdk-tests/tests/destinations/aws-s3.test.ts
index ffb7e348b..f4aa62c2d 100644
--- a/spec-sdk-tests/tests/destinations/aws-s3.test.ts
+++ b/spec-sdk-tests/tests/destinations/aws-s3.test.ts
@@ -64,14 +64,21 @@ describe('AWS S3 Destinations - Contract Tests (SDK-based validation)', () => {
expect(destination.config.region).to.equal(destinationData.config.region);
});
- it('should create an AWS S3 destination with array of topics', async () => {
+ it('should create an AWS S3 destination with array of topics', async function () {
+ const sdk = client.getSDK();
+ const instanceTopics = await sdk.topics.list();
+ if (instanceTopics.length < 2) {
+ this.skip();
+ return;
+ }
+ const topicsToUse = instanceTopics.slice(0, 2);
const destinationData = createAwsS3Destination({
- topics: TEST_TOPICS,
+ topics: topicsToUse,
});
const destination = await client.createDestination(destinationData);
- expect(destination.topics).to.have.lengthOf(TEST_TOPICS.length);
- TEST_TOPICS.forEach((topic) => {
+ expect(destination.topics).to.have.lengthOf(topicsToUse.length);
+ topicsToUse.forEach((topic: string) => {
expect(destination.topics).to.include(topic);
});
diff --git a/spec-sdk-tests/tests/destinations/aws-sqs.test.ts b/spec-sdk-tests/tests/destinations/aws-sqs.test.ts
index bedb2a259..c2631551e 100644
--- a/spec-sdk-tests/tests/destinations/aws-sqs.test.ts
+++ b/spec-sdk-tests/tests/destinations/aws-sqs.test.ts
@@ -63,14 +63,21 @@ describe('AWS SQS Destinations - Contract Tests (SDK-based validation)', () => {
expect(destination.config.queueUrl).to.equal(destinationData.config.queueUrl);
});
- it('should create an AWS SQS destination with array of topics', async () => {
+ it('should create an AWS SQS destination with array of topics', async function () {
+ const sdk = client.getSDK();
+ const instanceTopics = await sdk.topics.list();
+ if (instanceTopics.length < 2) {
+ this.skip();
+ return;
+ }
+ const topicsToUse = instanceTopics.slice(0, 2);
const destinationData = createAwsSqsDestination({
- topics: TEST_TOPICS,
+ topics: topicsToUse,
});
const destination = await client.createDestination(destinationData);
- expect(destination.topics).to.have.lengthOf(TEST_TOPICS.length);
- TEST_TOPICS.forEach((topic) => {
+ expect(destination.topics).to.have.lengthOf(topicsToUse.length);
+ topicsToUse.forEach((topic: string) => {
expect(destination.topics).to.include(topic);
});
diff --git a/spec-sdk-tests/tests/destinations/azure-servicebus.test.ts b/spec-sdk-tests/tests/destinations/azure-servicebus.test.ts
index 00e4f74b7..75b13ab4d 100644
--- a/spec-sdk-tests/tests/destinations/azure-servicebus.test.ts
+++ b/spec-sdk-tests/tests/destinations/azure-servicebus.test.ts
@@ -63,14 +63,21 @@ describe('Azure Service Bus Destinations - Contract Tests (SDK-based validation)
expect(destination.config.name).to.equal(destinationData.config.name);
});
- it('should create an Azure Service Bus destination with array of topics', async () => {
+ it('should create an Azure Service Bus destination with array of topics', async function () {
+ const sdk = client.getSDK();
+ const instanceTopics = await sdk.topics.list();
+ if (instanceTopics.length < 2) {
+ this.skip();
+ return;
+ }
+ const topicsToUse = instanceTopics.slice(0, 2);
const destinationData = createAzureServiceBusDestination({
- topics: TEST_TOPICS,
+ topics: topicsToUse,
});
const destination = await client.createDestination(destinationData);
- expect(destination.topics).to.have.lengthOf(TEST_TOPICS.length);
- TEST_TOPICS.forEach((topic) => {
+ expect(destination.topics).to.have.lengthOf(topicsToUse.length);
+ topicsToUse.forEach((topic: string) => {
expect(destination.topics).to.include(topic);
});
diff --git a/spec-sdk-tests/tests/destinations/gcp-pubsub.test.ts b/spec-sdk-tests/tests/destinations/gcp-pubsub.test.ts
index c0ac1bf49..815d94d1c 100644
--- a/spec-sdk-tests/tests/destinations/gcp-pubsub.test.ts
+++ b/spec-sdk-tests/tests/destinations/gcp-pubsub.test.ts
@@ -88,10 +88,17 @@ describe('GCP Pub/Sub Destinations - Contract Tests (SDK-based validation)', ()
expect(destination.config.topic).to.equal('test-topic');
});
- it('should create a GCP Pub/Sub destination with array of topics', async () => {
+ it('should create a GCP Pub/Sub destination with array of topics', async function () {
+ const sdk = client.getSDK();
+ const instanceTopics = await sdk.topics.list();
+ if (instanceTopics.length < 2) {
+ this.skip();
+ return;
+ }
+ const topicsToUse = instanceTopics.slice(0, 2);
const destination = await client.createDestination({
type: 'gcp_pubsub',
- topics: TEST_TOPICS,
+ topics: topicsToUse,
config: {
projectId: 'test-project-topics',
topic: 'events-topic',
@@ -101,9 +108,8 @@ describe('GCP Pub/Sub Destinations - Contract Tests (SDK-based validation)', ()
},
});
- expect(destination.topics).to.have.lengthOf(TEST_TOPICS.length);
- // Verify all configured test topics are present
- TEST_TOPICS.forEach((topic) => {
+ expect(destination.topics).to.have.lengthOf(topicsToUse.length);
+ topicsToUse.forEach((topic: string) => {
expect(destination.topics).to.include(topic);
});
diff --git a/spec-sdk-tests/tests/destinations/rabbitmq.test.ts b/spec-sdk-tests/tests/destinations/rabbitmq.test.ts
index edfca63ce..88d355a27 100644
--- a/spec-sdk-tests/tests/destinations/rabbitmq.test.ts
+++ b/spec-sdk-tests/tests/destinations/rabbitmq.test.ts
@@ -64,14 +64,21 @@ describe('RabbitMQ Destinations - Contract Tests (SDK-based validation)', () =>
expect(destination.config.exchange).to.equal(destinationData.config.exchange);
});
- it('should create a RabbitMQ destination with array of topics', async () => {
+ it('should create a RabbitMQ destination with array of topics', async function () {
+ const sdk = client.getSDK();
+ const instanceTopics = await sdk.topics.list();
+ if (instanceTopics.length < 2) {
+ this.skip();
+ return;
+ }
+ const topicsToUse = instanceTopics.slice(0, 2);
const destinationData = createRabbitMqDestination({
- topics: TEST_TOPICS,
+ topics: topicsToUse,
});
const destination = await client.createDestination(destinationData);
- expect(destination.topics).to.have.lengthOf(TEST_TOPICS.length);
- TEST_TOPICS.forEach((topic) => {
+ expect(destination.topics).to.have.lengthOf(topicsToUse.length);
+ topicsToUse.forEach((topic: string) => {
expect(destination.topics).to.include(topic);
});
diff --git a/spec-sdk-tests/tests/destinations/webhook.test.ts b/spec-sdk-tests/tests/destinations/webhook.test.ts
index 1dabf06e4..2828cc580 100644
--- a/spec-sdk-tests/tests/destinations/webhook.test.ts
+++ b/spec-sdk-tests/tests/destinations/webhook.test.ts
@@ -63,14 +63,21 @@ describe('Webhook Destinations - Contract Tests (SDK-based validation)', () => {
expect(destination.config.url).to.equal(destinationData.config.url);
});
- it('should create a webhook destination with array of topics', async () => {
+ it('should create a webhook destination with array of topics', async function () {
+ const sdk = client.getSDK();
+ const instanceTopics = await sdk.topics.list();
+ if (instanceTopics.length < 2) {
+ this.skip();
+ return;
+ }
+ const topicsToUse = instanceTopics.slice(0, 2);
const destinationData = createWebhookDestination({
- topics: TEST_TOPICS,
+ topics: topicsToUse,
});
const destination = await client.createDestination(destinationData);
- expect(destination.topics).to.have.lengthOf(TEST_TOPICS.length);
- TEST_TOPICS.forEach((topic) => {
+ expect(destination.topics).to.have.lengthOf(topicsToUse.length);
+ topicsToUse.forEach((topic: string) => {
expect(destination.topics).to.include(topic);
});
diff --git a/spec-sdk-tests/tests/events.test.ts b/spec-sdk-tests/tests/events.test.ts
index dbb000053..6fe127298 100644
--- a/spec-sdk-tests/tests/events.test.ts
+++ b/spec-sdk-tests/tests/events.test.ts
@@ -2,10 +2,7 @@ import { describe, it, before, after } from 'mocha';
import { expect } from 'chai';
import { SdkClient, createSdkClient } from '../utils/sdk-client';
import { createWebhookDestination } from '../factories/destination.factory';
-import type {
- Event,
- EventStatus,
-} from '../../sdks/outpost-typescript/dist/commonjs/models/components';
+import type { Event } from '../../sdks/outpost-typescript/dist/commonjs/models/components';
import type { Outpost } from '../../sdks/outpost-typescript/dist/commonjs';
/* eslint-disable no-console */
/* eslint-disable no-undef */
@@ -17,7 +14,7 @@ if (!process.env.TEST_TOPICS) {
const TEST_TOPICS = process.env.TEST_TOPICS.split(',').map((t) => t.trim());
/**
- * Poll for events with exponential backoff
+ * Poll for events (any count)
* @param fetchEvents Function that fetches events
* @param maxWaitMs Maximum time to wait in milliseconds
* @param intervalMs Initial interval between polls in milliseconds
@@ -59,24 +56,52 @@ async function pollForEvents(
}
/**
- * Tests for PR #491: https://github.com/hookdeck/outpost/pull/491
- *
- * This PR fixes issue #490 where the Event schema was missing the `status` field
- * that is returned by the API. The API returns events with a `status` field
- * (enum: "success" | "failed") but this field was not defined in the OpenAPI spec,
- * causing SDK clients to not have access to this important field.
+ * Poll until at least one attempt exists for the destination (event was delivered).
+ * @param fetchAttempts Function that fetches attempts (returns array of attempt objects)
+ * @param maxWaitMs Maximum time to wait in milliseconds
+ * @param intervalMs Interval between polls in milliseconds
+ * @returns Array of attempts, or empty if timeout
+ */
+async function pollForAttempts(
+ fetchAttempts: () => Promise,
+ maxWaitMs = 45000,
+ intervalMs = 5000
+): Promise {
+ const startTime = Date.now();
+ let pollCount = 0;
+
+ while (Date.now() - startTime < maxWaitMs) {
+ pollCount++;
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+ console.log(`Polling for attempts (poll ${pollCount}, elapsed: ${elapsed}s)...`);
+
+ const attempts = await fetchAttempts();
+ if (attempts.length > 0) {
+ console.log(`✓ Found ${attempts.length} attempt(s) after ${elapsed}s`);
+ return attempts;
+ }
+
+ const remaining = maxWaitMs - (Date.now() - startTime);
+ await new Promise((r) => setTimeout(r, remaining > intervalMs ? intervalMs : Math.max(0, remaining)));
+ }
+
+ const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
+ console.warn(`✗ No attempts found after ${totalTime}s`);
+ return [];
+}
+
+/**
+ * Events tests.
*
- * These tests verify that:
- * 1. Events returned from the API include the `status` field
- * 2. The `status` field has valid values ("success" or "failed")
- * 3. The SDK properly types and exposes the `status` field
+ * Event no longer has a status property in the spec; delivery outcome is on Attempt.
+ * These tests verify listing events, getting a single event by ID, and that publishing
+ * an event results in an attempt (with attempt.status).
*
* NOTE: For events to be logged and retrievable, there must be:
- * 1. A configured log store (e.g., PostgreSQL or ClickHouse)
- * 2. A subscriber actively consuming from the destination
- * Without these, events may not appear in the event lists.
+ * - A configured log store (e.g., PostgreSQL or ClickHouse)
+ * - A destination to deliver to (we create a webhook destination)
*/
-describe('Events - Status Field Tests (PR #491)', () => {
+describe('Events (PR #491)', () => {
let client: SdkClient;
let destinationId: string;
@@ -132,69 +157,46 @@ describe('Events - Status Field Tests (PR #491)', () => {
}
});
- describe('GET /api/v1/tenants/{tenant_id}/destinations/{destination_id}/events - Event Status Field', () => {
- it('should include status field in events returned from listByDestination', async function () {
- // Increase timeout for this test as it involves publishing and waiting for event delivery
- this.timeout(45000);
+ describe('GET /events', () => {
+ it('should list events by tenant', async function () {
+ this.timeout(60000);
- // Get the underlying SDK to access the events and publish methods
const sdk: Outpost = client.getSDK();
- // Publish an event - it will be routed to the destination by topic matching
await sdk.publish.event({
tenantId: client.getTenantId(),
topic: TEST_TOPICS[0],
data: {
- test: 'event-status-field-test',
+ test: 'events-list-test',
timestamp: new Date().toISOString(),
},
});
console.log('Event published successfully');
- // Poll for events with 5s intervals, max 30s wait
const events = await pollForEvents(
async () => {
- const response = await sdk.events.listByDestination({
+ const response = await sdk.events.list({
tenantId: client.getTenantId(),
- destinationId: destinationId,
});
- return response?.data || [];
+ return response?.models || [];
},
30000,
5000
);
- if (events.length === 0) {
- throw new Error('No events found after 30 seconds - event delivery may be failing');
- }
-
- // Verify that at least one event has the status field
- const eventWithStatus = events.find((event: Event) => event.status !== undefined);
-
- expect(eventWithStatus).to.exist;
- expect(eventWithStatus!.status).to.exist;
-
- // Verify the status field has a valid value
- const validStatuses: EventStatus[] = ['success', 'failed'];
- expect(validStatuses).to.include(eventWithStatus!.status);
-
- console.log(`Event status field verified: ${eventWithStatus!.status}`);
+ expect(events.length).to.be.at.least(1, 'Expected at least one event after listing by tenant');
+ console.log(`Listed ${events.length} event(s)`);
});
- it('should include status field when getting a single event', async function () {
- // Increase timeout for this test (no need to publish, just retrieve)
+ it('should get a single event by ID', async function () {
this.timeout(20000);
const sdk: Outpost = client.getSDK();
- // First, list events to get an event ID
- const response = await sdk.events.listByDestination({
+ const response = await sdk.events.list({
tenantId: client.getTenantId(),
- destinationId: destinationId,
});
-
- // The SDK wraps the API response in a 'data' property
- const events = response?.data || [];
+ const events = response?.models || [];
if (events.length === 0) {
console.warn('No events found - skipping single event test');
@@ -207,67 +209,48 @@ describe('Events - Status Field Tests (PR #491)', () => {
throw new Error('Event ID is undefined');
}
- console.log(`Getting event by ID: ${eventId}`);
-
- // Get the specific event
- const event = await sdk.events.getByDestination({
- tenantId: client.getTenantId(),
- destinationId: destinationId,
- eventId: eventId,
- });
-
- // Verify the status field exists
- expect(event.status).to.exist;
- const validStatuses: EventStatus[] = ['success', 'failed'];
- expect(validStatuses).to.include(event.status);
-
- console.log(`Single event status field verified: ${event.status}`);
+ const event = await sdk.events.get({ eventId });
+ expect(event).to.exist;
+ expect(event.id).to.equal(eventId);
+ console.log('Single event retrieved by ID');
});
});
- describe('GET /api/v1/tenants/{tenant_id}/events - Tenant Events Status Field', () => {
- it('should include status field in events returned from tenant events list', async function () {
- // Increase timeout for this test as it involves publishing and waiting for event delivery
- this.timeout(45000);
+ describe('Event → Attempt', () => {
+ it('publishing an event with matching topic to an enabled destination should generate an attempt', async function () {
+ this.timeout(60000);
const sdk: Outpost = client.getSDK();
- // Publish an event - it will be routed to the destination by topic matching
- await sdk.publish.event({
+ const publishResponse = await sdk.publish.event({
tenantId: client.getTenantId(),
topic: TEST_TOPICS[0],
data: {
- test: 'tenant-events-status-test',
+ test: 'event-generates-attempt',
timestamp: new Date().toISOString(),
},
});
- console.log('Event published successfully');
+ const eventId = publishResponse.id;
+ console.log(`Event published (id=${eventId}); waiting for attempt...`);
- // Poll for events with 5s intervals
- const events = await pollForEvents(
+ const attempts = await pollForAttempts(
async () => {
- const response = await sdk.events.list({
+ const response = await sdk.destinations.listAttempts({
tenantId: client.getTenantId(),
+ destinationId: destinationId,
+ eventId,
});
- return response?.data || [];
+ return response?.models ?? [];
},
- 30000,
+ 45000,
5000
);
- if (events.length === 0) {
- throw new Error('No tenant events found after 30 seconds - event delivery may be failing');
- }
-
- // Verify that at least one event has the status field
- const eventWithStatus = events.find((event: Event) => event.status !== undefined);
-
- expect(eventWithStatus).to.exist;
- expect(eventWithStatus!.status).to.exist;
- const validStatuses: EventStatus[] = ['success', 'failed'];
- expect(validStatuses).to.include(eventWithStatus!.status);
-
- console.log(`Tenant event status field verified: ${eventWithStatus!.status}`);
+ expect(attempts.length).to.be.at.least(1, 'Expected at least one attempt for the published event when tenant, enabled destination, and matching topic are in place');
+ const attempt = attempts[0];
+ expect(attempt.eventId).to.equal(eventId, 'Attempt should be for the event we just published');
+ expect(attempt.status).to.equal('success', 'Delivery to mock.hookdeck.com should succeed');
+ console.log(`Event ${eventId} generated attempt; status: ${attempt.status}`);
});
});
});
diff --git a/spec-sdk-tests/tests/sdk-defaults.test.ts b/spec-sdk-tests/tests/sdk-defaults.test.ts
new file mode 100644
index 000000000..8cbd1fd0b
--- /dev/null
+++ b/spec-sdk-tests/tests/sdk-defaults.test.ts
@@ -0,0 +1,17 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import { Outpost } from '../../sdks/outpost-typescript/dist/commonjs';
+
+const EXPECTED_DEFAULT_SERVER_URL = 'https://api.outpost.hookdeck.com/2025-07-01';
+
+describe('SDK defaults', () => {
+ it('new Outpost instance defaults to expected server URL when no serverURL or serverIdx override', () => {
+ // Minimal options: no serverURL, no serverIdx (uses first server from ServerList)
+ const client = new Outpost({});
+ expect(client._baseURL).to.exist;
+ const baseURL = client._baseURL!.href;
+ // Trailing slash may be added by SDK; normalize for comparison
+ const normalized = baseURL.replace(/\/+$/, '') + '/';
+ expect(normalized).to.equal(EXPECTED_DEFAULT_SERVER_URL + '/');
+ });
+});
diff --git a/spec-sdk-tests/tests/topics.test.ts b/spec-sdk-tests/tests/topics.test.ts
new file mode 100644
index 000000000..57148c855
--- /dev/null
+++ b/spec-sdk-tests/tests/topics.test.ts
@@ -0,0 +1,73 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import { createSdkClient } from '../utils/sdk-client';
+
+/**
+ * Topic management tests.
+ *
+ * API/SDK support: The OpenAPI spec only defines GET /topics (list). The SDK exposes
+ * sdk.topics.list() which returns the list of available event topics configured in the
+ * Outpost instance. There are no create/update/delete topic endpoints in the public API,
+ * so topic configuration (like in the portal UI) is done via server config or internal APIs.
+ *
+ * These tests cover:
+ * - Listing topics and validating response shape
+ * - Before-style check: test-only topic names must not appear in the list (so we don't
+ * rely on leftover test data; if the API later adds create/delete, we can add
+ * create → list (exists) → delete → list (absent) tests).
+ */
+
+/** Topic names used only for spec-SDK tests; they must not exist before/after. */
+const TEST_ONLY_TOPIC_NAMES = [
+ 'spec-sdk-test.placeholder',
+ 'outpost.spec-test.topic',
+ 'test.only.topic.management',
+];
+
+describe('Topics - List and sanity checks', () => {
+ it('should list topics and return an array of strings', async function () {
+ const client = createSdkClient();
+ const sdk = client.getSDK();
+
+ const topics = await sdk.topics.list();
+
+ expect(topics).to.be.an('array');
+ topics.forEach((t: string, i: number) => {
+ expect(t, `topic[${i}]`).to.be.a('string');
+ expect(t.length, `topic[${i}]`).to.be.greaterThan(0);
+ });
+ });
+
+ it('should not contain test-only placeholder topics (before/after sanity)', async function () {
+ // Skip unless SPEC_STRICT_TOPICS=true; real deployments may legitimately have these topic names.
+ if (process.env.SPEC_STRICT_TOPICS !== 'true') {
+ this.skip();
+ }
+ const client = createSdkClient();
+ const sdk = client.getSDK();
+
+ const topics = await sdk.topics.list();
+ const set = new Set(topics);
+
+ for (const testTopic of TEST_ONLY_TOPIC_NAMES) {
+ expect(set.has(testTopic), `Topic "${testTopic}" should not exist in instance list`).to.be.false;
+ }
+ });
+
+ it('should include configured instance topics when TEST_TOPICS is set', async function () {
+ const required = process.env.TEST_TOPICS?.split(',').map((t) => t.trim()).filter(Boolean);
+ if (!required?.length) {
+ this.skip();
+ return;
+ }
+
+ const client = createSdkClient();
+ const sdk = client.getSDK();
+ const topics = await sdk.topics.list();
+ const set = new Set(topics);
+
+ for (const name of required) {
+ expect(set.has(name), `Configured topic "${name}" (TEST_TOPICS) should be in list`).to.be.true;
+ }
+ });
+});