From 6008dee49bd51b19914cc84de18217228821e3be Mon Sep 17 00:00:00 2001 From: liyb Date: Wed, 14 Oct 2026 08:14:02 +0800 Subject: [PATCH] docs(webhooks): publish the model webhook Events API reference - Publish api-reference/webhooks.md and link it from the API reference sidebar; the old guards dated from Sandbox event webhooks, which were retired in B-225 (webhooks are model-only now). - Document Bearer or X-API-Key auth and the {code,message} error body. - Add /events/webhooks list/create/update/delete to the OpenAPI spec with a WebhookApiKey scheme; the endpoints never apply spending limits. - Validators now require the page, sidebar link and OpenAPI paths. --- .vitepress/config.ts | 1 - .vitepress/sidebar.ts | 1 + api-reference/authentication.md | 5 +- api-reference/errors.md | 2 + api-reference/webhooks.md | 17 ++++ public/openapi.yaml | 118 ++++++++++++++++++++++ scripts/validate-built-public-surface.mjs | 1 - scripts/validate-doc-content.mjs | 1 - scripts/validate-public-api-surface.mjs | 23 +++-- scripts/validate-seo.mjs | 1 - 10 files changed, 156 insertions(+), 14 deletions(-) diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 90975614..ba3a9e48 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -246,7 +246,6 @@ export default defineConfig({ 'DEPLOYMENT.md', // Retired Embed, Environment, Endpoint, Deployment, and Site Agent pages live here. '_archived/**', - 'api-reference/webhooks.md', 'agents/endpoint-quickstart.md', 'admin/api-keys.md', 'setup/cli.md', diff --git a/.vitepress/sidebar.ts b/.vitepress/sidebar.ts index f364749e..33f02d11 100644 --- a/.vitepress/sidebar.ts +++ b/.vitepress/sidebar.ts @@ -112,6 +112,7 @@ export const apiReferenceSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Overview', link: '/api-reference/' }, { text: 'Authentication', link: '/api-reference/authentication' }, { text: 'Errors', link: '/api-reference/errors' }, + { text: 'Webhooks', link: '/api-reference/webhooks' }, { text: 'OpenAPI Spec', link: '/docs/openapi.yaml' }, ], }, diff --git a/api-reference/authentication.md b/api-reference/authentication.md index afa1804d..5f2644d8 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -44,8 +44,9 @@ Gemini GenerateContent and Interactions operations accept `x-goog-api-key`, `Aut ### Extraction Logic Standard endpoints read `Authorization: Bearer ` only. The Anthropic Messages middleware reads `x-api-key` -first and then falls back to the Bearer header. Agents platform resources are the other exception: they accept -`Authorization: Bearer` or `X-API-Key` (see below). A missing supported credential returns `401 Unauthorized`. +first and then falls back to the Bearer header. Agents platform resources accept `Authorization: Bearer` or +`X-API-Key` (see below). The [webhook Events API](/api-reference/webhooks) (`/events/webhooks*`) also accepts either +header and uses the Bearer header when both are present. A missing supported credential returns `401 Unauthorized`. ### Agents platform resources diff --git a/api-reference/errors.md b/api-reference/errors.md index 634104fe..9e77ae65 100644 --- a/api-reference/errors.md +++ b/api-reference/errors.md @@ -21,6 +21,8 @@ Agents platform resources (`/v1/agents*`, `/v1/services*`, `/v1/schedules*`, `/v {"error":{"type":"invalid_request_error","code":"invalid_request","param":null,"message":"invalid request"}} ``` +The webhook Events API (`/events/webhooks*`) returns `{"code":,"message":"..."}`. See [Webhooks](/api-reference/webhooks#authentication-and-errors). + OpenAI-compatible endpoints retain an OpenAI-compatible envelope. `POST /v1/messages` retains an Anthropic-compatible envelope: ```json diff --git a/api-reference/webhooks.md b/api-reference/webhooks.md index f6e0a22a..89771620 100644 --- a/api-reference/webhooks.md +++ b/api-reference/webhooks.md @@ -86,6 +86,23 @@ curl -X PATCH https://api.sandbase.ai/events/webhooks/wh_01... \ `signatureSecret` cannot be viewed or changed through `PATCH`; a request containing it is rejected. If the secret is lost, delete and recreate the webhook. You can also view, edit, enable or disable, and delete registrations from the [Console Webhooks page](https://www.sandbase.ai/console/webhooks). +`GET` returns a JSON array of webhooks (without `signatureSecret`), `PATCH` returns the updated webhook, and `DELETE` +returns `204 No Content`. + +### Authentication and errors + +The Events API accepts the API key as `Authorization: Bearer $SANDBASE_API_KEY` or `X-API-Key: $SANDBASE_API_KEY`. When +both are sent, the Bearer header is used. + +Errors use their own body, with the HTTP status repeated in `code`: + +```json +{"code": 400, "message": "resourceType must be \"model\""} +``` + +Branch on the HTTP status. `401` means the key is missing, invalid, or revoked; `400` means the request body or +subscription is invalid; `404` (`webhook not found`) means the webhook does not exist in your organization. + ## Per-task model callback For one asynchronous image, video, or audio task, add `webhook_url` to `POST /v1/run` instead of registering a new webhook: diff --git a/public/openapi.yaml b/public/openapi.yaml index db9ab294..8a03f78c 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -65,6 +65,8 @@ tags: description: Model catalog - name: Tasks description: Asynchronous model task status, settlement, and usage + - name: Webhooks + description: Register organization webhooks for asynchronous model generation events - name: Account description: Organization balance and API execution history - name: Skills @@ -640,6 +642,72 @@ paths: '403': { description: API Key spending limit reached or organization disabled, content: { application/json: { schema: { $ref: '#/components/schemas/VolcengineError' } } } } '404': { description: 'Task is unknown, deleted, not native, or not owned by the current API key', content: { application/json: { schema: { $ref: '#/components/schemas/VolcengineError' } } } } '500': { description: Task lookup failed, content: { application/json: { schema: { $ref: '#/components/schemas/VolcengineError' } } } } + # --- Webhooks --------------------------------------------------- + /events/webhooks: + get: + operationId: listWebhooks + tags: [Webhooks] + summary: List webhooks + description: Lists the organization's webhooks. signatureSecret is never returned. + security: + - BearerAuth: [] + - WebhookApiKey: [] + responses: + '200': { description: Webhooks, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Webhook' } } } } } + '401': { description: 'Missing, invalid, or revoked API key', content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + '500': { description: Webhook lookup failed, content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + post: + operationId: createWebhook + tags: [Webhooks] + summary: Create a webhook + description: Registers a webhook for asynchronous model generation events. The response is the only time signatureSecret is returned. + security: + - BearerAuth: [] + - WebhookApiKey: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/WebhookCreateRequest' } + responses: + '200': { description: Webhook created, content: { application/json: { schema: { $ref: '#/components/schemas/WebhookCreated' } } } } + '400': { description: 'Invalid body, URL, resourceType, or events', content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + '401': { description: 'Missing, invalid, or revoked API key', content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + '500': { description: Webhook creation failed, content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + /events/webhooks/{webhook_id}: + parameters: + - { name: webhook_id, in: path, required: true, schema: { type: string }, description: Webhook ID returned by create or list. } + patch: + operationId: updateWebhook + tags: [Webhooks] + summary: Update a webhook + description: Updates the fields sent; omitted fields remain unchanged. The resulting resourceType and events subscription must be valid. signatureSecret cannot be changed. + security: + - BearerAuth: [] + - WebhookApiKey: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/WebhookUpdateRequest' } + responses: + '200': { description: Updated webhook, content: { application/json: { schema: { $ref: '#/components/schemas/Webhook' } } } } + '400': { description: 'Invalid body or subscription, or the request includes signatureSecret', content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + '401': { description: 'Missing, invalid, or revoked API key', content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + '404': { description: 'webhook not found', content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + '500': { description: Webhook update failed, content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + delete: + operationId: deleteWebhook + tags: [Webhooks] + summary: Delete a webhook + security: + - BearerAuth: [] + - WebhookApiKey: [] + responses: + '204': { description: Webhook deleted } + '401': { description: 'Missing, invalid, or revoked API key', content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + '404': { description: 'webhook not found', content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } + '500': { description: Webhook deletion failed, content: { application/json: { schema: { $ref: '#/components/schemas/WebhookError' } } } } # --- Account ---------------------------------------------------- /v1/account/balance: get: @@ -3872,6 +3940,11 @@ components: in: query name: key description: Google SDK compatibility fallback. Prefer x-goog-api-key or Authorization because URLs can be logged. + WebhookApiKey: + type: apiKey + in: header + name: X-API-Key + description: Accepted by the webhook Events API (/events/webhooks*). Authorization Bearer takes precedence when both are sent. AgentsApiKey: type: apiKey in: header @@ -4245,6 +4318,51 @@ components: description: Provider-returned download URL. It can be empty until the provider makes a URL available. created_at: { type: string, format: date-time } + Webhook: + type: object + required: [id, name, url, resourceType, events, enabled, createdAt] + properties: + id: { type: string } + name: { type: string } + url: { type: string, format: uri } + resourceType: { type: string, description: 'model for new registrations. Retired types (session, sandbox, template) can still appear on old registrations.' } + events: { type: array, items: { type: string }, description: Empty means all events in the resource type. } + enabled: { type: boolean } + createdAt: { type: string, format: date-time } + WebhookCreated: + allOf: + - $ref: '#/components/schemas/Webhook' + - type: object + required: [signatureSecret] + properties: + signatureSecret: { type: string, description: HMAC signing secret. Returned only in this response. } + WebhookCreateRequest: + type: object + required: [name, url, resourceType] + properties: + name: { type: string } + url: { type: string, format: uri, description: HTTP(S) endpoint. Use HTTPS in production. } + resourceType: { type: string, enum: [model] } + events: + type: array + items: { type: string, enum: [model.generation.completed, model.generation.failed, model.generation.timeout, model.generation.cancelled] } + description: Empty or omitted means all current and future events in the resource type. + enabled: { type: boolean, default: true } + signatureSecret: { type: string, description: Optional. Omit to let SandBase generate a high-entropy secret. } + WebhookUpdateRequest: + type: object + properties: + name: { type: string } + url: { type: string, format: uri } + resourceType: { type: string, enum: [model] } + events: { type: array, items: { type: string, enum: [model.generation.completed, model.generation.failed, model.generation.timeout, model.generation.cancelled] } } + enabled: { type: boolean } + WebhookError: + type: object + required: [code, message] + properties: + code: { type: integer, description: The HTTP status code. } + message: { type: string } TaskCostResponse: type: object required: [id, status, settled, currency, cost, estimated_cost, usage] diff --git a/scripts/validate-built-public-surface.mjs b/scripts/validate-built-public-surface.mjs index 38117cbf..70ff4983 100644 --- a/scripts/validate-built-public-surface.mjs +++ b/scripts/validate-built-public-surface.mjs @@ -57,7 +57,6 @@ const forbidden = [ [/\/v1\/generations(?:\/|\b)/i, 'withdrawn generation API'], [/\/v1\/blog\/assets(?:\/|\b)/i, 'internal Blog publishing storage API'], [sandboxAPIPath, 'Sandbox API'], - [/\/events\/webhooks(?:\/|\b)/i, 'Sandbox event webhook API'], [/\/v1\/endpoints\/[A-Za-z0-9_{}%.-]+\/mcp\b/i, 'Endpoint MCP transport'], [/\/v1\/endpoint_runtime_profiles\b/i, 'Endpoint runtime-profile discovery'], [/\/v1\/mcp(?!-connections)(?:\/|\b)/i, 'generic MCP transport or discovery'], diff --git a/scripts/validate-doc-content.mjs b/scripts/validate-doc-content.mjs index 2655bce2..899d52d4 100644 --- a/scripts/validate-doc-content.mjs +++ b/scripts/validate-doc-content.mjs @@ -7,7 +7,6 @@ const roots = ['admin', 'agents', 'sandbox', 'api-reference', 'for-agents', 'get const excluded = [ 'agents/deployments.md', 'agents/endpoint-quickstart.md', - 'api-reference/webhooks.md', 'setup/cli.md', 'setup/groups.md', ] diff --git a/scripts/validate-public-api-surface.mjs b/scripts/validate-public-api-surface.mjs index a6926442..15fdc9b4 100644 --- a/scripts/validate-public-api-surface.mjs +++ b/scripts/validate-public-api-surface.mjs @@ -64,7 +64,6 @@ for (const [label, content] of [['Capabilities', capabilitiesPage], ['Vision', v assert.match(legacySeedanceReference, /^canonical:\s*\/docs\/model-api-reference\/official-native-api\/$/m, 'Legacy Seedance reference must canonicalize to Official Native API') assert.match(legacySeedanceReference, /^robots:\s*noindex,follow$/m, 'Legacy Seedance reference must not compete in search results') assert.doesNotMatch(readme, /deepseek\/deepseek-v3\b/i, 'README must not recommend the retired DeepSeek V3 model') -assert.doesNotMatch(readme, /API Reference[^\n]*webhooks/i, 'README must not advertise the hidden Webhooks page') assert.doesNotMatch(readme, /\b\d[\d,]*\+?\s+models and APIs\b/i, 'README must not hard-code time-sensitive catalog counts') for (const overview of catalogOverviews) { assert.doesNotMatch(overview, /currently publishes API reference pages for \d/i, 'Catalog overviews must not hard-code time-sensitive counts') @@ -176,6 +175,11 @@ for (const [publicPath, pathItem] of Object.entries(openapiDocument.paths)) { operation.responses['401']?.content?.['application/json']?.schema, `${method.toUpperCase()} ${publicPath} must document its implemented JSON authentication-error envelope`, ) + if (publicPath.startsWith('/events/webhooks')) { + // Webhook management authenticates the key only; it never applies spending limits. + assert.ok(!operation.responses['402'], `${method.toUpperCase()} ${publicPath} must not claim spending-limit rejection`) + continue + } if (publicPath === '/v1/tasks/{task_id}/cost') { assert.ok(!operation.responses['402'], 'Task cost lookup must preserve its implemented spending-limit exception') assert.ok(operation.responses['403'], 'Task cost lookup must document scoped-key rejection') @@ -195,14 +199,14 @@ assert.match(config, /'_archived\/\*\*'/, 'Archived API pages must stay excluded for (const archivedSource of ['guides/site-agent-integration.md', 'use-cases', 'api-reference/embeds', 'api-reference/environments', 'api-reference/endpoints/mcp.md', 'api-reference/endpoints/acp.md']) { assert.ok(!existsSync(path.join(root, archivedSource)), `${archivedSource} is retired and must live under _archived/`) } -assert.match(config, /'api-reference\/webhooks\.md'/, 'Sandbox event webhook reference must stay excluded from the public build') +assert.doesNotMatch(config, /'api-reference\/webhooks\.md'/, 'The model webhook reference must be published') assert.match(config, /'agents\/endpoint-quickstart\.md'/, 'Retired Service quickstart must stay excluded from the public build') assert.match(config, /'admin\/api-keys\.md'/, 'Duplicate API Keys guide must stay excluded from the public build') assert.match(config, /'setup\/cli\.md'/, 'Duplicate CLI setup page must stay excluded from the public build') assert.match(config, /'setup\/groups\.md'/, 'Retired Platform Groups page must stay excluded from the public build') assert.doesNotMatch(platformSidebar, /Service quickstart/, 'Retired Service quickstart must not appear in the docs sidebar') assert.doesNotMatch(sidebar, /\/api-reference\/sandboxes?\b/i, 'Sidebar must not link to sandbox API pages') -assert.doesNotMatch(sidebar, /\/api-reference\/webhooks\b/i, 'Sidebar must not link to Sandbox event webhook APIs') +assert.match(sidebar, /\/api-reference\/webhooks\b/, 'Sidebar must link to the model webhook reference') assert.equal((modelSidebar.match(/text: 'Inference APIs'/g) ?? []).length, 0, 'Model API navigation must keep normalized Inference APIs in the concept/API reference docs') assert.doesNotMatch(sidebar, /text: 'Embed Configs'/, 'Platform API sidebar must not expose the unmaintained Embed Config module') assert.doesNotMatch(platformSidebar, /text: 'Models'/, 'Platform API sidebar must direct model discovery to the standalone Model API Reference') @@ -236,7 +240,13 @@ assert.doesNotMatch(openapi, /^ \/v1\/skills\/\{[^}]+\}\/mcp-publications:$/m, assert.doesNotMatch(openapi, /^ \/v1\/skill-mcp-publications(?:\/\{[^}]+\})?:$/m, 'Skill MCP publication management must not be public') assert.doesNotMatch(openapi, /^ \/v1\/capabilities\/\{[^}]+\}\/mcp:$/m, 'Capability MCP transport must not be public') assert.doesNotMatch(openapi, /^ \/v1\/skill-mcp\/\{[^}]+\}\/mcp:$/m, 'Published Skill MCP transport must not be public') -assert.doesNotMatch(openapi, /^ \/events\/webhooks(?:\/\{[^}]+\})?:$/m, 'Sandbox event webhook paths must not be public') +for (const webhookPath of ['/events/webhooks', '/events/webhooks/{webhook_id}']) { + for (const operation of Object.values(openapiDocument.paths[webhookPath] ?? {}).filter((value) => value?.operationId)) { + assert.deepEqual(operation.security, [{ BearerAuth: [] }, { WebhookApiKey: [] }], `${webhookPath} must document Bearer and X-API-Key authentication`) + } +} +assert.ok(openapiDocument.paths['/events/webhooks']?.get && openapiDocument.paths['/events/webhooks']?.post, 'Webhook list and create must be public') +assert.ok(openapiDocument.paths['/events/webhooks/{webhook_id}']?.patch && openapiDocument.paths['/events/webhooks/{webhook_id}']?.delete, 'Webhook update and delete must be public') const geminiGeneratePath = openapiDocument.paths['/v1beta/models/{model}:generateContent']?.post const geminiStreamPath = openapiDocument.paths['/v1beta/models/{model}:streamGenerateContent']?.post assert.ok(geminiGeneratePath, 'Public OpenAPI must expose the implemented Gemini GenerateContent endpoint') @@ -667,9 +677,7 @@ assert.match(uploadMediaResponse, /required: \[url, filename, size, type, conten assert.match(modelApiOverview, /\/api-reference\/models\/(?:assets|upload)/, 'Model API overview must link to the media asset references') assert.doesNotMatch(openapi, /^ \/default\/v1(?:\/|:)/m, 'Internal Console paths must not be public') -const unpublishedFiles = new Set([ - 'api-reference/webhooks.md', -]) +const unpublishedFiles = new Set([]) function inspectPublishedSources(directory) { for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -687,7 +695,6 @@ function inspectPublishedSources(directory) { assert.doesNotMatch(content, /\/default\/v1(?:\/|\b)/, `${relative} must not expose internal Console API paths`) assert.doesNotMatch(content, /\/v1\/generations(?:\/|\b)/, `${relative} must not expose the withdrawn generation path`) assert.doesNotMatch(content, /\/v1\/blog\/assets(?:\/|\b)/, `${relative} must not expose the internal Blog publishing storage API`) - assert.doesNotMatch(content, /\/events\/webhooks(?:\/|\b)/, `${relative} must not expose Sandbox event webhook paths`) assert.doesNotMatch(content, /\brun_abc123\b/, `${relative} must not assume a run ID prefix`) assert.doesNotMatch(content, /\btask_abc123\b/, `${relative} must not assume a task ID prefix`) assert.doesNotMatch(content, /\bcred_01/, `${relative} must use the implemented sec_ Managed Credential ID prefix`) diff --git a/scripts/validate-seo.mjs b/scripts/validate-seo.mjs index 151b7581..ebe14f53 100644 --- a/scripts/validate-seo.mjs +++ b/scripts/validate-seo.mjs @@ -6,7 +6,6 @@ import YAML from 'yaml' const roots = ['admin', 'agents', 'sandbox', 'api-reference', 'for-agents', 'getting-started', 'guides', 'models', 'setup', 'store', 'model-api-reference'] const excluded = [ 'agents/endpoint-quickstart.md', - 'api-reference/webhooks.md', 'setup/cli.md', 'setup/groups.md', ]