diff --git a/README.md b/README.md index c115b9f..b485229 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ works when one of these is set: | --- | --- | | `RELAY_API_KEY` | Account API key (`sk-...`) | | `RELAY_KEY` + `RELAY_SECRET` | Classic access token pair | +| `RELAY_BASE_URL` | API address, when not `https://my.webhookrelay.com` (self-hosted or local development) | Other options: `baseUrl`, `timeoutMs`, `fetch`, `headers`, `userAgent`. @@ -237,8 +238,22 @@ const message = await relay.outbound.messages.publish( Publishing is asynchronous: the result is the durable acceptance receipt. `outbound.messages.get(id)` shows each endpoint's delivery and attempts; `outbound.endpoints.retry`, `.recover` and `.replayMissing` re-send in the -background and return a recovery task (`outbound.recoveryTasks.get`). A runnable -version lives in [`examples/outbound.ts`](./examples/outbound.ts). +background and return a recovery task (`outbound.recoveryTasks.get`). + +Watch endpoint health across all your customers. Endpoints carry `stats` +(attempts and failures over the last 24 hours) when listed or read: + +```ts +const { endpoints, stats } = await relay.outbound.health(); +// endpoints: { active, failing, paused, disabled } counts; stats: { attempts, failures } + +// Failing endpoints come longest failing first. Filter by `consumer` too; +// `limit` is 1-100 (default 50), page with `offset`. +const failing = await relay.outbound.endpoints.listAll({ state: "failing", limit: 20 }); +for (const e of failing) console.log(e.consumer, e.url, e.failing_since, e.stats?.failures); +``` + +A runnable version lives in [`examples/outbound.ts`](./examples/outbound.ts). ## Receiving webhooks diff --git a/examples/outbound.ts b/examples/outbound.ts index 8eb7e59..4ee207e 100644 --- a/examples/outbound.ts +++ b/examples/outbound.ts @@ -32,6 +32,19 @@ export function publishInvoicePaid(relay: WebhookRelay) { } // dashboard-snippet:end +// Find customers whose endpoints are failing, longest failing first. +export async function reportFailingEndpoints(relay: WebhookRelay) { + const health = await relay.outbound.health(); + if (health.endpoints.failing === 0) return []; + const failing = await relay.outbound.endpoints.listAll({ state: "failing", limit: 20 }); + return failing.map((e) => ({ + consumer: e.consumer, + url: e.url, + failingSince: e.failing_since, + failures24h: e.stats?.failures ?? 0, + })); +} + async function main() { const customerURL = process.env.CUSTOMER_WEBHOOK_URL; if (!customerURL) throw new Error("set CUSTOMER_WEBHOOK_URL"); @@ -40,6 +53,7 @@ async function main() { console.log("endpoint:", endpoint.id, "signing secret:", endpoint.secret); const message = await publishInvoicePaid(relay); console.log("message:", message.id, "->", message.endpoint_ids.length, "endpoint(s)"); + console.log("failing endpoints:", await reportFailingEndpoints(relay)); } if (import.meta.filename === process.argv[1]) { diff --git a/package-lock.json b/package-lock.json index 2e0685a..ca46d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@webhookrelay/sdk", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@webhookrelay/sdk", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@types/node": "^20.11.0", diff --git a/package.json b/package.json index 45ce892..983f88a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@webhookrelay/sdk", - "version": "0.2.0", + "version": "0.3.0", "description": "Official TypeScript/JavaScript SDK for the Webhook Relay API — manage buckets, inputs, outputs, service connections and JavaScript transformation functions, poll webhooks and subscribe to them in real time over WebSocket.", "type": "module", "main": "./dist/index.cjs", diff --git a/src/config.ts b/src/config.ts index e052cb4..decf438 100644 --- a/src/config.ts +++ b/src/config.ts @@ -70,7 +70,8 @@ function readEnv(name: string): string | undefined { * 1. `apiKey` (or a `secret` that looks like an `sk-` key) → Bearer auth. * 2. `key` + `secret` classic access token → HTTP Basic auth. * - * Environment fallbacks (Node): `RELAY_API_KEY`, then `RELAY_KEY` + `RELAY_SECRET`. + * Environment fallbacks (Node): `RELAY_API_KEY`, then `RELAY_KEY` + `RELAY_SECRET`, + * and `RELAY_BASE_URL` for the API address (self-hosted or local development). */ export function resolveConfig(config: WebhookRelayConfig = {}): ResolvedConfig { const apiKey = config.apiKey ?? readEnv("RELAY_API_KEY"); @@ -105,7 +106,7 @@ export function resolveConfig(config: WebhookRelayConfig = {}): ResolvedConfig { ); } - const baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + const baseUrl = (config.baseUrl ?? readEnv("RELAY_BASE_URL") ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); return { baseUrl, diff --git a/src/generated/api.ts b/src/generated/api.ts index 623aa7f..13df30e 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -791,6 +791,8 @@ export interface ApiEndpointWithSecret { rate?: number; secret?: string; state?: "active" | "failing" | "paused" | "disabled"; + /** Stats are the endpoint's attempts over OutboundStatsWindow; read-only. */ + stats?: StructsOutboundEndpointStats; /** seconds; zero uses OutboundDefaultTimeout */ timeout?: number; updated_at?: string; @@ -907,6 +909,16 @@ export interface ApiOrgSamlSSORequest { signature_method?: string; } +export interface ApiOutboundAccess { + enabled?: boolean; + request?: ApiOutboundAccessRequest; +} + +export interface ApiOutboundAccessRequest { + requested_at?: string; + ticket_id?: string; +} + export interface ApiOutboundErrorResponse { error?: string; } @@ -921,6 +933,11 @@ export interface ApiOutboundMessageDetail { event_type?: string; id?: string; payload?: object; + /** + * UnavailableEndpointIDs lists the endpoints whose delivery could not be + * read in time; the message detail is otherwise complete. + */ + unavailable_endpoint_ids?: string[]; } export interface ApiOutboundRecoveryBody { @@ -2464,12 +2481,19 @@ export interface StructsOutboundEndpoint { previous_secret_expires_at?: string; rate?: number; state?: "active" | "failing" | "paused" | "disabled"; + /** Stats are the endpoint's attempts over OutboundStatsWindow; read-only. */ + stats?: StructsOutboundEndpointStats; /** seconds; zero uses OutboundDefaultTimeout */ timeout?: number; updated_at?: string; url?: string; } +export interface StructsOutboundEndpointStats { + attempts?: number; + failures?: number; +} + export interface StructsOutboundEventType { created_at?: string; deprecated?: boolean; @@ -2479,6 +2503,13 @@ export interface StructsOutboundEventType { updated_at?: string; } +export interface StructsOutboundHealth { + endpoints?: Record; + /** Since is the start of the window the stats cover. */ + since?: string; + stats?: StructsOutboundEndpointStats; +} + export interface StructsOutboundMessage { consumer?: string; created_at?: string; @@ -6873,6 +6904,42 @@ export class Api< ...params, }), + /** + * @description Outbound webhooks are a pilot. Reports whether they are enabled for the account and, while they are not, the open request to enable them. + * + * @tags outbound + * @name OutboundAccessList + * @summary Get outbound webhooks access + * @request GET:/v1/outbound/access + * @secure + */ + outboundAccessList: (params: RequestParams = {}) => + this.request({ + path: `/v1/outbound/access`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Asks support to enable outbound webhooks by opening a support ticket. Asking again while that ticket is open returns it instead of opening another; nothing is opened when outbound webhooks are already enabled. + * + * @tags outbound + * @name OutboundAccessRequestCreate + * @summary Request outbound webhooks + * @request POST:/v1/outbound/access/request + * @secure + */ + outboundAccessRequestCreate: (params: RequestParams = {}) => + this.request({ + path: `/v1/outbound/access/request`, + method: "POST", + secure: true, + format: "json", + ...params, + }), + /** * @description Lists the producer's customers that receive outbound webhooks. Pilot: requires the outbound feature flag. * @@ -6892,7 +6959,7 @@ export class Api< }), /** - * @description Newest first. Signing secrets are not included. + * @description Newest first, with each endpoint's attempts and failures over the last 24 hours. Signing secrets are not included. * * @tags outbound * @name OutboundConsumersEndpointsList @@ -6996,6 +7063,37 @@ export class Api< ...params, }), + /** + * @description Filter by state to find failing or disabled endpoints among all of the account's consumers. Failing endpoints come longest failing first, others newest first. Each endpoint carries its attempts and failures over the last 24 hours. Signing secrets are not included. + * + * @tags outbound + * @name OutboundEndpointsList + * @summary List outbound endpoints across consumers + * @request GET:/v1/outbound/endpoints + * @secure + */ + outboundEndpointsList: ( + query?: { + /** Only endpoints in this state */ + state?: "active" | "failing" | "paused" | "disabled"; + /** Only this consumer's endpoints */ + consumer?: string; + /** Page size, at most 100 */ + limit?: number; + /** Page offset */ + offset?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/v1/outbound/endpoints`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + /** * @description Delivery history is kept. * @@ -7072,6 +7170,12 @@ export class Api< limit?: number; /** Page offset */ offset?: number; + /** Only deliveries in this status */ + status?: "sent" | "failed" | "stalled" | "received" | "rejected"; + /** Only deliveries of this event type */ + event_type?: string; + /** Only the delivery of this message */ + message_id?: string; }, params: RequestParams = {}, ) => @@ -7169,7 +7273,7 @@ export class Api< }), /** - * @description Re-sends message_id to the endpoint in the background and returns the recovery task. + * @description Re-sends message_id to the endpoint in the background and returns the recovery task. A delivery waiting for its next scheduled retry is re-sent now; one whose first attempt is still queued is refused. * * @tags outbound * @name OutboundEndpointsRetryCreate @@ -7317,6 +7421,24 @@ export class Api< ...params, }), + /** + * @description Counts the account's endpoints by state and their delivery attempts and failures over the last 24 hours, to the hour. Attempts are counted within about 15 seconds. + * + * @tags outbound + * @name OutboundHealthList + * @summary Get outbound endpoint health + * @request GET:/v1/outbound/health + * @secure + */ + outboundHealthList: (params: RequestParams = {}) => + this.request({ + path: `/v1/outbound/health`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + /** * @description Newest first, without payloads. * @@ -7349,7 +7471,7 @@ export class Api< }), /** - * @description Accepts a message for signed, asynchronous delivery to every endpoint of the consumer subscribed to its event type. Reusing an Idempotency-Key with the same message returns the original receipt. + * @description Accepts a message for signed, asynchronous delivery to every endpoint of the consumer subscribed to its event type. An event type not yet in the catalog is added on first publish; deprecated event types are rejected. Reusing an Idempotency-Key with the same message returns the original receipt. * * @tags outbound * @name OutboundMessagesCreate diff --git a/src/resources/outbound.ts b/src/resources/outbound.ts index dfe8ca6..e21bb50 100644 --- a/src/resources/outbound.ts +++ b/src/resources/outbound.ts @@ -1,6 +1,7 @@ import type { HttpClient } from "../http.js"; import { outboundEndpointParams, outboundMessageParams } from "../params.js"; import type { + ListAllOutboundEndpointsParams, ListOutboundDeliveriesParams, ListOutboundMessagesParams, OutboundConsumer, @@ -9,6 +10,7 @@ import type { OutboundEndpointParams, OutboundEndpointWithSecret, OutboundEventType, + OutboundHealth, OutboundMessage, OutboundRecoveryParams, OutboundRecoveryTask, @@ -101,7 +103,7 @@ export class OutboundEventTypesResource { export class OutboundEndpointsResource { constructor(private readonly http: HttpClient) {} - /** List a consumer's endpoints, newest first. */ + /** List a consumer's endpoints, newest first, with their stats. */ list(consumerId: string): Promise { return this.http.unwrap( this.http.api.v1.outboundConsumersEndpointsList(segment(consumerId)), @@ -110,6 +112,23 @@ export class OutboundEndpointsResource { ); } + /** + * List endpoints across all consumers, for example to find the failing + * ones. Failing endpoints come longest failing first, others newest first. + * Each endpoint carries its attempts and failures over the last 24 hours. + * + * ```ts + * const failing = await relay.outbound.endpoints.listAll({ state: "failing", limit: 20 }); + * ``` + */ + listAll(params: ListAllOutboundEndpointsParams = {}): Promise { + return this.http.unwrap( + this.http.api.v1.outboundEndpointsList(params), + "GET", + "/v1/outbound/endpoints", + ); + } + /** Get an endpoint by ID. */ get(id: string): Promise { return this.http.unwrap( @@ -317,11 +336,29 @@ export class OutboundResource { readonly messages: OutboundMessagesResource; readonly recoveryTasks: OutboundRecoveryTasksResource; - constructor(http: HttpClient) { + constructor(private readonly http: HttpClient) { this.consumers = new OutboundConsumersResource(http); this.eventTypes = new OutboundEventTypesResource(http); this.endpoints = new OutboundEndpointsResource(http); this.messages = new OutboundMessagesResource(http); this.recoveryTasks = new OutboundRecoveryTasksResource(http); } + + /** + * Count your endpoints by state and their delivery attempts and failures + * over the last 24 hours, to the hour. Attempts are counted within about + * 15 seconds. + * + * ```ts + * const { endpoints, stats } = await relay.outbound.health(); + * console.log(endpoints.failing, stats.failures / Math.max(stats.attempts, 1)); + * ``` + */ + health(): Promise { + return this.http.unwrap( + this.http.api.v1.outboundHealthList(), + "GET", + "/v1/outbound/health", + ); + } } diff --git a/src/types.ts b/src/types.ts index 0a2c042..27c579f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -502,10 +502,41 @@ export interface OutboundEndpoint { failing_since?: string; /** When the secret replaced by the last rotation stops signing deliveries. */ previous_secret_expires_at?: string; + /** + * Delivery attempts and failures over the last 24 hours. Read-only; set when + * endpoints are listed or read, absent on creation. + */ + stats?: OutboundEndpointStats; created_at?: string; updated_at?: string; } +/** Delivery attempts and how many of them failed. */ +export interface OutboundEndpointStats { + attempts: number; + failures: number; +} + +export interface ListAllOutboundEndpointsParams { + /** Only endpoints in this state. `failing` endpoints come longest failing first. */ + state?: OutboundEndpointState; + /** Only this consumer's endpoints. */ + consumer?: string; + /** Page size, 1-100; defaults to 50. */ + limit?: number; + offset?: number; +} + +/** Health of all your endpoints. */ +export interface OutboundHealth { + /** Live endpoints by state; every state is present, zero when none. */ + endpoints: Record; + /** Attempts and failures across all endpoints since `since`. */ + stats: OutboundEndpointStats; + /** RFC3339 start of the window the stats cover: 24 hours ago, to the hour. */ + since: string; +} + /** A new endpoint with its signing secret, returned only on creation. */ export interface OutboundEndpointWithSecret extends OutboundEndpoint { secret: string; @@ -559,6 +590,7 @@ export interface OutboundDeliveryAttempt { status_code?: number; outcome?: string; error?: string; + /** The endpoint's response body, base64-encoded. */ response_body?: string; } @@ -583,6 +615,11 @@ export interface OutboundMessage { enqueued_at?: string; /** Set by `messages.get`: one delivery per addressed endpoint. */ deliveries?: OutboundDelivery[]; + /** + * Set by `messages.get` when some deliveries could not be read in time; + * ask again later for those endpoints. + */ + unavailable_endpoint_ids?: string[]; } export interface ListOutboundMessagesParams { @@ -596,6 +633,12 @@ export interface ListOutboundMessagesParams { export interface ListOutboundDeliveriesParams { limit?: number; offset?: number; + /** Only deliveries in this status. `stalled` means a retry is scheduled. */ + status?: "sent" | "failed" | "stalled" | "received" | "rejected"; + /** Only deliveries of this event type. */ + event_type?: string; + /** Only the delivery of this message. */ + message_id?: string; } export interface OutboundRecoveryParams { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 230da97..721f2bf 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -1037,6 +1037,10 @@ definitions: - paused - disabled type: string + stats: + allOf: + - $ref: '#/definitions/structs.OutboundEndpointStats' + description: Stats are the endpoint's attempts over OutboundStatsWindow; read-only. timeout: description: seconds; zero uses OutboundDefaultTimeout type: integer @@ -1205,6 +1209,20 @@ definitions: signature_method: type: string type: object + api.outboundAccess: + properties: + enabled: + type: boolean + request: + $ref: '#/definitions/api.outboundAccessRequest' + type: object + api.outboundAccessRequest: + properties: + requested_at: + type: string + ticket_id: + type: string + type: object api.outboundErrorResponse: properties: error: @@ -1234,6 +1252,13 @@ definitions: type: string payload: type: object + unavailable_endpoint_ids: + description: |- + UnavailableEndpointIDs lists the endpoints whose delivery could not be + read in time; the message detail is otherwise complete. + items: + type: string + type: array type: object api.outboundRecoveryBody: properties: @@ -3772,6 +3797,10 @@ definitions: - paused - disabled type: string + stats: + allOf: + - $ref: '#/definitions/structs.OutboundEndpointStats' + description: Stats are the endpoint's attempts over OutboundStatsWindow; read-only. timeout: description: seconds; zero uses OutboundDefaultTimeout type: integer @@ -3780,6 +3809,13 @@ definitions: url: type: string type: object + structs.OutboundEndpointStats: + properties: + attempts: + type: integer + failures: + type: integer + type: object structs.OutboundEventType: properties: created_at: @@ -3795,6 +3831,18 @@ definitions: updated_at: type: string type: object + structs.OutboundHealth: + properties: + endpoints: + additionalProperties: + type: integer + type: object + since: + description: Since is the start of the window the stats cover. + type: string + stats: + $ref: '#/definitions/structs.OutboundEndpointStats' + type: object structs.OutboundMessage: properties: consumer: @@ -9994,6 +10042,47 @@ paths: summary: Update organization SAML SSO configuration tags: - organizations + /v1/outbound/access: + get: + description: Outbound webhooks are a pilot. Reports whether they are enabled + for the account and, while they are not, the open request to enable them. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.outboundAccess' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.outboundErrorResponse' + security: + - BearerAuth: [] + summary: Get outbound webhooks access + tags: + - outbound + /v1/outbound/access/request: + post: + description: Asks support to enable outbound webhooks by opening a support ticket. + Asking again while that ticket is open returns it instead of opening another; + nothing is opened when outbound webhooks are already enabled. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.outboundAccess' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.outboundErrorResponse' + security: + - BearerAuth: [] + summary: Request outbound webhooks + tags: + - outbound /v1/outbound/consumers: get: description: 'Lists the producer''s customers that receive outbound webhooks. @@ -10018,7 +10107,8 @@ paths: - outbound /v1/outbound/consumers/{consumer}/endpoints: get: - description: Newest first. Signing secrets are not included. + description: Newest first, with each endpoint's attempts and failures over the + last 24 hours. Signing secrets are not included. parameters: - description: Consumer ID in: path @@ -10083,6 +10173,10 @@ paths: description: Consumer not found schema: $ref: '#/definitions/api.outboundErrorResponse' + "409": + description: The consumer already has an endpoint with this URL + schema: + $ref: '#/definitions/api.outboundErrorResponse' security: - BearerAuth: [] summary: Create an outbound endpoint @@ -10185,6 +10279,56 @@ paths: summary: Create or update an outbound consumer tags: - outbound + /v1/outbound/endpoints: + get: + description: Filter by state to find failing or disabled endpoints among all + of the account's consumers. Failing endpoints come longest failing first, + others newest first. Each endpoint carries its attempts and failures over + the last 24 hours. Signing secrets are not included. + parameters: + - description: Only endpoints in this state + enum: + - active + - failing + - paused + - disabled + in: query + name: state + type: string + - description: Only this consumer's endpoints + in: query + name: consumer + type: string + - description: Page size, at most 100 + in: query + name: limit + type: integer + - description: Page offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/structs.OutboundEndpoint' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.outboundErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.outboundErrorResponse' + security: + - BearerAuth: [] + summary: List outbound endpoints across consumers + tags: + - outbound /v1/outbound/endpoints/{id}: delete: description: Delivery history is kept. @@ -10277,6 +10421,10 @@ paths: description: Not Found schema: $ref: '#/definitions/api.outboundErrorResponse' + "409": + description: The consumer already has an endpoint with this URL + schema: + $ref: '#/definitions/api.outboundErrorResponse' security: - BearerAuth: [] summary: Update an outbound endpoint @@ -10299,6 +10447,24 @@ paths: in: query name: offset type: integer + - description: Only deliveries in this status + enum: + - sent + - failed + - stalled + - received + - rejected + in: query + name: status + type: string + - description: Only deliveries of this event type + in: query + name: event_type + type: string + - description: Only the delivery of this message + in: query + name: message_id + type: string produces: - application/json responses: @@ -10308,6 +10474,10 @@ paths: items: $ref: '#/definitions/structs.Log' type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.outboundErrorResponse' "403": description: Forbidden schema: @@ -10479,7 +10649,8 @@ paths: consumes: - application/json description: Re-sends message_id to the endpoint in the background and returns - the recovery task. + the recovery task. A delivery waiting for its next scheduled retry is re-sent + now; one whose first attempt is still queued is refused. parameters: - description: Endpoint ID in: path @@ -10511,6 +10682,10 @@ paths: description: Not Found schema: $ref: '#/definitions/api.outboundErrorResponse' + "409": + description: The message's first delivery attempt is still queued + schema: + $ref: '#/definitions/api.outboundErrorResponse' "429": description: Too many recovery tasks are still running schema: @@ -10649,10 +10824,6 @@ paths: description: OK schema: $ref: '#/definitions/api.deletedResponse' - "400": - description: An endpoint subscribes to the event type - schema: - $ref: '#/definitions/api.outboundErrorResponse' "403": description: Forbidden schema: @@ -10661,6 +10832,10 @@ paths: description: Not Found schema: $ref: '#/definitions/api.outboundErrorResponse' + "409": + description: An endpoint subscribes to the event type + schema: + $ref: '#/definitions/api.outboundErrorResponse' security: - BearerAuth: [] summary: Delete an outbound event type @@ -10702,6 +10877,27 @@ paths: summary: Create or update an outbound event type tags: - outbound + /v1/outbound/health: + get: + description: Counts the account's endpoints by state and their delivery attempts + and failures over the last 24 hours, to the hour. Attempts are counted within + about 15 seconds. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/structs.OutboundHealth' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.outboundErrorResponse' + security: + - BearerAuth: [] + summary: Get outbound endpoint health + tags: + - outbound /v1/outbound/messages: get: description: Newest first, without payloads. @@ -10744,8 +10940,9 @@ paths: consumes: - application/json description: Accepts a message for signed, asynchronous delivery to every endpoint - of the consumer subscribed to its event type. Reusing an Idempotency-Key with - the same message returns the original receipt. + of the consumer subscribed to its event type. An event type not yet in the + catalog is added on first publish; deprecated event types are rejected. Reusing + an Idempotency-Key with the same message returns the original receipt. parameters: - description: Message in: body @@ -10762,7 +10959,7 @@ paths: - application/json responses: "202": - description: Accepted + description: The receipt, without the payload schema: $ref: '#/definitions/structs.OutboundMessage' "400": @@ -10781,6 +10978,10 @@ paths: description: The idempotency key was used for a different message schema: $ref: '#/definitions/api.outboundErrorResponse' + "413": + description: The payload exceeds 256 KiB + schema: + $ref: '#/definitions/api.outboundErrorResponse' "429": description: Too many messages are still waiting to be enqueued schema: diff --git a/test/config.test.ts b/test/config.test.ts index cc2aea8..90c22e7 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -57,6 +57,18 @@ describe("resolveConfig", () => { } }); + it("falls back to RELAY_BASE_URL from the environment", () => { + const saved = process.env.RELAY_BASE_URL; + process.env.RELAY_BASE_URL = "https://localhost:9300/"; + try { + expect(resolveConfig({ apiKey: "sk-x" }).baseUrl).toBe("https://localhost:9300"); + expect(resolveConfig({ apiKey: "sk-x", baseUrl: "https://explicit.example" }).baseUrl).toBe("https://explicit.example"); + } finally { + if (saved === undefined) delete process.env.RELAY_BASE_URL; + else process.env.RELAY_BASE_URL = saved; + } + }); + it("sets a default timeout and a branded user-agent", () => { const c = resolveConfig({ apiKey: "sk-x" }); expect(c.timeoutMs).toBe(30_000); diff --git a/test/outbound.test.ts b/test/outbound.test.ts index bce63db..8f999c1 100644 --- a/test/outbound.test.ts +++ b/test/outbound.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createClient } from "./helpers.js"; -import { onboardCustomer, publishInvoicePaid } from "../examples/outbound.js"; +import { onboardCustomer, publishInvoicePaid, reportFailingEndpoints } from "../examples/outbound.js"; const base = "https://my.webhookrelay.com"; const requests = (calls: { method: string; url: string }[]) => @@ -100,6 +100,7 @@ describe("outbound", () => { ); const deliveries = await relay.outbound.endpoints.deliveries("endpoint-1", { limit: 5 }); + await relay.outbound.endpoints.deliveries("endpoint-1", { status: "failed", event_type: "invoice.paid" }); await relay.outbound.endpoints.retry("endpoint-1", "message-1"); await relay.outbound.endpoints.recover("endpoint-1", { since: "2026-09-01T00:00:00Z" }); await relay.outbound.endpoints.replayMissing("endpoint-1"); @@ -108,13 +109,84 @@ describe("outbound", () => { expect(deliveries[0]?.message_id).toBe("message-1"); expect(requests(calls)).toEqual([ "GET /v1/outbound/endpoints/endpoint-1/deliveries?limit=5", + "GET /v1/outbound/endpoints/endpoint-1/deliveries?status=failed&event_type=invoice.paid", "POST /v1/outbound/endpoints/endpoint-1/retry", "POST /v1/outbound/endpoints/endpoint-1/recover", "POST /v1/outbound/endpoints/endpoint-1/replay-missing", "GET /v1/outbound/recovery-tasks/task-1", ]); - expect(calls[1]?.body).toEqual({ message_id: "message-1" }); - expect(calls[2]?.body).toEqual({ since: "2026-09-01T00:00:00Z" }); + expect(calls[2]?.body).toEqual({ message_id: "message-1" }); + expect(calls[3]?.body).toEqual({ since: "2026-09-01T00:00:00Z" }); + }); + + it("lists endpoints across consumers with filters and stats", async () => { + const endpoint = { + id: "endpoint-1", + consumer: "customer_42", + url: "https://example.com/hook", + event_types: ["*"], + rate: 0, + timeout: 0, + auto_disable: true, + state: "failing", + consecutive_failures: 7, + failing_since: "2026-09-27T10:00:00Z", + stats: { attempts: 12, failures: 7 }, + }; + const { relay, calls } = createClient(() => ({ json: [endpoint] })); + + const all = await relay.outbound.endpoints.listAll(); + const failing = await relay.outbound.endpoints.listAll({ + state: "failing", + consumer: "customer:42", + limit: 100, + offset: 20, + }); + const listed = await relay.outbound.endpoints.list("customer_42"); + + expect(requests(calls)).toEqual([ + "GET /v1/outbound/endpoints", + "GET /v1/outbound/endpoints?state=failing&consumer=customer%3A42&limit=100&offset=20", + "GET /v1/outbound/consumers/customer_42/endpoints", + ]); + expect(all).toEqual([endpoint]); + expect(failing[0]?.stats).toEqual({ attempts: 12, failures: 7 }); + expect(failing[0]?.failing_since).toBe("2026-09-27T10:00:00Z"); + expect(listed[0]?.stats?.failures).toBe(7); + }); + + it("reads outbound health", async () => { + const body = { + endpoints: { active: 3, failing: 1, paused: 0, disabled: 2 }, + stats: { attempts: 120, failures: 9 }, + since: "2026-09-27T12:00:00Z", + }; + const { relay, calls } = createClient(() => ({ json: body })); + + const health = await relay.outbound.health(); + + expect(requests(calls)).toEqual(["GET /v1/outbound/health"]); + expect(health).toEqual(body); + expect(health.endpoints.failing).toBe(1); + expect(health.stats.failures).toBe(9); + }); + + it("runs the failing-endpoints example", async () => { + const { relay, calls } = createClient((url) => + url.endsWith("/health") + ? { json: { endpoints: { active: 1, failing: 1, paused: 0, disabled: 0 }, stats: { attempts: 4, failures: 3 }, since: "2026-09-27T12:00:00Z" } } + : { json: [{ id: "endpoint-1", consumer: "customer_42", url: "https://customer.example/webhooks", failing_since: "2026-09-27T10:00:00Z", stats: { attempts: 4, failures: 3 } }] }, + ); + + const report = await reportFailingEndpoints(relay); + + expect(requests(calls)).toEqual([ + "GET /v1/outbound/health", + "GET /v1/outbound/endpoints?state=failing&limit=20", + ]); + expect(report).toEqual([ + { consumer: "customer_42", url: "https://customer.example/webhooks", failingSince: "2026-09-27T10:00:00Z", failures24h: 3 }, + ]); }); it("runs the example that the dashboard shows", async () => {