From 463791f44808b6e07a900c41d976269cd2fb75cc Mon Sep 17 00:00:00 2001 From: Patrick Tannoury Date: Sat, 8 Aug 2026 18:13:45 +0200 Subject: [PATCH] feat: add the webhooks namespace and signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second step of the SDK expansion: cover the webhooks API, which was already complete server-side and had no client. Each SDK gains a `webhooks` namespace over the ten endpoints under /v1/projects/:projectId/webhooks — list, get, create, update, delete, resume, rotate-secret, test, list deliveries, and replay a delivery. The other half is receiving. Every SDK can now verify the HMAC signature the platform sends, against the same scheme the platform signs with: hmac-sha256 over ".", a 300s replay window, and a constant-time digest comparison. Two entry points: a boolean `verify` for callers that only need a yes/no, and a `constructEvent` that raises WebhookSignatureError and returns the parsed envelope, so an unverified payload cannot be read by accident. Verification deliberately takes the raw body rather than a parsed object, since key order and whitespace are part of what was signed. --- javascript/package-lock.json | 18 + javascript/package.json | 1 + javascript/src/client.ts | 3 + javascript/src/errors.ts | 14 + javascript/src/index.ts | 35 ++ javascript/src/services/webhooks.test.ts | 325 +++++++++++++++ javascript/src/services/webhooks.ts | 375 ++++++++++++++++++ javascript/src/webhooks/signature.test.ts | 211 ++++++++++ javascript/src/webhooks/signature.ts | 134 +++++++ javascript/tsconfig.json | 3 +- php/src/Cosmoner.php | 2 + php/src/WebhookSignature.php | 145 +++++++ php/src/WebhookSignatureError.php | 20 + php/src/WebhooksService.php | 382 ++++++++++++++++++ php/tests/WebhookSignatureTest.php | 200 ++++++++++ php/tests/WebhooksServiceTest.php | 346 ++++++++++++++++ python/src/cosmoner/__init__.py | 20 + python/src/cosmoner/client.py | 3 + python/src/cosmoner/errors.py | 13 + python/src/cosmoner/webhook_signature.py | 131 +++++++ python/src/cosmoner/webhooks.py | 456 ++++++++++++++++++++++ python/tests/test_webhook_signature.py | 141 +++++++ python/tests/test_webhooks.py | 352 +++++++++++++++++ 23 files changed, 3329 insertions(+), 1 deletion(-) create mode 100644 javascript/src/services/webhooks.test.ts create mode 100644 javascript/src/services/webhooks.ts create mode 100644 javascript/src/webhooks/signature.test.ts create mode 100644 javascript/src/webhooks/signature.ts create mode 100644 php/src/WebhookSignature.php create mode 100644 php/src/WebhookSignatureError.php create mode 100644 php/src/WebhooksService.php create mode 100644 php/tests/WebhookSignatureTest.php create mode 100644 php/tests/WebhooksServiceTest.php create mode 100644 python/src/cosmoner/webhook_signature.py create mode 100644 python/src/cosmoner/webhooks.py create mode 100644 python/tests/test_webhook_signature.py create mode 100644 python/tests/test_webhooks.py diff --git a/javascript/package-lock.json b/javascript/package-lock.json index 0de629b..9918ed3 100644 --- a/javascript/package-lock.json +++ b/javascript/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.0", "license": "MIT", "devDependencies": { + "@types/node": "^22.20.1", "oxlint": "^1.77.0", "tsup": "^8.0.0", "typescript": "^7.0.2", @@ -1542,6 +1543,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -3160,6 +3171,13 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", diff --git a/javascript/package.json b/javascript/package.json index 3e7cb1f..a02cea9 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -32,6 +32,7 @@ ], "license": "MIT", "devDependencies": { + "@types/node": "^22.20.1", "oxlint": "^1.77.0", "tsup": "^8.0.0", "typescript": "^7.0.2", diff --git a/javascript/src/client.ts b/javascript/src/client.ts index df8a327..9dbaf28 100644 --- a/javascript/src/client.ts +++ b/javascript/src/client.ts @@ -2,6 +2,7 @@ import { resolveConfig, type CosmonerConfig, type ResolvedConfig } from "./config"; import { EmailService } from "./services/email"; +import { WebhooksService } from "./services/webhooks"; import { Transport } from "./transport"; /** @@ -27,6 +28,7 @@ export class Cosmoner { private readonly transport: Transport; readonly email: EmailService; + readonly webhooks: WebhooksService; constructor(config: CosmonerConfig) { this.config = resolveConfig(config); @@ -39,5 +41,6 @@ export class Cosmoner { this.transport = new Transport(this.config); this.email = new EmailService(this.transport, this.config); + this.webhooks = new WebhooksService(this.transport, this.config); } } diff --git a/javascript/src/errors.ts b/javascript/src/errors.ts index 3e2f042..b793633 100644 --- a/javascript/src/errors.ts +++ b/javascript/src/errors.ts @@ -114,6 +114,20 @@ export class ServerError extends CosmonerError { } } +/** + * A received webhook could not be verified against its signing secret. + * + * Unlike the rest of the hierarchy this never comes from an API response — it + * is raised locally while checking an inbound request, so it carries no + * meaningful HTTP status. + */ +export class WebhookSignatureError extends CosmonerError { + constructor(message: string) { + super(0, "WEBHOOK_SIGNATURE_INVALID", message); + this.name = "WebhookSignatureError"; + } +} + /** Shape of the API's error envelope. */ interface ErrorEnvelope { success?: false; diff --git a/javascript/src/index.ts b/javascript/src/index.ts index 8101805..0ef4d3c 100644 --- a/javascript/src/index.ts +++ b/javascript/src/index.ts @@ -18,12 +18,47 @@ export { RateLimitError, ServerError, ValidationError, + WebhookSignatureError, } from "./errors"; export { EmailService, type SendEmailParams, type SendEmailResponse, } from "./services/email"; +export { + WEBHOOK_EVENT_TYPES, + WebhooksService, + type CreateWebhookEndpointParams, + type CreateWebhookEndpointResponse, + type DeleteWebhookEndpointResponse, + type GetWebhookEndpointResponse, + type ListDeliveriesParams, + type ListWebhookDeliveriesResponse, + type ListWebhookEndpointsResponse, + type ReplayWebhookDeliveryResponse, + type ResumeWebhookEndpointResponse, + type RotateWebhookSecretResponse, + type TestWebhookEndpointResponse, + type UpdateWebhookEndpointParams, + type UpdateWebhookEndpointResponse, + type WebhookDelivery, + type WebhookDeliveryStatus, + type WebhookDisabledReason, + type WebhookEndpoint, + type WebhookEndpointStats, + type WebhookEndpointWithSecret, + type WebhookEventType, +} from "./services/webhooks"; +export { + constructEvent, + DEFAULT_TOLERANCE_SECONDS, + DELIVERY_ID_HEADER, + EVENT_TYPE_HEADER, + SIGNATURE_HEADER, + verifyWebhookSignature, + type VerifyWebhookParams, + type WebhookEvent, +} from "./webhooks/signature"; export { VERSION } from "./version"; import { Cosmoner } from "./client"; diff --git a/javascript/src/services/webhooks.test.ts b/javascript/src/services/webhooks.test.ts new file mode 100644 index 0000000..aa73ddf --- /dev/null +++ b/javascript/src/services/webhooks.test.ts @@ -0,0 +1,325 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { Cosmoner, NotFoundError } from "../index"; + +const BASE = "https://api.test.dev/v1/projects/proj-1/webhooks"; + +/** Build a client pointed at the mocked host, with retries disabled. */ +function makeClient() { + return new Cosmoner({ + apiKey: "key-123", + projectId: "proj-1", + baseUrl: "https://api.test.dev", + maxRetries: 0, + }); +} + +/** A JSON response with the API's success envelope. */ +function ok(data: unknown, status = 200) { + return new Response(JSON.stringify({ success: true, data }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const ENDPOINT = { + id: "ep-1", + name: "Billing receiver", + description: null, + url: "https://example.com/hooks", + events: ["email.delivered"], + enabled: true, + secretHint: "cdef", + consecutiveFailures: 0, + disabledAt: null, + disabledReason: null, + lastSuccessAt: null, + lastFailureAt: null, + createdAt: "2026-08-08T12:00:00.000Z", + updatedAt: "2026-08-08T12:00:00.000Z", +}; + +describe("WebhooksService", () => { + let client: Cosmoner; + + beforeEach(() => { + client = makeClient(); + }); + + describe("argument validation", () => { + it("requires an endpointId on get", async () => { + await expect(client.webhooks.get("")).rejects.toThrow("endpointId is required"); + }); + + it("requires an endpointId on delete", async () => { + await expect(client.webhooks.delete("")).rejects.toThrow("endpointId is required"); + }); + + it("requires a deliveryId on replay", async () => { + await expect(client.webhooks.replayDelivery("ep-1", "")).rejects.toThrow( + "deliveryId is required" + ); + }); + + it("requires a name on create", async () => { + await expect( + client.webhooks.create({ name: "", url: "https://e.com", events: ["email.sent"] }) + ).rejects.toThrow("name is required"); + }); + + it("requires a url on create", async () => { + await expect( + client.webhooks.create({ name: "Hooks", url: "", events: ["email.sent"] }) + ).rejects.toThrow("url is required"); + }); + + it("requires at least one event on create", async () => { + await expect( + client.webhooks.create({ name: "Hooks", url: "https://e.com", events: [] }) + ).rejects.toThrow("At least one event is required"); + }); + + it("rejects clearing every event on update", async () => { + await expect(client.webhooks.update("ep-1", { events: [] })).rejects.toThrow( + "At least one event is required" + ); + }); + + it("requires a projectId when the client has no default", async () => { + const scopeless = new Cosmoner({ + apiKey: "key-123", + baseUrl: "https://api.test.dev", + maxRetries: 0, + }); + + await expect(scopeless.webhooks.list()).rejects.toThrow("projectId is required"); + }); + }); + + describe("API calls", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("lists endpoints", async () => { + fetchSpy.mockResolvedValueOnce(ok([ENDPOINT])); + + const result = await client.webhooks.list(); + + expect(result.data).toEqual([ENDPOINT]); + expect(fetchSpy).toHaveBeenCalledWith(BASE, expect.objectContaining({ method: "GET" })); + }); + + it("fetches one endpoint with its stats", async () => { + const stats = { succeeded: 12, failed: 1, pending: 0 }; + fetchSpy.mockResolvedValueOnce(ok({ endpoint: ENDPOINT, stats })); + + const result = await client.webhooks.get("ep-1"); + + expect(result.data.stats).toEqual(stats); + expect(fetchSpy).toHaveBeenCalledWith( + `${BASE}/ep-1`, + expect.objectContaining({ method: "GET" }) + ); + }); + + it("creates an endpoint and surfaces the one-time secret", async () => { + fetchSpy.mockResolvedValueOnce(ok({ ...ENDPOINT, secret: "whsec_full_value" }, 201)); + + const result = await client.webhooks.create({ + name: "Billing receiver", + url: "https://example.com/hooks", + events: ["email.delivered", "email.bounced"], + description: "Receipts", + }); + + expect(result.data.secret).toBe("whsec_full_value"); + + const [, init] = fetchSpy.mock.calls[0]!; + expect(init).toMatchObject({ method: "POST" }); + expect(JSON.parse(init!.body as string)).toEqual({ + name: "Billing receiver", + url: "https://example.com/hooks", + events: ["email.delivered", "email.bounced"], + description: "Receipts", + }); + }); + + it("updates an endpoint with PATCH, omitting untouched fields", async () => { + fetchSpy.mockResolvedValueOnce(ok(ENDPOINT)); + + await client.webhooks.update("ep-1", { enabled: false }); + + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe(`${BASE}/ep-1`); + expect(init).toMatchObject({ method: "PATCH" }); + // Undefined fields must not be sent — the API treats present keys as edits. + expect(JSON.parse(init!.body as string)).toEqual({ enabled: false }); + }); + + it("sends an explicit null to clear the description", async () => { + fetchSpy.mockResolvedValueOnce(ok(ENDPOINT)); + + await client.webhooks.update("ep-1", { description: null }); + + const [, init] = fetchSpy.mock.calls[0]!; + expect(JSON.parse(init!.body as string)).toEqual({ description: null }); + }); + + it("deletes an endpoint", async () => { + fetchSpy.mockResolvedValueOnce(ok(null)); + + const result = await client.webhooks.delete("ep-1"); + + expect(result.data).toBeNull(); + expect(fetchSpy).toHaveBeenCalledWith( + `${BASE}/ep-1`, + expect.objectContaining({ method: "DELETE" }) + ); + }); + + it("resumes a paused endpoint", async () => { + fetchSpy.mockResolvedValueOnce(ok(ENDPOINT)); + + await client.webhooks.resume("ep-1"); + + expect(fetchSpy).toHaveBeenCalledWith( + `${BASE}/ep-1/resume`, + expect.objectContaining({ method: "POST" }) + ); + }); + + it("rotates the signing secret", async () => { + fetchSpy.mockResolvedValueOnce(ok({ id: "ep-1", secret: "whsec_rotated" })); + + const result = await client.webhooks.rotateSecret("ep-1"); + + expect(result.data.secret).toBe("whsec_rotated"); + expect(fetchSpy).toHaveBeenCalledWith( + `${BASE}/ep-1/rotate-secret`, + expect.objectContaining({ method: "POST" }) + ); + }); + + it("sends a test event with an explicit type", async () => { + fetchSpy.mockResolvedValueOnce(ok({ outcome: "SUCCEEDED", delivery: { id: "dlv-1" } })); + + const result = await client.webhooks.test("ep-1", { eventType: "email.bounced" }); + + expect(result.data.outcome).toBe("SUCCEEDED"); + + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe(`${BASE}/ep-1/test`); + expect(JSON.parse(init!.body as string)).toEqual({ eventType: "email.bounced" }); + }); + + it("lets the server pick the test event type when none is given", async () => { + fetchSpy.mockResolvedValueOnce(ok({ outcome: "SUCCEEDED", delivery: { id: "dlv-1" } })); + + await client.webhooks.test("ep-1"); + + const [, init] = fetchSpy.mock.calls[0]!; + expect(JSON.parse(init!.body as string)).toEqual({}); + }); + + it("passes delivery filters through as query parameters", async () => { + fetchSpy.mockResolvedValueOnce(ok({ deliveries: [], nextCursor: null })); + + await client.webhooks.listDeliveries("ep-1", { + status: "FAILED", + eventType: "email.bounced", + limit: 50, + cursor: "dlv-9", + }); + + const [url] = fetchSpy.mock.calls[0]!; + const parsed = new URL(url as string); + expect(parsed.pathname).toBe("/v1/projects/proj-1/webhooks/ep-1/deliveries"); + expect(Object.fromEntries(parsed.searchParams)).toEqual({ + status: "FAILED", + eventType: "email.bounced", + limit: "50", + cursor: "dlv-9", + }); + }); + + it("omits absent delivery filters entirely", async () => { + fetchSpy.mockResolvedValueOnce(ok({ deliveries: [], nextCursor: null })); + + await client.webhooks.listDeliveries("ep-1"); + + expect(fetchSpy.mock.calls[0]![0]).toBe(`${BASE}/ep-1/deliveries`); + }); + + it("replays a delivery", async () => { + fetchSpy.mockResolvedValueOnce(ok({ id: "dlv-2" }, 201)); + + const result = await client.webhooks.replayDelivery("ep-1", "dlv-1"); + + expect(result.data.id).toBe("dlv-2"); + expect(fetchSpy).toHaveBeenCalledWith( + `${BASE}/ep-1/deliveries/dlv-1/replay`, + expect.objectContaining({ method: "POST" }) + ); + }); + + it("targets another project when projectId is passed per call", async () => { + fetchSpy.mockResolvedValueOnce(ok([ENDPOINT])); + + await client.webhooks.list({ projectId: "proj-2" }); + + expect(fetchSpy.mock.calls[0]![0]).toBe("https://api.test.dev/v1/projects/proj-2/webhooks"); + }); + + it("maps a 404 onto NotFoundError", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: false, + error: { code: "NOT_FOUND", message: "Webhook endpoint not found" }, + }), + { status: 404, headers: { "Content-Type": "application/json" } } + ) + ); + + await expect(client.webhooks.get("ep-missing")).rejects.toThrow(NotFoundError); + }); + + it("sends auth and idempotency headers on writes", async () => { + fetchSpy.mockResolvedValueOnce(ok(ENDPOINT, 201)); + + await client.webhooks.create({ + name: "Hooks", + url: "https://example.com/hooks", + events: ["email.sent"], + }); + + const [, init] = fetchSpy.mock.calls[0]!; + expect(init!.headers).toMatchObject({ + Authorization: "Bearer key-123", + "Content-Type": "application/json", + }); + expect((init!.headers as Record)["Idempotency-Key"]).toBeTruthy(); + }); + }); + + describe("verification helpers", () => { + it("exposes verify on the namespace", () => { + expect(client.webhooks.verify({ payload: "{}", signature: "garbage", secret: "s" })).toBe( + false + ); + }); + + it("exposes constructEvent on the namespace", () => { + expect(() => + client.webhooks.constructEvent({ payload: "{}", signature: "garbage", secret: "s" }) + ).toThrow(/Malformed/); + }); + }); +}); diff --git a/javascript/src/services/webhooks.ts b/javascript/src/services/webhooks.ts new file mode 100644 index 0000000..456ecd5 --- /dev/null +++ b/javascript/src/services/webhooks.ts @@ -0,0 +1,375 @@ +/* eslint-disable require-await -- Every method here validates its arguments before + reaching the transport. Keeping them `async` makes a bad argument reject the + returned promise rather than throw synchronously, so one `.catch()` covers both + client-side and server-side failures. */ + +/** Webhooks service namespace — endpoint management and delivery inspection. */ + +import { resolveProjectId, type ResolvedConfig } from "../config"; +import type { Transport } from "../transport"; +import { + constructEvent, + verifyWebhookSignature, + type VerifyWebhookParams, + type WebhookEvent, +} from "../webhooks/signature"; + +/** Every event type an endpoint can subscribe to. */ +export const WEBHOOK_EVENT_TYPES = [ + "email.sent", + "email.delivered", + "email.delivery_delayed", + "email.bounced", + "email.complained", + "email.opened", + "email.clicked", + "email.rejected", + "email.rendering_failed", + "email.domain_verified", + "email.sending_paused", + "app.deployed", + "app.failed", + "domain.verified", + "domain.expired", + "server.running", + "server.error", + "member.invited", + "member.joined", +] as const; + +export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; + +/** Lifecycle state of a single delivery attempt chain. */ +export type WebhookDeliveryStatus = "PENDING" | "SUCCEEDED" | "FAILED"; + +/** Why an endpoint stopped receiving events. */ +export type WebhookDisabledReason = "MANUAL" | "CONSECUTIVE_FAILURES"; + +/** A configured webhook endpoint. The full secret is never returned here. */ +export interface WebhookEndpoint { + id: string; + name: string; + description: string | null; + url: string; + events: WebhookEventType[]; + enabled: boolean; + /** Last four characters of the signing secret — enough to tell keys apart. */ + secretHint: string; + consecutiveFailures: number; + disabledAt: string | null; + disabledReason: WebhookDisabledReason | null; + lastSuccessAt: string | null; + lastFailureAt: string | null; + createdAt: string; + updatedAt: string; +} + +/** Delivery counts behind an endpoint's health badge. */ +export interface WebhookEndpointStats { + succeeded: number; + failed: number; + pending: number; +} + +/** One attempt chain for one event against one endpoint. */ +export interface WebhookDelivery { + id: string; + eventType: WebhookEventType; + eventId: string; + payload: unknown; + status: WebhookDeliveryStatus; + attempt: number; + nextAttemptAt: string | null; + responseStatus: number | null; + responseBody: string | null; + errorMessage: string | null; + durationMs: number | null; + deliveredAt: string | null; + createdAt: string; +} + +/** Options accepted by every method, for working across projects. */ +export interface ProjectScopedParams { + /** Overrides the client-level default project for this call. */ + projectId?: string; +} + +/** Arguments accepted by `client.webhooks.create()`. */ +export interface CreateWebhookEndpointParams extends ProjectScopedParams { + name: string; + /** Must be an HTTPS URL — the API rejects plaintext endpoints. */ + url: string; + events: WebhookEventType[]; + description?: string; +} + +/** Arguments accepted by `client.webhooks.update()`. */ +export interface UpdateWebhookEndpointParams extends ProjectScopedParams { + name?: string; + url?: string; + events?: WebhookEventType[]; + /** Pass null to clear the description. */ + description?: string | null; + enabled?: boolean; +} + +/** Filters accepted by `client.webhooks.listDeliveries()`. */ +export interface ListDeliveriesParams extends ProjectScopedParams { + status?: WebhookDeliveryStatus; + eventType?: WebhookEventType; + /** 1–100, defaults to 25 server-side. */ + limit?: number; + /** `nextCursor` from the previous page. */ + cursor?: string; +} + +/** An endpoint returned with its signing secret, shown only once. */ +export interface WebhookEndpointWithSecret extends WebhookEndpoint { + /** + * The full signing secret. Returned only when the endpoint is created — + * store it now, because no later call can retrieve it. + */ + secret: string; +} + +interface Envelope { + success: true; + data: T; +} + +export type ListWebhookEndpointsResponse = Envelope; +export type GetWebhookEndpointResponse = Envelope<{ + endpoint: WebhookEndpoint; + stats: WebhookEndpointStats; +}>; +export type CreateWebhookEndpointResponse = Envelope; +export type UpdateWebhookEndpointResponse = Envelope; +export type ResumeWebhookEndpointResponse = Envelope; +export type RotateWebhookSecretResponse = Envelope<{ id: string; secret: string }>; +export type TestWebhookEndpointResponse = Envelope<{ + outcome: string; + delivery: WebhookDelivery; +}>; +export type ListWebhookDeliveriesResponse = Envelope<{ + deliveries: WebhookDelivery[]; + nextCursor: string | null; +}>; +export type ReplayWebhookDeliveryResponse = Envelope; +export type DeleteWebhookEndpointResponse = Envelope; + +/** Webhook endpoint and delivery operations for a project. */ +export class WebhooksService { + constructor( + private readonly transport: Transport, + private readonly config: ResolvedConfig + ) {} + + /** Builds the collection route for the resolved project. */ + private basePath(projectId?: string): string { + return `/v1/projects/${resolveProjectId(this.config, projectId)}/webhooks`; + } + + /** Lists every endpoint on the project, newest first. */ + async list(params: ProjectScopedParams = {}): Promise { + return this.transport.request( + "GET", + this.basePath(params.projectId) + ); + } + + /** Fetches one endpoint together with its delivery counts. */ + async get( + endpointId: string, + params: ProjectScopedParams = {} + ): Promise { + if (!endpointId) throw new Error("endpointId is required"); + + return this.transport.request( + "GET", + `${this.basePath(params.projectId)}/${endpointId}` + ); + } + + /** + * Creates an endpoint and returns it with its signing secret. + * + * The secret is returned by this call alone — every later read gives only + * `secretHint`, so persist it before discarding the response. + */ + async create(params: CreateWebhookEndpointParams): Promise { + if (!params.name) throw new Error("name is required"); + if (!params.url) throw new Error("url is required"); + if (!params.events?.length) throw new Error("At least one event is required"); + + return this.transport.request( + "POST", + this.basePath(params.projectId), + { + body: { + name: params.name, + url: params.url, + events: params.events, + description: params.description, + }, + } + ); + } + + /** + * Updates an endpoint's configuration. + * + * Re-enabling a paused endpoint through `enabled: true` also clears its + * failure streak, so one more failure will not immediately re-pause it. + */ + async update( + endpointId: string, + params: UpdateWebhookEndpointParams + ): Promise { + if (!endpointId) throw new Error("endpointId is required"); + if (params.events && params.events.length === 0) { + throw new Error("At least one event is required"); + } + + return this.transport.request( + "PATCH", + `${this.basePath(params.projectId)}/${endpointId}`, + { + body: { + name: params.name, + url: params.url, + events: params.events, + description: params.description, + enabled: params.enabled, + }, + } + ); + } + + /** Deletes an endpoint and its delivery history. */ + async delete( + endpointId: string, + params: ProjectScopedParams = {} + ): Promise { + if (!endpointId) throw new Error("endpointId is required"); + + return this.transport.request( + "DELETE", + `${this.basePath(params.projectId)}/${endpointId}` + ); + } + + /** + * Clears an auto-pause and re-queues the backlog held while it was down. + * + * An endpoint pauses itself after ten consecutive failed deliveries. + */ + async resume( + endpointId: string, + params: ProjectScopedParams = {} + ): Promise { + if (!endpointId) throw new Error("endpointId is required"); + + return this.transport.request( + "POST", + `${this.basePath(params.projectId)}/${endpointId}/resume` + ); + } + + /** + * Issues a new signing secret and returns it in full. + * + * The previous secret stops verifying immediately, so deploy the new one + * before rotating if the receiver cannot tolerate rejected deliveries. + */ + async rotateSecret( + endpointId: string, + params: ProjectScopedParams = {} + ): Promise { + if (!endpointId) throw new Error("endpointId is required"); + + return this.transport.request( + "POST", + `${this.basePath(params.projectId)}/${endpointId}/rotate-secret` + ); + } + + /** + * Sends a synthetic event and reports what the endpoint answered. + * + * Defaults to the endpoint's first subscribed event. Test sends never move + * the failure streak in either direction. + */ + async test( + endpointId: string, + params: ProjectScopedParams & { eventType?: WebhookEventType } = {} + ): Promise { + if (!endpointId) throw new Error("endpointId is required"); + + return this.transport.request( + "POST", + `${this.basePath(params.projectId)}/${endpointId}/test`, + { body: { eventType: params.eventType } } + ); + } + + /** Lists deliveries for an endpoint, newest first, cursor-paginated. */ + async listDeliveries( + endpointId: string, + params: ListDeliveriesParams = {} + ): Promise { + if (!endpointId) throw new Error("endpointId is required"); + + return this.transport.request( + "GET", + `${this.basePath(params.projectId)}/${endpointId}/deliveries`, + { + query: { + status: params.status, + eventType: params.eventType, + limit: params.limit, + cursor: params.cursor, + }, + } + ); + } + + /** + * Queues a fresh delivery carrying the same payload. + * + * The endpoint must be enabled — replaying into a paused endpoint fails + * rather than silently queueing behind the backlog. + */ + async replayDelivery( + endpointId: string, + deliveryId: string, + params: ProjectScopedParams = {} + ): Promise { + if (!endpointId) throw new Error("endpointId is required"); + if (!deliveryId) throw new Error("deliveryId is required"); + + return this.transport.request( + "POST", + `${this.basePath(params.projectId)}/${endpointId}/deliveries/${deliveryId}/replay` + ); + } + + /** + * Reports whether a received request carries a valid signature. + * + * Needs the raw request body: a re-serialized object will not match what + * was signed. + */ + verify(params: VerifyWebhookParams): boolean { + return verifyWebhookSignature(params); + } + + /** + * Verifies a received request and returns its parsed event envelope. + * + * Throws `WebhookSignatureError` when verification fails, so there is no + * payload to act on unless the signature held. + */ + constructEvent(params: VerifyWebhookParams): WebhookEvent { + return constructEvent(params); + } +} diff --git a/javascript/src/webhooks/signature.test.ts b/javascript/src/webhooks/signature.test.ts new file mode 100644 index 0000000..9b8a60e --- /dev/null +++ b/javascript/src/webhooks/signature.test.ts @@ -0,0 +1,211 @@ +import { createHmac } from "node:crypto"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { + constructEvent, + DEFAULT_TOLERANCE_SECONDS, + SIGNATURE_HEADER, + verifyWebhookSignature, + WebhookSignatureError, +} from "../index"; + +const SECRET = "whsec_0123456789abcdef"; +const BODY = JSON.stringify({ + id: "dlv_1", + type: "email.delivered", + createdAt: "2026-08-08T12:00:00.000Z", + data: { messageId: "msg-1" }, +}); + +/** Builds the header the platform would send for a body at a given time. */ +function sign(body: string, secret = SECRET, timestampSeconds = Math.floor(Date.now() / 1000)) { + const v1 = createHmac("sha256", secret).update(`${timestampSeconds}.${body}`).digest("hex"); + return `t=${timestampSeconds},v1=${v1}`; +} + +describe("verifyWebhookSignature", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("accepts a signature produced with the same secret", () => { + expect( + verifyWebhookSignature({ payload: BODY, signature: sign(BODY), secret: SECRET }) + ).toBe(true); + }); + + it("accepts a raw body passed as a Buffer", () => { + expect( + verifyWebhookSignature({ + payload: Buffer.from(BODY, "utf8"), + signature: sign(BODY), + secret: SECRET, + }) + ).toBe(true); + }); + + it("rejects a body modified after signing", () => { + const tampered = BODY.replace("msg-1", "msg-2"); + + expect( + verifyWebhookSignature({ payload: tampered, signature: sign(BODY), secret: SECRET }) + ).toBe(false); + }); + + it("rejects a signature made with a different secret", () => { + expect( + verifyWebhookSignature({ + payload: BODY, + signature: sign(BODY, "whsec_someone_elses_key"), + secret: SECRET, + }) + ).toBe(false); + }); + + it("rejects a signature older than the tolerance window", () => { + const stale = Math.floor(Date.now() / 1000) - (DEFAULT_TOLERANCE_SECONDS + 60); + + expect( + verifyWebhookSignature({ + payload: BODY, + signature: sign(BODY, SECRET, stale), + secret: SECRET, + }) + ).toBe(false); + }); + + it("rejects a timestamp far in the future", () => { + const future = Math.floor(Date.now() / 1000) + (DEFAULT_TOLERANCE_SECONDS + 60); + + expect( + verifyWebhookSignature({ + payload: BODY, + signature: sign(BODY, SECRET, future), + secret: SECRET, + }) + ).toBe(false); + }); + + it("accepts a stale signature when the age check is disabled", () => { + const stale = Math.floor(Date.now() / 1000) - 86_400; + + expect( + verifyWebhookSignature({ + payload: BODY, + signature: sign(BODY, SECRET, stale), + secret: SECRET, + toleranceSeconds: 0, + }) + ).toBe(true); + }); + + it("honours a custom tolerance", () => { + const stale = Math.floor(Date.now() / 1000) - 100; + const params = { payload: BODY, signature: sign(BODY, SECRET, stale), secret: SECRET }; + + expect(verifyWebhookSignature({ ...params, toleranceSeconds: 60 })).toBe(false); + expect(verifyWebhookSignature({ ...params, toleranceSeconds: 300 })).toBe(true); + }); + + it.each([ + ["empty", ""], + ["missing v1", "t=1754654400"], + ["missing timestamp", "v1=abc123"], + ["non-numeric timestamp", "t=not-a-number,v1=abc123"], + ["unstructured", "just-a-hex-digest"], + ])("rejects a %s signature header", (_label, signature) => { + expect(verifyWebhookSignature({ payload: BODY, signature, secret: SECRET })).toBe(false); + }); + + it("rejects a v1 digest of the wrong length without throwing", () => { + // timingSafeEqual throws on length mismatch, so this must be caught before. + expect( + verifyWebhookSignature({ + payload: BODY, + signature: `t=${Math.floor(Date.now() / 1000)},v1=deadbeef`, + secret: SECRET, + }) + ).toBe(false); + }); + + it("rejects an empty secret", () => { + expect(verifyWebhookSignature({ payload: BODY, signature: sign(BODY), secret: "" })).toBe( + false + ); + }); + + it("verifies a payload signed exactly at the tolerance boundary", () => { + const now = 1_754_654_400_000; + vi.useFakeTimers(); + vi.setSystemTime(now); + + const boundary = Math.floor(now / 1000) - DEFAULT_TOLERANCE_SECONDS; + + expect( + verifyWebhookSignature({ + payload: BODY, + signature: sign(BODY, SECRET, boundary), + secret: SECRET, + }) + ).toBe(true); + }); +}); + +describe("constructEvent", () => { + it("returns the parsed envelope for a valid signature", () => { + const event = constructEvent<{ messageId: string }>({ + payload: BODY, + signature: sign(BODY), + secret: SECRET, + }); + + expect(event).toEqual({ + id: "dlv_1", + type: "email.delivered", + createdAt: "2026-08-08T12:00:00.000Z", + data: { messageId: "msg-1" }, + }); + expect(event.data.messageId).toBe("msg-1"); + }); + + it("throws WebhookSignatureError when the signature does not match", () => { + expect(() => + constructEvent({ payload: BODY, signature: sign("something else"), secret: SECRET }) + ).toThrow(WebhookSignatureError); + }); + + it("names the header when it is missing entirely", () => { + expect(() => constructEvent({ payload: BODY, signature: "", secret: SECRET })).toThrow( + SIGNATURE_HEADER + ); + }); + + it("distinguishes a malformed header from a failed comparison", () => { + expect(() => + constructEvent({ payload: BODY, signature: "garbage", secret: SECRET }) + ).toThrow(/Malformed/); + }); + + it("throws when the secret is missing", () => { + expect(() => constructEvent({ payload: BODY, signature: sign(BODY), secret: "" })).toThrow( + "Signing secret is required" + ); + }); + + it("throws when a correctly signed body is not JSON", () => { + const notJson = "this is signed but not json"; + + expect(() => + constructEvent({ payload: notJson, signature: sign(notJson), secret: SECRET }) + ).toThrow("not valid JSON"); + }); + + it("carries the SDK error code", () => { + try { + constructEvent({ payload: BODY, signature: "garbage", secret: SECRET }); + expect.unreachable("should have thrown"); + } catch (err) { + expect((err as WebhookSignatureError).code).toBe("WEBHOOK_SIGNATURE_INVALID"); + } + }); +}); diff --git a/javascript/src/webhooks/signature.ts b/javascript/src/webhooks/signature.ts new file mode 100644 index 0000000..27125e1 --- /dev/null +++ b/javascript/src/webhooks/signature.ts @@ -0,0 +1,134 @@ +/** Verification of the HMAC signature Cosmoner sends with every webhook. */ + +import { createHmac, timingSafeEqual } from "node:crypto"; + +import { WebhookSignatureError } from "../errors"; + +/** Header carrying the timestamp and signature of the request body. */ +export const SIGNATURE_HEADER = "x-cosmoner-signature"; +/** Header carrying the delivery id, so receivers can dedupe replays. */ +export const DELIVERY_ID_HEADER = "x-cosmoner-delivery-id"; +/** Header carrying the event type, for routing without parsing the body. */ +export const EVENT_TYPE_HEADER = "x-cosmoner-event"; + +/** How old a signature may be before it is rejected as a replay. */ +export const DEFAULT_TOLERANCE_SECONDS = 300; + +/** The JSON envelope Cosmoner POSTs to an endpoint. */ +export interface WebhookEvent { + id: string; + type: string; + createdAt: string; + data: T; +} + +/** Arguments shared by `verify` and `constructEvent`. */ +export interface VerifyWebhookParams { + /** + * The raw request body, exactly as received. + * + * Passing a parsed object re-serialized with `JSON.stringify` will not + * verify: key order and whitespace are part of what was signed. + */ + payload: string | Buffer; + /** Value of the `x-cosmoner-signature` header. */ + signature: string; + /** The endpoint's signing secret, as returned when it was created. */ + secret: string; + /** Overrides the 300s replay window. Pass 0 to skip the age check. */ + toleranceSeconds?: number; +} + +/** Parsed form of the signature header. */ +interface ParsedSignature { + timestamp: number; + v1: string; +} + +/** Splits `t=,v1=` into its parts, or null if malformed. */ +function parseSignatureHeader(header: string): ParsedSignature | null { + const parts: Record = {}; + for (const segment of header.split(",")) { + const [key, ...rest] = segment.split("="); + if (key) parts[key.trim()] = rest.join("="); + } + + const timestamp = Number(parts.t); + const v1 = parts.v1; + if (!Number.isFinite(timestamp) || !v1) return null; + + return { timestamp, v1 }; +} + +/** Compares two hex digests without leaking their difference through timing. */ +function constantTimeEquals(a: string, b: string): boolean { + const left = Buffer.from(a, "utf8"); + const right = Buffer.from(b, "utf8"); + // timingSafeEqual throws on a length mismatch rather than returning false. + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} + +/** + * Recomputes the signature for a body and reports whether it matches. + * + * Returns false for every failure mode — malformed header, stale timestamp, + * wrong secret — so callers that only need a yes/no do not have to catch. + * Use `constructEvent` when the reason matters. + */ +export function verifyWebhookSignature(params: VerifyWebhookParams): boolean { + const { payload, signature, secret } = params; + const tolerance = params.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS; + + if (!signature || !secret) return false; + + const parsed = parseSignatureHeader(signature); + if (!parsed) return false; + + if (tolerance > 0) { + const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - parsed.timestamp); + if (ageSeconds > tolerance) return false; + } + + const body = typeof payload === "string" ? payload : payload.toString("utf8"); + const expected = createHmac("sha256", secret) + .update(`${parsed.timestamp}.${body}`) + .digest("hex"); + + return constantTimeEquals(expected, parsed.v1); +} + +/** + * Verifies a webhook and returns its parsed envelope. + * + * Throws `WebhookSignatureError` rather than returning false, so an unverified + * payload cannot be used by accident: there is no value to read unless the + * signature held. + */ +export function constructEvent(params: VerifyWebhookParams): WebhookEvent { + if (!params.signature) { + throw new WebhookSignatureError(`Missing ${SIGNATURE_HEADER} header`); + } + if (!params.secret) { + throw new WebhookSignatureError("Signing secret is required"); + } + if (!parseSignatureHeader(params.signature)) { + throw new WebhookSignatureError( + `Malformed ${SIGNATURE_HEADER} header — expected "t=,v1="` + ); + } + if (!verifyWebhookSignature(params)) { + throw new WebhookSignatureError( + "Webhook signature verification failed — the payload was not signed with this secret, or it is outside the replay window" + ); + } + + const body = + typeof params.payload === "string" ? params.payload : params.payload.toString("utf8"); + + try { + return JSON.parse(body) as WebhookEvent; + } catch { + throw new WebhookSignatureError("Webhook payload is not valid JSON"); + } +} diff --git a/javascript/tsconfig.json b/javascript/tsconfig.json index feb013d..107d25a 100644 --- a/javascript/tsconfig.json +++ b/javascript/tsconfig.json @@ -7,7 +7,8 @@ "strict": true, "esModuleInterop": true, "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src"] } diff --git a/php/src/Cosmoner.php b/php/src/Cosmoner.php index 567a0fa..6b10f65 100644 --- a/php/src/Cosmoner.php +++ b/php/src/Cosmoner.php @@ -20,6 +20,7 @@ class Cosmoner public readonly int $maxRetries; public readonly EmailService $email; + public readonly WebhooksService $webhooks; private readonly Config $config; private readonly Transport $transport; @@ -42,5 +43,6 @@ public function __construct( $this->transport = new Transport($this->config, $httpClient ?? new CurlHttpClient()); $this->email = new EmailService($this->transport, $this->config); + $this->webhooks = new WebhooksService($this->transport, $this->config); } } diff --git a/php/src/WebhookSignature.php b/php/src/WebhookSignature.php new file mode 100644 index 0000000..c6f716e --- /dev/null +++ b/php/src/WebhookSignature.php @@ -0,0 +1,145 @@ + 0) { + $age = abs(time() - $timestamp); + if ($age > $toleranceSeconds) { + return false; + } + } + + $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret); + + return hash_equals($expected, $provided); + } + + /** + * Verifies a received request and returns its decoded event envelope. + * + * Throws rather than returning false, so an unverified payload cannot be + * used by accident: there is no value to read unless the signature held. + * + * @return array The decoded `{id, type, createdAt, data}` envelope. + * + * @throws WebhookSignatureError When the signature is missing, malformed, + * stale, wrong, or the body is not JSON. + */ + public static function constructEvent( + string $payload, + string $signature, + string $secret, + int $toleranceSeconds = self::DEFAULT_TOLERANCE_SECONDS, + ): array { + if ($signature === '') { + throw new WebhookSignatureError('Missing ' . self::SIGNATURE_HEADER . ' header'); + } + if ($secret === '') { + throw new WebhookSignatureError('Signing secret is required'); + } + if (self::parseHeader($signature) === null) { + throw new WebhookSignatureError( + 'Malformed ' . self::SIGNATURE_HEADER . ' header — expected "t=,v1="', + ); + } + if (!self::verify($payload, $signature, $secret, $toleranceSeconds)) { + throw new WebhookSignatureError( + 'Webhook signature verification failed — the payload was not signed with this ' + . 'secret, or it is outside the replay window', + ); + } + + $decoded = json_decode($payload, true); + if (!is_array($decoded)) { + throw new WebhookSignatureError('Webhook payload is not valid JSON'); + } + + /** @var array */ + return $decoded; + } + + /** + * Splits `t=,v1=` into its parts, or null if malformed. + * + * @return array{0: int, 1: string}|null + */ + private static function parseHeader(string $header): ?array + { + $parts = []; + foreach (explode(',', $header) as $segment) { + $pieces = explode('=', $segment, 2); + if (count($pieces) === 2) { + $parts[trim($pieces[0])] = $pieces[1]; + } + } + + $timestamp = $parts['t'] ?? null; + $provided = $parts['v1'] ?? null; + + // A numeric check is required: PHP would otherwise cast "abc" to 0 and + // silently compare against a signature timestamped at the epoch. + if ($timestamp === null || $provided === null || $provided === '') { + return null; + } + if (preg_match('/^-?\d+$/', $timestamp) !== 1) { + return null; + } + + return [(int) $timestamp, $provided]; + } +} diff --git a/php/src/WebhookSignatureError.php b/php/src/WebhookSignatureError.php new file mode 100644 index 0000000..919872f --- /dev/null +++ b/php/src/WebhookSignatureError.php @@ -0,0 +1,20 @@ +>} + * + * @throws CosmonerError On API errors. + */ + public function list(?string $projectId = null): array + { + /** @var array{success: true, data: array>} */ + return $this->transport->request('GET', $this->basePath($projectId)); + } + + /** + * Fetches one endpoint together with its delivery counts. + * + * @return array{success: true, data: array} + * + * @throws CosmonerError On API errors. + * @throws InvalidArgumentException On invalid input. + */ + public function get(string $endpointId, ?string $projectId = null): array + { + self::requireEndpointId($endpointId); + + /** @var array{success: true, data: array} */ + return $this->transport->request('GET', $this->basePath($projectId) . "/{$endpointId}"); + } + + /** + * Creates an endpoint and returns it with its signing secret. + * + * The secret is returned by this call alone — every later read gives only + * `secretHint`, so persist it before discarding the response. + * + * @param string $name Human-readable label. + * @param string $url Must be an HTTPS URL; the API rejects plaintext endpoints. + * @param string[] $events Event types to subscribe to; at least one. + * @param ?string $description Optional free-text note. + * @param ?string $projectId Overrides the client-level default project. + * + * @return array{success: true, data: array} + * + * @throws CosmonerError On API errors. + * @throws InvalidArgumentException On invalid input. + */ + public function create( + string $name, + string $url, + array $events, + ?string $description = null, + ?string $projectId = null, + ): array { + if ($name === '') { + throw new InvalidArgumentException('name is required'); + } + if ($url === '') { + throw new InvalidArgumentException('url is required'); + } + if ($events === []) { + throw new InvalidArgumentException('At least one event is required'); + } + + $payload = ['name' => $name, 'url' => $url, 'events' => array_values($events)]; + if ($description !== null) { + $payload['description'] = $description; + } + + /** @var array{success: true, data: array} */ + return $this->transport->request('POST', $this->basePath($projectId), $payload); + } + + /** + * Updates an endpoint's configuration, sending only the fields given. + * + * Re-enabling a paused endpoint through `$enabled = true` also clears its + * failure streak, so one more failure will not immediately re-pause it. + * Pass `$description = null` to clear the description; leave it out to + * keep the current value. + * + * @param string[]|null $events Event types to subscribe to; at least one when given. + * + * @return array{success: true, data: array} + * + * @throws CosmonerError On API errors. + * @throws InvalidArgumentException On invalid input. + */ + public function update( + string $endpointId, + ?string $name = null, + ?string $url = null, + ?array $events = null, + ?string $description = self::UNSET, + ?bool $enabled = null, + ?string $projectId = null, + ): array { + self::requireEndpointId($endpointId); + if ($events !== null && $events === []) { + throw new InvalidArgumentException('At least one event is required'); + } + + $payload = []; + if ($name !== null) { + $payload['name'] = $name; + } + if ($url !== null) { + $payload['url'] = $url; + } + if ($events !== null) { + $payload['events'] = array_values($events); + } + if ($description !== self::UNSET) { + $payload['description'] = $description; + } + if ($enabled !== null) { + $payload['enabled'] = $enabled; + } + + /** @var array{success: true, data: array} */ + return $this->transport->request( + 'PATCH', + $this->basePath($projectId) . "/{$endpointId}", + // See test(): an empty array would encode as "[]", not "{}". + $payload === [] ? null : $payload, + ); + } + + /** + * Deletes an endpoint and its delivery history. + * + * @return array{success: true, data: null} + * + * @throws CosmonerError On API errors. + * @throws InvalidArgumentException On invalid input. + */ + public function delete(string $endpointId, ?string $projectId = null): array + { + self::requireEndpointId($endpointId); + + /** @var array{success: true, data: null} */ + return $this->transport->request( + 'DELETE', + $this->basePath($projectId) . "/{$endpointId}", + ); + } + + /** + * Clears an auto-pause and re-queues the backlog held while it was down. + * + * An endpoint pauses itself after ten consecutive failed deliveries. + * + * @return array{success: true, data: array} + * + * @throws CosmonerError On API errors. + * @throws InvalidArgumentException On invalid input. + */ + public function resume(string $endpointId, ?string $projectId = null): array + { + self::requireEndpointId($endpointId); + + /** @var array{success: true, data: array} */ + return $this->transport->request( + 'POST', + $this->basePath($projectId) . "/{$endpointId}/resume", + ); + } + + /** + * Issues a new signing secret and returns it in full. + * + * The previous secret stops verifying immediately, so deploy the new one + * before rotating if the receiver cannot tolerate rejected deliveries. + * + * @return array{success: true, data: array{id: string, secret: string}} + * + * @throws CosmonerError On API errors. + * @throws InvalidArgumentException On invalid input. + */ + public function rotateSecret(string $endpointId, ?string $projectId = null): array + { + self::requireEndpointId($endpointId); + + /** @var array{success: true, data: array{id: string, secret: string}} */ + return $this->transport->request( + 'POST', + $this->basePath($projectId) . "/{$endpointId}/rotate-secret", + ); + } + + /** + * Sends a synthetic event and reports what the endpoint answered. + * + * Defaults to the endpoint's first subscribed event. Test sends never move + * the failure streak in either direction. + * + * @return array{success: true, data: array} + * + * @throws CosmonerError On API errors. + * @throws InvalidArgumentException On invalid input. + */ + public function test( + string $endpointId, + ?string $eventType = null, + ?string $projectId = null, + ): array { + self::requireEndpointId($endpointId); + + // Sent as no body rather than an empty array: json_encode([]) emits "[]", + // which the API rejects as an array where it expects an object. + $payload = $eventType !== null ? ['eventType' => $eventType] : null; + + /** @var array{success: true, data: array} */ + return $this->transport->request( + 'POST', + $this->basePath($projectId) . "/{$endpointId}/test", + $payload, + ); + } + + /** + * Lists deliveries for an endpoint, newest first, cursor-paginated. + * + * @param ?string $status One of PENDING, SUCCEEDED, FAILED. + * @param ?int $limit 1–100; the API defaults to 25. + * @param ?string $cursor `nextCursor` from the previous page. + * + * @return array{success: true, data: array{deliveries: array>, nextCursor: ?string}} + * + * @throws CosmonerError On API errors. + * @throws InvalidArgumentException On invalid input. + */ + public function listDeliveries( + string $endpointId, + ?string $status = null, + ?string $eventType = null, + ?int $limit = null, + ?string $cursor = null, + ?string $projectId = null, + ): array { + self::requireEndpointId($endpointId); + + $query = []; + if ($status !== null) { + $query['status'] = $status; + } + if ($eventType !== null) { + $query['eventType'] = $eventType; + } + if ($limit !== null) { + $query['limit'] = $limit; + } + if ($cursor !== null) { + $query['cursor'] = $cursor; + } + + /** @var array{success: true, data: array{deliveries: array>, nextCursor: ?string}} */ + return $this->transport->request( + 'GET', + $this->basePath($projectId) . "/{$endpointId}/deliveries", + null, + $query, + ); + } + + /** + * Queues a fresh delivery carrying the same payload. + * + * The endpoint must be enabled — replaying into a paused endpoint fails + * rather than silently queueing behind the backlog. + * + * @return array{success: true, data: array} + * + * @throws CosmonerError On API errors. + * @throws InvalidArgumentException On invalid input. + */ + public function replayDelivery( + string $endpointId, + string $deliveryId, + ?string $projectId = null, + ): array { + self::requireEndpointId($endpointId); + if ($deliveryId === '') { + throw new InvalidArgumentException('deliveryId is required'); + } + + /** @var array{success: true, data: array} */ + return $this->transport->request( + 'POST', + $this->basePath($projectId) . "/{$endpointId}/deliveries/{$deliveryId}/replay", + ); + } + + /** + * Reports whether a received request carries a valid signature. + * + * Convenience wrapper over {@see WebhookSignature::verify()} for callers + * that already hold a client. Needs the raw request body. + */ + public function verify( + string $payload, + string $signature, + string $secret, + int $toleranceSeconds = WebhookSignature::DEFAULT_TOLERANCE_SECONDS, + ): bool { + return WebhookSignature::verify($payload, $signature, $secret, $toleranceSeconds); + } + + /** + * Verifies a received request and returns its decoded event envelope. + * + * @return array + * + * @throws WebhookSignatureError When verification fails. + */ + public function constructEvent( + string $payload, + string $signature, + string $secret, + int $toleranceSeconds = WebhookSignature::DEFAULT_TOLERANCE_SECONDS, + ): array { + return WebhookSignature::constructEvent($payload, $signature, $secret, $toleranceSeconds); + } + + /** Builds the collection route for the resolved project. */ + private function basePath(?string $projectId): string + { + return '/v1/projects/' . $this->config->resolveProjectId($projectId) . '/webhooks'; + } + + /** Rejects an empty endpoint id before it becomes a malformed route. */ + private static function requireEndpointId(string $endpointId): void + { + if ($endpointId === '') { + throw new InvalidArgumentException('endpointId is required'); + } + } +} diff --git a/php/tests/WebhookSignatureTest.php b/php/tests/WebhookSignatureTest.php new file mode 100644 index 0000000..da01ada --- /dev/null +++ b/php/tests/WebhookSignatureTest.php @@ -0,0 +1,200 @@ +body = (string) json_encode([ + 'id' => 'dlv_1', + 'type' => 'email.delivered', + 'createdAt' => '2026-08-08T12:00:00.000Z', + 'data' => ['messageId' => 'msg-1'], + ]); + } + + /** Build the header the platform would send for a body at a given time. */ + private function sign(string $body, ?string $secret = null, ?int $timestamp = null): string + { + $secret ??= self::SECRET; + $timestamp ??= time(); + $digest = hash_hmac('sha256', $timestamp . '.' . $body, $secret); + + return "t={$timestamp},v1={$digest}"; + } + + public function testAcceptsASignatureFromTheSameSecret(): void + { + $this->assertTrue( + WebhookSignature::verify($this->body, $this->sign($this->body), self::SECRET), + ); + } + + public function testRejectsABodyModifiedAfterSigning(): void + { + $tampered = str_replace('msg-1', 'msg-2', $this->body); + + $this->assertFalse( + WebhookSignature::verify($tampered, $this->sign($this->body), self::SECRET), + ); + } + + public function testRejectsASignatureFromAnotherSecret(): void + { + $signature = $this->sign($this->body, 'whsec_theirs'); + + $this->assertFalse(WebhookSignature::verify($this->body, $signature, self::SECRET)); + } + + public function testRejectsASignatureOlderThanTheTolerance(): void + { + $stale = time() - (WebhookSignature::DEFAULT_TOLERANCE_SECONDS + 60); + $signature = $this->sign($this->body, null, $stale); + + $this->assertFalse(WebhookSignature::verify($this->body, $signature, self::SECRET)); + } + + public function testRejectsATimestampFarInTheFuture(): void + { + $future = time() + (WebhookSignature::DEFAULT_TOLERANCE_SECONDS + 60); + $signature = $this->sign($this->body, null, $future); + + $this->assertFalse(WebhookSignature::verify($this->body, $signature, self::SECRET)); + } + + public function testAcceptsAStaleSignatureWhenTheAgeCheckIsDisabled(): void + { + $stale = time() - 86400; + $signature = $this->sign($this->body, null, $stale); + + $this->assertTrue(WebhookSignature::verify($this->body, $signature, self::SECRET, 0)); + } + + public function testHonoursACustomTolerance(): void + { + $signature = $this->sign($this->body, null, time() - 100); + + $this->assertFalse(WebhookSignature::verify($this->body, $signature, self::SECRET, 60)); + $this->assertTrue(WebhookSignature::verify($this->body, $signature, self::SECRET, 300)); + } + + /** + * @return array + */ + public static function malformedHeaderProvider(): array + { + return [ + 'empty' => [''], + 'missing v1' => ['t=1754654400'], + 'missing timestamp' => ['v1=abc123'], + 'non-numeric timestamp' => ['t=not-a-number,v1=abc123'], + 'unstructured' => ['just-a-digest'], + ]; + } + + #[DataProvider('malformedHeaderProvider')] + public function testRejectsMalformedHeaders(string $signature): void + { + $this->assertFalse(WebhookSignature::verify($this->body, $signature, self::SECRET)); + } + + public function testRejectsADigestOfTheWrongLength(): void + { + $signature = 't=' . time() . ',v1=deadbeef'; + + $this->assertFalse(WebhookSignature::verify($this->body, $signature, self::SECRET)); + } + + public function testRejectsAnEmptySecret(): void + { + $this->assertFalse(WebhookSignature::verify($this->body, $this->sign($this->body), '')); + } + + public function testAcceptsASignatureExactlyAtTheToleranceBoundary(): void + { + $boundary = time() - WebhookSignature::DEFAULT_TOLERANCE_SECONDS; + $signature = $this->sign($this->body, null, $boundary); + + $this->assertTrue(WebhookSignature::verify($this->body, $signature, self::SECRET)); + } + + public function testConstructEventReturnsTheParsedEnvelope(): void + { + $event = WebhookSignature::constructEvent( + $this->body, + $this->sign($this->body), + self::SECRET, + ); + + $this->assertSame('dlv_1', $event['id']); + $this->assertSame('email.delivered', $event['type']); + $this->assertSame(['messageId' => 'msg-1'], $event['data']); + } + + public function testConstructEventThrowsWhenTheSignatureDoesNotMatch(): void + { + $this->expectException(WebhookSignatureError::class); + + WebhookSignature::constructEvent( + $this->body, + $this->sign('something else'), + self::SECRET, + ); + } + + public function testConstructEventNamesTheHeaderWhenItIsMissing(): void + { + $this->expectException(WebhookSignatureError::class); + $this->expectExceptionMessage(WebhookSignature::SIGNATURE_HEADER); + + WebhookSignature::constructEvent($this->body, '', self::SECRET); + } + + public function testConstructEventDistinguishesAMalformedHeader(): void + { + $this->expectException(WebhookSignatureError::class); + $this->expectExceptionMessage('Malformed'); + + WebhookSignature::constructEvent($this->body, 'garbage', self::SECRET); + } + + public function testConstructEventThrowsWhenTheSecretIsMissing(): void + { + $this->expectException(WebhookSignatureError::class); + $this->expectExceptionMessage('Signing secret is required'); + + WebhookSignature::constructEvent($this->body, $this->sign($this->body), ''); + } + + public function testConstructEventThrowsWhenASignedBodyIsNotJson(): void + { + $notJson = 'this is signed but not json'; + + $this->expectException(WebhookSignatureError::class); + $this->expectExceptionMessage('not valid JSON'); + + WebhookSignature::constructEvent($notJson, $this->sign($notJson), self::SECRET); + } + + public function testConstructEventCarriesTheSdkErrorCode(): void + { + try { + WebhookSignature::constructEvent($this->body, 'garbage', self::SECRET); + $this->fail('Expected WebhookSignatureError'); + } catch (WebhookSignatureError $e) { + $this->assertSame('WEBHOOK_SIGNATURE_INVALID', $e->errorCode); + } + } +} diff --git a/php/tests/WebhooksServiceTest.php b/php/tests/WebhooksServiceTest.php new file mode 100644 index 0000000..8c6389e --- /dev/null +++ b/php/tests/WebhooksServiceTest.php @@ -0,0 +1,346 @@ +http = new FakeHttpClient(); + $this->client = new Cosmoner( + 'key-123', + 'proj-1', + 'https://api.test.dev', + 30.0, + 0, + $this->http, + ); + } + + /** + * Decode the JSON body of the recorded request. + * + * @return array + */ + private function sentBody(): array + { + return json_decode((string) $this->http->requests[0]['body'], true); + } + + /** @return array */ + private function endpointFixture(): array + { + return [ + 'id' => 'ep-1', + 'name' => 'Billing receiver', + 'description' => null, + 'url' => 'https://example.com/hooks', + 'events' => ['email.delivered'], + 'enabled' => true, + 'secretHint' => 'cdef', + 'consecutiveFailures' => 0, + 'disabledAt' => null, + 'disabledReason' => null, + 'lastSuccessAt' => null, + 'lastFailureAt' => null, + 'createdAt' => '2026-08-08T12:00:00.000Z', + 'updatedAt' => '2026-08-08T12:00:00.000Z', + ]; + } + + public function testThrowsWhenEndpointIdIsEmptyOnGet(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('endpointId is required'); + + $this->client->webhooks->get(''); + } + + public function testThrowsWhenDeliveryIdIsEmptyOnReplay(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('deliveryId is required'); + + $this->client->webhooks->replayDelivery('ep-1', ''); + } + + public function testThrowsWhenNameIsEmptyOnCreate(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('name is required'); + + $this->client->webhooks->create('', 'https://e.com', ['email.sent']); + } + + public function testThrowsWhenUrlIsEmptyOnCreate(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('url is required'); + + $this->client->webhooks->create('Hooks', '', ['email.sent']); + } + + public function testThrowsWhenNoEventsGivenOnCreate(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one event is required'); + + $this->client->webhooks->create('Hooks', 'https://e.com', []); + } + + public function testThrowsWhenClearingEveryEventOnUpdate(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one event is required'); + + $this->client->webhooks->update('ep-1', null, null, []); + } + + public function testThrowsWhenNoProjectIdIsAvailable(): void + { + $scopeless = new Cosmoner('key-123', null, 'https://api.test.dev', 30.0, 0, $this->http); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('projectId is required'); + + $scopeless->webhooks->list(); + } + + public function testListsEndpoints(): void + { + $this->http->queueJson(200, ['success' => true, 'data' => [$this->endpointFixture()]]); + + $result = $this->client->webhooks->list(); + + $this->assertSame([$this->endpointFixture()], $result['data']); + $this->assertSame('GET', $this->http->requests[0]['method']); + $this->assertSame(self::BASE, $this->http->requests[0]['url']); + } + + public function testFetchesOneEndpointWithStats(): void + { + $stats = ['succeeded' => 12, 'failed' => 1, 'pending' => 0]; + $this->http->queueJson(200, [ + 'success' => true, + 'data' => ['endpoint' => $this->endpointFixture(), 'stats' => $stats], + ]); + + $result = $this->client->webhooks->get('ep-1'); + + $this->assertSame($stats, $result['data']['stats']); + $this->assertSame(self::BASE . '/ep-1', $this->http->requests[0]['url']); + } + + public function testCreatesAnEndpointAndSurfacesTheOneTimeSecret(): void + { + $this->http->queueJson(201, [ + 'success' => true, + 'data' => $this->endpointFixture() + ['secret' => 'whsec_full_value'], + ]); + + $result = $this->client->webhooks->create( + 'Billing receiver', + 'https://example.com/hooks', + ['email.delivered', 'email.bounced'], + 'Receipts', + ); + + $this->assertSame('whsec_full_value', $result['data']['secret']); + $this->assertSame('POST', $this->http->requests[0]['method']); + $this->assertSame([ + 'name' => 'Billing receiver', + 'url' => 'https://example.com/hooks', + 'events' => ['email.delivered', 'email.bounced'], + 'description' => 'Receipts', + ], $this->sentBody()); + } + + public function testOmitsDescriptionWhenNotGiven(): void + { + $this->http->queueJson(201, ['success' => true, 'data' => []]); + + $this->client->webhooks->create('Hooks', 'https://e.com', ['email.sent']); + + $this->assertArrayNotHasKey('description', $this->sentBody()); + } + + public function testUpdatesOnlyTheFieldsGiven(): void + { + $this->http->queueJson(200, ['success' => true, 'data' => $this->endpointFixture()]); + + $this->client->webhooks->update('ep-1', enabled: false); + + $this->assertSame('PATCH', $this->http->requests[0]['method']); + // Untouched fields must not be sent — the API treats present keys as edits. + // Named arguments are required to leave $description at its sentinel. + $this->assertSame(['enabled' => false], $this->sentBody()); + } + + public function testSendsAnExplicitNullToClearTheDescription(): void + { + $this->http->queueJson(200, ['success' => true, 'data' => $this->endpointFixture()]); + + $this->client->webhooks->update('ep-1', description: null); + + $this->assertSame(['description' => null], $this->sentBody()); + } + + public function testDeletesAnEndpoint(): void + { + $this->http->queueJson(200, ['success' => true, 'data' => null]); + + $this->client->webhooks->delete('ep-1'); + + $this->assertSame('DELETE', $this->http->requests[0]['method']); + $this->assertSame(self::BASE . '/ep-1', $this->http->requests[0]['url']); + $this->assertNull($this->http->requests[0]['body']); + } + + public function testResumesAPausedEndpoint(): void + { + $this->http->queueJson(200, ['success' => true, 'data' => $this->endpointFixture()]); + + $this->client->webhooks->resume('ep-1'); + + $this->assertSame('POST', $this->http->requests[0]['method']); + $this->assertSame(self::BASE . '/ep-1/resume', $this->http->requests[0]['url']); + } + + public function testRotatesTheSigningSecret(): void + { + $this->http->queueJson(200, [ + 'success' => true, + 'data' => ['id' => 'ep-1', 'secret' => 'whsec_rotated'], + ]); + + $result = $this->client->webhooks->rotateSecret('ep-1'); + + $this->assertSame('whsec_rotated', $result['data']['secret']); + $this->assertSame(self::BASE . '/ep-1/rotate-secret', $this->http->requests[0]['url']); + } + + public function testSendsATestEventWithAnExplicitType(): void + { + $this->http->queueJson(200, [ + 'success' => true, + 'data' => ['outcome' => 'SUCCEEDED', 'delivery' => []], + ]); + + $result = $this->client->webhooks->test('ep-1', 'email.bounced'); + + $this->assertSame('SUCCEEDED', $result['data']['outcome']); + $this->assertSame(['eventType' => 'email.bounced'], $this->sentBody()); + } + + public function testSendsNoBodyWhenTheTestEventTypeIsOmitted(): void + { + $this->http->queueJson(200, [ + 'success' => true, + 'data' => ['outcome' => 'SUCCEEDED', 'delivery' => []], + ]); + + $this->client->webhooks->test('ep-1'); + + // An empty array would encode as "[]", which the API rejects where it + // expects an object — so no body is sent at all. + $this->assertNull($this->http->requests[0]['body']); + } + + public function testPassesDeliveryFiltersAsQueryParameters(): void + { + $this->http->queueJson(200, [ + 'success' => true, + 'data' => ['deliveries' => [], 'nextCursor' => null], + ]); + + $this->client->webhooks->listDeliveries('ep-1', 'FAILED', 'email.bounced', 50, 'dlv-9'); + + $url = $this->http->requests[0]['url']; + $this->assertStringStartsWith(self::BASE . '/ep-1/deliveries?', $url); + + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + $this->assertSame([ + 'status' => 'FAILED', + 'eventType' => 'email.bounced', + 'limit' => '50', + 'cursor' => 'dlv-9', + ], $query); + } + + public function testOmitsAbsentDeliveryFilters(): void + { + $this->http->queueJson(200, [ + 'success' => true, + 'data' => ['deliveries' => [], 'nextCursor' => null], + ]); + + $this->client->webhooks->listDeliveries('ep-1'); + + $this->assertSame(self::BASE . '/ep-1/deliveries', $this->http->requests[0]['url']); + } + + public function testReplaysADelivery(): void + { + $this->http->queueJson(201, ['success' => true, 'data' => ['id' => 'dlv-2']]); + + $result = $this->client->webhooks->replayDelivery('ep-1', 'dlv-1'); + + $this->assertSame('dlv-2', $result['data']['id']); + $this->assertSame( + self::BASE . '/ep-1/deliveries/dlv-1/replay', + $this->http->requests[0]['url'], + ); + } + + public function testTargetsAnotherProjectPerCall(): void + { + $this->http->queueJson(200, ['success' => true, 'data' => []]); + + $this->client->webhooks->list('proj-2'); + + $this->assertSame( + 'https://api.test.dev/v1/projects/proj-2/webhooks', + $this->http->requests[0]['url'], + ); + } + + public function testMapsA404OntoNotFoundError(): void + { + $this->http->queueJson(404, [ + 'success' => false, + 'error' => ['code' => 'NOT_FOUND', 'message' => 'Webhook endpoint not found'], + ]); + + $this->expectException(NotFoundError::class); + + $this->client->webhooks->get('ep-missing'); + } + + public function testSendsAuthAndIdempotencyHeadersOnWrites(): void + { + $this->http->queueJson(201, ['success' => true, 'data' => []]); + + $this->client->webhooks->create('Hooks', 'https://e.com', ['email.sent']); + + $headers = $this->http->requests[0]['headers']; + $this->assertSame('Bearer key-123', $headers['Authorization']); + $this->assertNotEmpty($headers['Idempotency-Key']); + } + + public function testExposesVerifyOnTheNamespace(): void + { + $this->assertFalse($this->client->webhooks->verify('{}', 'garbage', 'secret')); + } +} diff --git a/python/src/cosmoner/__init__.py b/python/src/cosmoner/__init__.py index 793a297..9a23061 100644 --- a/python/src/cosmoner/__init__.py +++ b/python/src/cosmoner/__init__.py @@ -13,10 +13,26 @@ RateLimitError, ServerError, ValidationError, + WebhookSignatureError, ) +from .webhook_signature import ( + DEFAULT_TOLERANCE_SECONDS, + DELIVERY_ID_HEADER, + EVENT_TYPE_HEADER, + SIGNATURE_HEADER, + construct_event, + verify_webhook_signature, +) +from .webhooks import WEBHOOK_EVENT_TYPES, AsyncWebhooksService, WebhooksService __all__ = [ + "DEFAULT_TOLERANCE_SECONDS", + "DELIVERY_ID_HEADER", + "EVENT_TYPE_HEADER", + "SIGNATURE_HEADER", + "WEBHOOK_EVENT_TYPES", "AsyncCosmoner", + "AsyncWebhooksService", "AuthenticationError", "ConflictError", "Cosmoner", @@ -28,5 +44,9 @@ "RateLimitError", "ServerError", "ValidationError", + "WebhookSignatureError", + "WebhooksService", "__version__", + "construct_event", + "verify_webhook_signature", ] diff --git a/python/src/cosmoner/client.py b/python/src/cosmoner/client.py index 1a8fa4b..735cb81 100644 --- a/python/src/cosmoner/client.py +++ b/python/src/cosmoner/client.py @@ -10,6 +10,7 @@ ) from ._transport import AsyncTransport, Transport from .email import AsyncEmailService, EmailService +from .webhooks import AsyncWebhooksService, WebhooksService class Cosmoner: @@ -41,6 +42,7 @@ def __init__( self._transport = Transport(config) self.email = EmailService(self._transport, config) + self.webhooks = WebhooksService(self._transport, config) def close(self) -> None: """Releases the underlying connection pool.""" @@ -79,6 +81,7 @@ def __init__( self._transport = AsyncTransport(config) self.email = AsyncEmailService(self._transport, config) + self.webhooks = AsyncWebhooksService(self._transport, config) async def aclose(self) -> None: """Releases the underlying connection pool.""" diff --git a/python/src/cosmoner/errors.py b/python/src/cosmoner/errors.py index 90fe461..b800cb4 100644 --- a/python/src/cosmoner/errors.py +++ b/python/src/cosmoner/errors.py @@ -89,6 +89,19 @@ class ServerError(CosmonerError): """5xx — the API failed to handle an otherwise valid request.""" +class WebhookSignatureError(CosmonerError): + """A received webhook could not be verified against its signing secret. + + Unlike the rest of the hierarchy this never comes from an API response — + it is raised locally while checking an inbound request, so it carries no + meaningful HTTP status. + """ + + def __init__(self, message: str) -> None: + """Records the reason verification failed under a fixed error code.""" + super().__init__(0, "WEBHOOK_SIGNATURE_INVALID", message) + + _STATUS_MAP = { 400: ValidationError, 401: AuthenticationError, diff --git a/python/src/cosmoner/webhook_signature.py b/python/src/cosmoner/webhook_signature.py new file mode 100644 index 0000000..2c1821d --- /dev/null +++ b/python/src/cosmoner/webhook_signature.py @@ -0,0 +1,131 @@ +"""Verification of the HMAC signature Cosmoner sends with every webhook.""" + +from __future__ import annotations + +import hmac +import json +import time +from hashlib import sha256 +from typing import Any, NamedTuple + +from .errors import WebhookSignatureError + +#: Header carrying the timestamp and signature of the request body. +SIGNATURE_HEADER = "x-cosmoner-signature" +#: Header carrying the delivery id, so receivers can dedupe replays. +DELIVERY_ID_HEADER = "x-cosmoner-delivery-id" +#: Header carrying the event type, for routing without parsing the body. +EVENT_TYPE_HEADER = "x-cosmoner-event" + +#: How old a signature may be before it is rejected as a replay. +DEFAULT_TOLERANCE_SECONDS = 300 + + +class _ParsedSignature(NamedTuple): + """The two fields carried by the signature header.""" + + timestamp: int + v1: str + + +def _parse_signature_header(header: str) -> _ParsedSignature | None: + """Splits ``t=,v1=`` into its parts, or None if malformed.""" + parts: dict[str, str] = {} + for segment in header.split(","): + key, separator, value = segment.partition("=") + if separator: + parts[key.strip()] = value + + raw_timestamp = parts.get("t") + v1 = parts.get("v1") + if not raw_timestamp or not v1: + return None + + try: + timestamp = int(raw_timestamp) + except ValueError: + return None + + return _ParsedSignature(timestamp, v1) + + +def _expected_digest(body: bytes, secret: str, timestamp: int) -> str: + """Recomputes the hex digest the platform would have sent for this body.""" + signed = str(timestamp).encode("utf-8") + b"." + body + return hmac.new(secret.encode("utf-8"), signed, sha256).hexdigest() + + +def _as_bytes(payload: str | bytes) -> bytes: + """Normalizes a raw body to the bytes that were actually signed.""" + return payload.encode("utf-8") if isinstance(payload, str) else payload + + +def verify_webhook_signature( + payload: str | bytes, + signature: str, + secret: str, + *, + tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS, +) -> bool: + """Recomputes the signature for a body and reports whether it matches. + + ``payload`` must be the raw request body exactly as received — a dict + re-serialized with ``json.dumps`` will not verify, because key order and + whitespace are part of what was signed. + + Returns False for every failure mode (malformed header, stale timestamp, + wrong secret) so callers needing only a yes/no do not have to catch. Use + :func:`construct_event` when the reason matters. Pass + ``tolerance_seconds=0`` to skip the age check. + """ + if not signature or not secret: + return False + + parsed = _parse_signature_header(signature) + if parsed is None: + return False + + if tolerance_seconds > 0: + age = abs(int(time.time()) - parsed.timestamp) + if age > tolerance_seconds: + return False + + expected = _expected_digest(_as_bytes(payload), secret, parsed.timestamp) + return hmac.compare_digest(expected, parsed.v1) + + +def construct_event( + payload: str | bytes, + signature: str, + secret: str, + *, + tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS, +) -> dict[str, Any]: + """Verifies a received request and returns its parsed event envelope. + + Raises :class:`~cosmoner.errors.WebhookSignatureError` rather than + returning False, so an unverified payload cannot be used by accident: + there is no value to read unless the signature held. + """ + if not signature: + raise WebhookSignatureError(f"Missing {SIGNATURE_HEADER} header") + if not secret: + raise WebhookSignatureError("Signing secret is required") + if _parse_signature_header(signature) is None: + raise WebhookSignatureError( + f'Malformed {SIGNATURE_HEADER} header — expected "t=,v1="' + ) + if not verify_webhook_signature( + payload, signature, secret, tolerance_seconds=tolerance_seconds + ): + raise WebhookSignatureError( + "Webhook signature verification failed — the payload was not signed " + "with this secret, or it is outside the replay window" + ) + + try: + event: dict[str, Any] = json.loads(_as_bytes(payload)) + except ValueError as exc: + raise WebhookSignatureError("Webhook payload is not valid JSON") from exc + + return event diff --git a/python/src/cosmoner/webhooks.py b/python/src/cosmoner/webhooks.py new file mode 100644 index 0000000..bbd485f --- /dev/null +++ b/python/src/cosmoner/webhooks.py @@ -0,0 +1,456 @@ +"""Webhooks service namespace — endpoint management and delivery inspection.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from ._config import ClientConfig, resolve_project_id +from ._transport import AsyncTransport, Transport + +#: Every event type an endpoint can subscribe to. +WEBHOOK_EVENT_TYPES = ( + "email.sent", + "email.delivered", + "email.delivery_delayed", + "email.bounced", + "email.complained", + "email.opened", + "email.clicked", + "email.rejected", + "email.rendering_failed", + "email.domain_verified", + "email.sending_paused", + "app.deployed", + "app.failed", + "domain.verified", + "domain.expired", + "server.running", + "server.error", + "member.invited", + "member.joined", +) + +#: Sentinel distinguishing "leave unchanged" from an explicit ``None``. +_UNSET = object() + + +def _create_payload( + name: str, url: str, events: Sequence[str], description: str | None +) -> dict[str, Any]: + """Validates create arguments and shapes them into the API request body.""" + if not name: + raise ValueError("name is required") + if not url: + raise ValueError("url is required") + if not events: + raise ValueError("At least one event is required") + + payload: dict[str, Any] = {"name": name, "url": url, "events": list(events)} + if description is not None: + payload["description"] = description + + return payload + + +def _update_payload( + name: str | None, + url: str | None, + events: Sequence[str] | None, + description: Any, + enabled: bool | None, +) -> dict[str, Any]: + """Shapes update arguments, omitting anything the caller left untouched. + + ``description`` uses a sentinel rather than ``None`` because clearing a + description means sending an explicit null. + """ + if events is not None and not events: + raise ValueError("At least one event is required") + + payload: dict[str, Any] = {} + if name is not None: + payload["name"] = name + if url is not None: + payload["url"] = url + if events is not None: + payload["events"] = list(events) + if description is not _UNSET: + payload["description"] = description + if enabled is not None: + payload["enabled"] = enabled + + return payload + + +def _delivery_params( + status: str | None, event_type: str | None, limit: int | None, cursor: str | None +) -> dict[str, Any]: + """Drops absent delivery filters so they are not sent as empty query values.""" + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + if event_type is not None: + params["eventType"] = event_type + if limit is not None: + params["limit"] = limit + if cursor is not None: + params["cursor"] = cursor + + return params + + +def _require_endpoint_id(endpoint_id: str) -> None: + """Rejects an empty endpoint id before it becomes a malformed route.""" + if not endpoint_id: + raise ValueError("endpoint_id is required") + + +class WebhooksService: + """Synchronous webhook endpoint and delivery operations for a project.""" + + def __init__(self, transport: Transport, config: ClientConfig) -> None: + """Binds the namespace to the client's transport and resolved configuration.""" + self._transport = transport + self._config = config + + def _base_path(self, project_id: str | None) -> str: + """Builds the collection route for the resolved project.""" + return f"/v1/projects/{resolve_project_id(self._config, project_id)}/webhooks" + + def list(self, *, project_id: str | None = None) -> dict[str, Any]: + """Lists every endpoint on the project, newest first.""" + result: dict[str, Any] = self._transport.request( + "GET", self._base_path(project_id) + ) + return result + + def get(self, endpoint_id: str, *, project_id: str | None = None) -> dict[str, Any]: + """Fetches one endpoint together with its delivery counts.""" + _require_endpoint_id(endpoint_id) + + result: dict[str, Any] = self._transport.request( + "GET", f"{self._base_path(project_id)}/{endpoint_id}" + ) + return result + + def create( + self, + name: str, + url: str, + events: Sequence[str], + *, + description: str | None = None, + project_id: str | None = None, + ) -> dict[str, Any]: + """Creates an endpoint and returns it with its signing secret. + + The secret is returned by this call alone — every later read gives only + ``secretHint``, so persist it before discarding the response. + """ + payload = _create_payload(name, url, events, description) + + result: dict[str, Any] = self._transport.request( + "POST", self._base_path(project_id), json=payload + ) + return result + + def update( + self, + endpoint_id: str, + *, + name: str | None = None, + url: str | None = None, + events: Sequence[str] | None = None, + description: str | None = _UNSET, # type: ignore[assignment] + enabled: bool | None = None, + project_id: str | None = None, + ) -> dict[str, Any]: + """Updates an endpoint's configuration. + + Re-enabling a paused endpoint through ``enabled=True`` also clears its + failure streak, so one more failure will not immediately re-pause it. + Pass ``description=None`` to clear the description. + """ + _require_endpoint_id(endpoint_id) + payload = _update_payload(name, url, events, description, enabled) + + result: dict[str, Any] = self._transport.request( + "PATCH", f"{self._base_path(project_id)}/{endpoint_id}", json=payload + ) + return result + + def delete( + self, endpoint_id: str, *, project_id: str | None = None + ) -> dict[str, Any]: + """Deletes an endpoint and its delivery history.""" + _require_endpoint_id(endpoint_id) + + result: dict[str, Any] = self._transport.request( + "DELETE", f"{self._base_path(project_id)}/{endpoint_id}" + ) + return result + + def resume( + self, endpoint_id: str, *, project_id: str | None = None + ) -> dict[str, Any]: + """Clears an auto-pause and re-queues the backlog held while it was down. + + An endpoint pauses itself after ten consecutive failed deliveries. + """ + _require_endpoint_id(endpoint_id) + + result: dict[str, Any] = self._transport.request( + "POST", f"{self._base_path(project_id)}/{endpoint_id}/resume" + ) + return result + + def rotate_secret( + self, endpoint_id: str, *, project_id: str | None = None + ) -> dict[str, Any]: + """Issues a new signing secret and returns it in full. + + The previous secret stops verifying immediately, so deploy the new one + before rotating if the receiver cannot tolerate rejected deliveries. + """ + _require_endpoint_id(endpoint_id) + + result: dict[str, Any] = self._transport.request( + "POST", f"{self._base_path(project_id)}/{endpoint_id}/rotate-secret" + ) + return result + + def test( + self, + endpoint_id: str, + *, + event_type: str | None = None, + project_id: str | None = None, + ) -> dict[str, Any]: + """Sends a synthetic event and reports what the endpoint answered. + + Defaults to the endpoint's first subscribed event. Test sends never + move the failure streak in either direction. + """ + _require_endpoint_id(endpoint_id) + payload = {"eventType": event_type} if event_type is not None else {} + + result: dict[str, Any] = self._transport.request( + "POST", f"{self._base_path(project_id)}/{endpoint_id}/test", json=payload + ) + return result + + def list_deliveries( + self, + endpoint_id: str, + *, + status: str | None = None, + event_type: str | None = None, + limit: int | None = None, + cursor: str | None = None, + project_id: str | None = None, + ) -> dict[str, Any]: + """Lists deliveries for an endpoint, newest first, cursor-paginated.""" + _require_endpoint_id(endpoint_id) + params = _delivery_params(status, event_type, limit, cursor) + + result: dict[str, Any] = self._transport.request( + "GET", + f"{self._base_path(project_id)}/{endpoint_id}/deliveries", + params=params or None, + ) + return result + + def replay_delivery( + self, endpoint_id: str, delivery_id: str, *, project_id: str | None = None + ) -> dict[str, Any]: + """Queues a fresh delivery carrying the same payload. + + The endpoint must be enabled — replaying into a paused endpoint fails + rather than silently queueing behind the backlog. + """ + _require_endpoint_id(endpoint_id) + if not delivery_id: + raise ValueError("delivery_id is required") + + result: dict[str, Any] = self._transport.request( + "POST", + f"{self._base_path(project_id)}/{endpoint_id}/deliveries/{delivery_id}/replay", + ) + return result + + +class AsyncWebhooksService: + """Asynchronous counterpart to :class:`WebhooksService`.""" + + def __init__(self, transport: AsyncTransport, config: ClientConfig) -> None: + """Binds the namespace to the client's transport and resolved configuration.""" + self._transport = transport + self._config = config + + def _base_path(self, project_id: str | None) -> str: + """Builds the collection route for the resolved project.""" + return f"/v1/projects/{resolve_project_id(self._config, project_id)}/webhooks" + + async def list(self, *, project_id: str | None = None) -> dict[str, Any]: + """Lists every endpoint on the project, newest first.""" + result: dict[str, Any] = await self._transport.request( + "GET", self._base_path(project_id) + ) + return result + + async def get( + self, endpoint_id: str, *, project_id: str | None = None + ) -> dict[str, Any]: + """Fetches one endpoint together with its delivery counts.""" + _require_endpoint_id(endpoint_id) + + result: dict[str, Any] = await self._transport.request( + "GET", f"{self._base_path(project_id)}/{endpoint_id}" + ) + return result + + async def create( + self, + name: str, + url: str, + events: Sequence[str], + *, + description: str | None = None, + project_id: str | None = None, + ) -> dict[str, Any]: + """Creates an endpoint and returns it with its signing secret. + + The secret is returned by this call alone — every later read gives only + ``secretHint``, so persist it before discarding the response. + """ + payload = _create_payload(name, url, events, description) + + result: dict[str, Any] = await self._transport.request( + "POST", self._base_path(project_id), json=payload + ) + return result + + async def update( + self, + endpoint_id: str, + *, + name: str | None = None, + url: str | None = None, + events: Sequence[str] | None = None, + description: str | None = _UNSET, # type: ignore[assignment] + enabled: bool | None = None, + project_id: str | None = None, + ) -> dict[str, Any]: + """Updates an endpoint's configuration. + + Re-enabling a paused endpoint through ``enabled=True`` also clears its + failure streak, so one more failure will not immediately re-pause it. + Pass ``description=None`` to clear the description. + """ + _require_endpoint_id(endpoint_id) + payload = _update_payload(name, url, events, description, enabled) + + result: dict[str, Any] = await self._transport.request( + "PATCH", f"{self._base_path(project_id)}/{endpoint_id}", json=payload + ) + return result + + async def delete( + self, endpoint_id: str, *, project_id: str | None = None + ) -> dict[str, Any]: + """Deletes an endpoint and its delivery history.""" + _require_endpoint_id(endpoint_id) + + result: dict[str, Any] = await self._transport.request( + "DELETE", f"{self._base_path(project_id)}/{endpoint_id}" + ) + return result + + async def resume( + self, endpoint_id: str, *, project_id: str | None = None + ) -> dict[str, Any]: + """Clears an auto-pause and re-queues the backlog held while it was down. + + An endpoint pauses itself after ten consecutive failed deliveries. + """ + _require_endpoint_id(endpoint_id) + + result: dict[str, Any] = await self._transport.request( + "POST", f"{self._base_path(project_id)}/{endpoint_id}/resume" + ) + return result + + async def rotate_secret( + self, endpoint_id: str, *, project_id: str | None = None + ) -> dict[str, Any]: + """Issues a new signing secret and returns it in full. + + The previous secret stops verifying immediately, so deploy the new one + before rotating if the receiver cannot tolerate rejected deliveries. + """ + _require_endpoint_id(endpoint_id) + + result: dict[str, Any] = await self._transport.request( + "POST", f"{self._base_path(project_id)}/{endpoint_id}/rotate-secret" + ) + return result + + async def test( + self, + endpoint_id: str, + *, + event_type: str | None = None, + project_id: str | None = None, + ) -> dict[str, Any]: + """Sends a synthetic event and reports what the endpoint answered. + + Defaults to the endpoint's first subscribed event. Test sends never + move the failure streak in either direction. + """ + _require_endpoint_id(endpoint_id) + payload = {"eventType": event_type} if event_type is not None else {} + + result: dict[str, Any] = await self._transport.request( + "POST", f"{self._base_path(project_id)}/{endpoint_id}/test", json=payload + ) + return result + + async def list_deliveries( + self, + endpoint_id: str, + *, + status: str | None = None, + event_type: str | None = None, + limit: int | None = None, + cursor: str | None = None, + project_id: str | None = None, + ) -> dict[str, Any]: + """Lists deliveries for an endpoint, newest first, cursor-paginated.""" + _require_endpoint_id(endpoint_id) + params = _delivery_params(status, event_type, limit, cursor) + + result: dict[str, Any] = await self._transport.request( + "GET", + f"{self._base_path(project_id)}/{endpoint_id}/deliveries", + params=params or None, + ) + return result + + async def replay_delivery( + self, endpoint_id: str, delivery_id: str, *, project_id: str | None = None + ) -> dict[str, Any]: + """Queues a fresh delivery carrying the same payload. + + The endpoint must be enabled — replaying into a paused endpoint fails + rather than silently queueing behind the backlog. + """ + _require_endpoint_id(endpoint_id) + if not delivery_id: + raise ValueError("delivery_id is required") + + result: dict[str, Any] = await self._transport.request( + "POST", + f"{self._base_path(project_id)}/{endpoint_id}/deliveries/{delivery_id}/replay", + ) + return result diff --git a/python/tests/test_webhook_signature.py b/python/tests/test_webhook_signature.py new file mode 100644 index 0000000..e11cb86 --- /dev/null +++ b/python/tests/test_webhook_signature.py @@ -0,0 +1,141 @@ +import hashlib +import hmac +import json +import time + +import pytest + +from cosmoner import ( + DEFAULT_TOLERANCE_SECONDS, + SIGNATURE_HEADER, + WebhookSignatureError, + construct_event, + verify_webhook_signature, +) + +SECRET = "whsec_0123456789abcdef" +BODY = json.dumps( + { + "id": "dlv_1", + "type": "email.delivered", + "createdAt": "2026-08-08T12:00:00.000Z", + "data": {"messageId": "msg-1"}, + } +) + + +def sign(body, secret=SECRET, timestamp=None): + """Build the header the platform would send for a body at a given time.""" + timestamp = int(time.time()) if timestamp is None else timestamp + signed = f"{timestamp}.".encode() + (body.encode() if isinstance(body, str) else body) + digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() + return f"t={timestamp},v1={digest}" + + +class TestVerifyWebhookSignature: + """Tests for the boolean verification helper.""" + + def test_accepts_a_signature_from_the_same_secret(self): + assert verify_webhook_signature(BODY, sign(BODY), SECRET) is True + + def test_accepts_a_raw_body_passed_as_bytes(self): + payload = BODY.encode() + assert verify_webhook_signature(payload, sign(payload), SECRET) is True + + def test_rejects_a_body_modified_after_signing(self): + tampered = BODY.replace("msg-1", "msg-2") + assert verify_webhook_signature(tampered, sign(BODY), SECRET) is False + + def test_rejects_a_signature_from_another_secret(self): + assert verify_webhook_signature(BODY, sign(BODY, "whsec_theirs"), SECRET) is False + + def test_rejects_a_signature_older_than_the_tolerance(self): + stale = int(time.time()) - (DEFAULT_TOLERANCE_SECONDS + 60) + assert verify_webhook_signature(BODY, sign(BODY, SECRET, stale), SECRET) is False + + def test_rejects_a_timestamp_far_in_the_future(self): + future = int(time.time()) + (DEFAULT_TOLERANCE_SECONDS + 60) + assert verify_webhook_signature(BODY, sign(BODY, SECRET, future), SECRET) is False + + def test_accepts_a_stale_signature_when_the_age_check_is_disabled(self): + stale = int(time.time()) - 86_400 + assert ( + verify_webhook_signature( + BODY, sign(BODY, SECRET, stale), SECRET, tolerance_seconds=0 + ) + is True + ) + + def test_honours_a_custom_tolerance(self): + stale = int(time.time()) - 100 + header = sign(BODY, SECRET, stale) + + assert ( + verify_webhook_signature(BODY, header, SECRET, tolerance_seconds=60) is False + ) + assert ( + verify_webhook_signature(BODY, header, SECRET, tolerance_seconds=300) is True + ) + + @pytest.mark.parametrize( + "header", + ["", "t=1754654400", "v1=abc123", "t=not-a-number,v1=abc123", "just-a-digest"], + ids=["empty", "missing-v1", "missing-timestamp", "non-numeric", "unstructured"], + ) + def test_rejects_malformed_headers(self, header): + assert verify_webhook_signature(BODY, header, SECRET) is False + + def test_rejects_a_digest_of_the_wrong_length(self): + header = f"t={int(time.time())},v1=deadbeef" + assert verify_webhook_signature(BODY, header, SECRET) is False + + def test_rejects_an_empty_secret(self): + assert verify_webhook_signature(BODY, sign(BODY), "") is False + + def test_accepts_a_signature_exactly_at_the_tolerance_boundary(self): + boundary = int(time.time()) - DEFAULT_TOLERANCE_SECONDS + assert ( + verify_webhook_signature(BODY, sign(BODY, SECRET, boundary), SECRET) is True + ) + + +class TestConstructEvent: + """Tests for the raising variant that returns the parsed envelope.""" + + def test_returns_the_parsed_envelope(self): + event = construct_event(BODY, sign(BODY), SECRET) + + assert event == { + "id": "dlv_1", + "type": "email.delivered", + "createdAt": "2026-08-08T12:00:00.000Z", + "data": {"messageId": "msg-1"}, + } + + def test_raises_when_the_signature_does_not_match(self): + with pytest.raises(WebhookSignatureError): + construct_event(BODY, sign("something else"), SECRET) + + def test_names_the_header_when_it_is_missing(self): + with pytest.raises(WebhookSignatureError, match=SIGNATURE_HEADER): + construct_event(BODY, "", SECRET) + + def test_distinguishes_a_malformed_header_from_a_failed_comparison(self): + with pytest.raises(WebhookSignatureError, match="Malformed"): + construct_event(BODY, "garbage", SECRET) + + def test_raises_when_the_secret_is_missing(self): + with pytest.raises(WebhookSignatureError, match="Signing secret is required"): + construct_event(BODY, sign(BODY), "") + + def test_raises_when_a_signed_body_is_not_json(self): + not_json = "this is signed but not json" + + with pytest.raises(WebhookSignatureError, match="not valid JSON"): + construct_event(not_json, sign(not_json), SECRET) + + def test_carries_the_sdk_error_code(self): + with pytest.raises(WebhookSignatureError) as excinfo: + construct_event(BODY, "garbage", SECRET) + + assert excinfo.value.code == "WEBHOOK_SIGNATURE_INVALID" diff --git a/python/tests/test_webhooks.py b/python/tests/test_webhooks.py new file mode 100644 index 0000000..0437ba2 --- /dev/null +++ b/python/tests/test_webhooks.py @@ -0,0 +1,352 @@ +import json + +import pytest + +from cosmoner import AsyncCosmoner, Cosmoner, NotFoundError + +BASE = "https://api.test.dev/v1/projects/proj-1/webhooks" + +ENDPOINT = { + "id": "ep-1", + "name": "Billing receiver", + "description": None, + "url": "https://example.com/hooks", + "events": ["email.delivered"], + "enabled": True, + "secretHint": "cdef", + "consecutiveFailures": 0, + "disabledAt": None, + "disabledReason": None, + "lastSuccessAt": None, + "lastFailureAt": None, + "createdAt": "2026-08-08T12:00:00.000Z", + "updatedAt": "2026-08-08T12:00:00.000Z", +} + + +@pytest.fixture() +def client(): + """Provide a Cosmoner client configured for testing, with retries disabled.""" + return Cosmoner( + api_key="key-123", + project_id="proj-1", + base_url="https://api.test.dev", + max_retries=0, + ) + + +class TestWebhookValidation: + """Tests for client-side argument validation.""" + + def test_requires_an_endpoint_id_on_get(self, client): + with pytest.raises(ValueError, match="endpoint_id is required"): + client.webhooks.get("") + + def test_requires_an_endpoint_id_on_delete(self, client): + with pytest.raises(ValueError, match="endpoint_id is required"): + client.webhooks.delete("") + + def test_requires_a_delivery_id_on_replay(self, client): + with pytest.raises(ValueError, match="delivery_id is required"): + client.webhooks.replay_delivery("ep-1", "") + + def test_requires_a_name_on_create(self, client): + with pytest.raises(ValueError, match="name is required"): + client.webhooks.create(name="", url="https://e.com", events=["email.sent"]) + + def test_requires_a_url_on_create(self, client): + with pytest.raises(ValueError, match="url is required"): + client.webhooks.create(name="Hooks", url="", events=["email.sent"]) + + def test_requires_at_least_one_event_on_create(self, client): + with pytest.raises(ValueError, match="At least one event is required"): + client.webhooks.create(name="Hooks", url="https://e.com", events=[]) + + def test_rejects_clearing_every_event_on_update(self, client): + with pytest.raises(ValueError, match="At least one event is required"): + client.webhooks.update("ep-1", events=[]) + + def test_requires_a_project_id_when_the_client_has_no_default(self): + scopeless = Cosmoner(api_key="key-123", max_retries=0) + + with pytest.raises(ValueError, match="project_id is required"): + scopeless.webhooks.list() + + +class TestWebhookEndpoints: + """Tests for endpoint management via mocked HTTP.""" + + def test_lists_endpoints(self, client, httpx_mock): + httpx_mock.add_response(url=BASE, json={"success": True, "data": [ENDPOINT]}) + + result = client.webhooks.list() + + assert result["data"] == [ENDPOINT] + assert httpx_mock.get_request().method == "GET" + + def test_fetches_one_endpoint_with_stats(self, client, httpx_mock): + stats = {"succeeded": 12, "failed": 1, "pending": 0} + httpx_mock.add_response( + url=f"{BASE}/ep-1", + json={"success": True, "data": {"endpoint": ENDPOINT, "stats": stats}}, + ) + + result = client.webhooks.get("ep-1") + + assert result["data"]["stats"] == stats + + def test_creates_an_endpoint_and_surfaces_the_one_time_secret( + self, client, httpx_mock + ): + httpx_mock.add_response( + url=BASE, + status_code=201, + json={"success": True, "data": {**ENDPOINT, "secret": "whsec_full_value"}}, + ) + + result = client.webhooks.create( + name="Billing receiver", + url="https://example.com/hooks", + events=["email.delivered", "email.bounced"], + description="Receipts", + ) + + assert result["data"]["secret"] == "whsec_full_value" + + request = httpx_mock.get_request() + assert request.method == "POST" + assert json.loads(request.content) == { + "name": "Billing receiver", + "url": "https://example.com/hooks", + "events": ["email.delivered", "email.bounced"], + "description": "Receipts", + } + + def test_omits_description_when_not_given(self, client, httpx_mock): + httpx_mock.add_response( + url=BASE, status_code=201, json={"success": True, "data": {}} + ) + + client.webhooks.create(name="Hooks", url="https://e.com", events=["email.sent"]) + + assert "description" not in json.loads(httpx_mock.get_request().content) + + def test_updates_only_the_fields_given(self, client, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-1", json={"success": True, "data": ENDPOINT} + ) + + client.webhooks.update("ep-1", enabled=False) + + request = httpx_mock.get_request() + assert request.method == "PATCH" + # Untouched fields must not be sent — the API treats present keys as edits. + assert json.loads(request.content) == {"enabled": False} + + def test_sends_an_explicit_null_to_clear_the_description(self, client, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-1", json={"success": True, "data": ENDPOINT} + ) + + client.webhooks.update("ep-1", description=None) + + assert json.loads(httpx_mock.get_request().content) == {"description": None} + + def test_deletes_an_endpoint(self, client, httpx_mock): + httpx_mock.add_response(url=f"{BASE}/ep-1", json={"success": True, "data": None}) + + result = client.webhooks.delete("ep-1") + + assert result["data"] is None + assert httpx_mock.get_request().method == "DELETE" + + def test_resumes_a_paused_endpoint(self, client, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-1/resume", json={"success": True, "data": ENDPOINT} + ) + + client.webhooks.resume("ep-1") + + assert httpx_mock.get_request().method == "POST" + + def test_rotates_the_signing_secret(self, client, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-1/rotate-secret", + json={"success": True, "data": {"id": "ep-1", "secret": "whsec_rotated"}}, + ) + + result = client.webhooks.rotate_secret("ep-1") + + assert result["data"]["secret"] == "whsec_rotated" + + def test_targets_another_project_per_call(self, client, httpx_mock): + httpx_mock.add_response( + url="https://api.test.dev/v1/projects/proj-2/webhooks", + json={"success": True, "data": []}, + ) + + client.webhooks.list(project_id="proj-2") + + assert "proj-2" in str(httpx_mock.get_request().url) + + def test_maps_a_404_onto_not_found_error(self, client, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-missing", + status_code=404, + json={ + "success": False, + "error": {"code": "NOT_FOUND", "message": "Webhook endpoint not found"}, + }, + ) + + with pytest.raises(NotFoundError): + client.webhooks.get("ep-missing") + + def test_sends_auth_and_idempotency_headers_on_writes(self, client, httpx_mock): + httpx_mock.add_response( + url=BASE, status_code=201, json={"success": True, "data": {}} + ) + + client.webhooks.create(name="Hooks", url="https://e.com", events=["email.sent"]) + + request = httpx_mock.get_request() + assert request.headers["authorization"] == "Bearer key-123" + assert request.headers["idempotency-key"] + + +class TestWebhookTestSend: + """Tests for the synthetic test-send endpoint.""" + + def test_sends_an_explicit_event_type(self, client, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-1/test", + json={"success": True, "data": {"outcome": "SUCCEEDED", "delivery": {}}}, + ) + + result = client.webhooks.test("ep-1", event_type="email.bounced") + + assert result["data"]["outcome"] == "SUCCEEDED" + assert json.loads(httpx_mock.get_request().content) == { + "eventType": "email.bounced" + } + + def test_lets_the_server_pick_the_event_type(self, client, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-1/test", + json={"success": True, "data": {"outcome": "SUCCEEDED", "delivery": {}}}, + ) + + client.webhooks.test("ep-1") + + assert json.loads(httpx_mock.get_request().content) == {} + + +class TestWebhookDeliveries: + """Tests for the delivery log and replay.""" + + def test_passes_filters_through_as_query_parameters(self, client, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-1/deliveries?status=FAILED&eventType=email.bounced&limit=50&cursor=dlv-9", + json={"success": True, "data": {"deliveries": [], "nextCursor": None}}, + ) + + client.webhooks.list_deliveries( + "ep-1", + status="FAILED", + event_type="email.bounced", + limit=50, + cursor="dlv-9", + ) + + params = httpx_mock.get_request().url.params + assert dict(params) == { + "status": "FAILED", + "eventType": "email.bounced", + "limit": "50", + "cursor": "dlv-9", + } + + def test_omits_absent_filters(self, client, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-1/deliveries", + json={"success": True, "data": {"deliveries": [], "nextCursor": None}}, + ) + + client.webhooks.list_deliveries("ep-1") + + assert str(httpx_mock.get_request().url) == f"{BASE}/ep-1/deliveries" + + def test_replays_a_delivery(self, client, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-1/deliveries/dlv-1/replay", + status_code=201, + json={"success": True, "data": {"id": "dlv-2"}}, + ) + + result = client.webhooks.replay_delivery("ep-1", "dlv-1") + + assert result["data"]["id"] == "dlv-2" + assert httpx_mock.get_request().method == "POST" + + +class TestAsyncWebhooks: + """Tests for the async webhooks namespace.""" + + async def test_lists_endpoints(self, httpx_mock): + httpx_mock.add_response(url=BASE, json={"success": True, "data": [ENDPOINT]}) + + async with AsyncCosmoner( + api_key="key-123", + project_id="proj-1", + base_url="https://api.test.dev", + max_retries=0, + ) as client: + result = await client.webhooks.list() + + assert result["data"] == [ENDPOINT] + + async def test_creates_an_endpoint(self, httpx_mock): + httpx_mock.add_response( + url=BASE, + status_code=201, + json={"success": True, "data": {**ENDPOINT, "secret": "whsec_async"}}, + ) + + async with AsyncCosmoner( + api_key="key-123", + project_id="proj-1", + base_url="https://api.test.dev", + max_retries=0, + ) as client: + result = await client.webhooks.create( + name="Hooks", url="https://e.com", events=["email.sent"] + ) + + assert result["data"]["secret"] == "whsec_async" + + async def test_validates_arguments_before_any_request(self, httpx_mock): + async with AsyncCosmoner( + api_key="key-123", + project_id="proj-1", + base_url="https://api.test.dev", + max_retries=0, + ) as client: + with pytest.raises(ValueError, match="endpoint_id is required"): + await client.webhooks.get("") + + async def test_replays_a_delivery(self, httpx_mock): + httpx_mock.add_response( + url=f"{BASE}/ep-1/deliveries/dlv-1/replay", + status_code=201, + json={"success": True, "data": {"id": "dlv-2"}}, + ) + + async with AsyncCosmoner( + api_key="key-123", + project_id="proj-1", + base_url="https://api.test.dev", + max_retries=0, + ) as client: + result = await client.webhooks.replay_delivery("ep-1", "dlv-1") + + assert result["data"]["id"] == "dlv-2"