From 293641664a038ddd9db1ef337852b7b398fa6b07 Mon Sep 17 00:00:00 2001 From: "yuechen.wyh" <300878504+moonyue-w@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:18:17 +0800 Subject: [PATCH 1/2] feat(forward): support webhook subscriptions and signed deliveries --- CHANGELOG.md | 2 + README.md | 4 + docs/forward-api-coverage.md | 4 + docs/webhooks.md | 104 ++++++ examples/README.md | 2 + .../cloudagents/examples/WebhooksExample.java | 24 ++ .../examples/WebhooksConsumerTest.java | 49 +++ .../qoder/cloudagents/AsyncForwardClient.java | 7 + .../com/qoder/cloudagents/ForwardClient.java | 7 + .../forward/AsyncWebhookEndpoints.java | 247 ++++++++++++ .../cloudagents/forward/AsyncWebhooks.java | 15 + .../cloudagents/forward/WebhookEndpoint.java | 59 +++ .../forward/WebhookEndpointCreateParams.java | 60 +++ .../WebhookEndpointCreateResponse.java | 44 +++ .../forward/WebhookEndpointListParams.java | 43 +++ .../forward/WebhookEndpointListResponse.java | 35 ++ .../forward/WebhookEndpointTestResponse.java | 19 + .../forward/WebhookEndpointUpdateParams.java | 67 ++++ .../cloudagents/forward/WebhookEndpoints.java | 172 +++++++++ .../forward/WebhookSignatures.java | 82 ++++ .../forward/WebhookVerificationException.java | 8 + .../qoder/cloudagents/forward/Webhooks.java | 13 + .../com/qoder/cloudagents/WebhooksTest.java | 353 ++++++++++++++++++ .../forward/WebhookSignaturesTest.java | 56 +++ src/test/resources/webhook-signature.json | 7 + 25 files changed, 1483 insertions(+) create mode 100644 docs/webhooks.md create mode 100644 examples/src/main/java/com/qoder/cloudagents/examples/WebhooksExample.java create mode 100644 examples/src/test/java/com/qoder/cloudagents/examples/WebhooksConsumerTest.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/AsyncWebhookEndpoints.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/AsyncWebhooks.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/WebhookEndpoint.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/WebhookEndpointCreateParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/WebhookEndpointCreateResponse.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/WebhookEndpointListParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/WebhookEndpointListResponse.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/WebhookEndpointTestResponse.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/WebhookEndpointUpdateParams.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/WebhookEndpoints.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/WebhookSignatures.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/WebhookVerificationException.java create mode 100644 src/main/java/com/qoder/cloudagents/forward/Webhooks.java create mode 100644 src/test/java/com/qoder/cloudagents/WebhooksTest.java create mode 100644 src/test/java/com/qoder/cloudagents/forward/WebhookSignaturesTest.java create mode 100644 src/test/resources/webhook-signature.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f097712..b35bee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Add Forward Webhook endpoint creation, listing, retrieval, partial update, deletion, enable/disable and synthetic test delivery. +- Add raw-body webhook signature verification with HMAC-SHA256, multiple v1 signatures and a 300-second timestamp window; include receiver examples and offline contracts. - Public Java 8 HTTP transport and per-attempt interceptor contracts for custom networking and tracing, without exposing OkHttp types. Cancellation reaches downstream transport futures; SSE bodies remain streaming. Storage downloads use the transport without API credentials or interceptors. WebSockets and SA Key exchange retain their existing transports. - Synchronous/asynchronous `request(ApiRequest, RequestOptions)` for additional JSON endpoints, reusing authentication, retries, cancellation and response metadata with relative path segments. - Synchronous/asynchronous `withOptions(...)` to derive independently closeable clients while retaining resolved authentication and unspecified settings. Caller-supplied credentials and transports remain caller-owned. diff --git a/README.md b/README.md index 7a3406b..fdc8eae 100644 --- a/README.md +++ b/README.md @@ -249,3 +249,7 @@ Pin the SDK version in your build. Before 1.0, incompatible API changes use a ne ## License [MIT](LICENSE). + +## Forward Webhooks (next release) + +Manage webhook endpoints and verify signed event deliveries. See [Webhook usage and receiver examples](docs/webhooks.md). diff --git a/docs/forward-api-coverage.md b/docs/forward-api-coverage.md index b5029ac..fe067ef 100644 --- a/docs/forward-api-coverage.md +++ b/docs/forward-api-coverage.md @@ -193,3 +193,7 @@ HTTP BaseURL 为 CN `https://api.qoder.com.cn/api/v1/forward` 或 Global `https: | POST | `/channel_pairings/{pairing_id}` | `channelPairings().update(...)` | 暂不增加,当前范围外 | 后续若决定纳入,再重新核对参数、权限和分页并更新总基线;不能将“完成配对”视为已实现以上三个管理操作。 + +## Next release: Webhooks + +Eight Webhook endpoint operations extend the original 123-operation baseline to **131 HTTP operations**. Sync/async methods, request metadata, cursor pagination and raw-body signature verification are implemented. See [Webhook guide](webhooks.md) for paths, authentication and validation boundaries. This is separate from the three deferred Pairing operations. diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..363bdc5 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,104 @@ +# Forward Webhooks + +Available in the next SDK release. Configure account notifications with a PAT or +administrator SAT. Identity SATs cannot manage webhook endpoints. Use the same +regional Forward client and shared authentication configuration as other APIs. + +## Endpoint operations + +All paths are relative to `/api/v1/forward`. + +| Method | Path | Result | +| --- | --- | --- | +| POST | `/webhook/endpoints` | Create; HTTP 201, includes `signing_secret` once | +| GET | `/webhook/endpoints` | List; `limit` and `after_id`, preserving server order | +| GET | `/webhook/endpoints/{endpoint_id}` | Retrieve | +| PUT | `/webhook/endpoints/{endpoint_id}` | Partial update | +| DELETE | `/webhook/endpoints/{endpoint_id}` | Delete; HTTP 204, no response body | +| POST | `/webhook/endpoints/{endpoint_id}/enable` | Enable | +| POST | `/webhook/endpoints/{endpoint_id}/disable` | Disable | +| POST | `/webhook/endpoints/{endpoint_id}/test` | HTTP 202, `event_id` and `delivery_rows` | + +- `events` accepts full event names or `*`. Strings remain open for future event + types; the SDK does not maintain a closed event enumeration. The current official + catalog includes `forward.schedule.archived`. +- Omitted update fields are unchanged. `active: false` disables an endpoint. + `events` replaces the subscription list. `metadata` merges keys; a null entry + deletes that key. Create metadata values are strings. +- Securely store the create response's `signing_secret` before leaving the creation + flow. List/retrieve/update cannot recover it. Do not log the create response. +- `test` only queues a synthetic event; 202 and a positive `delivery_rows` do not + confirm receipt. A disabled endpoint may return zero rows. Verify actual delivery + at your receiver. The SDK does not poll delivery state or automatically replay + a test POST without an idempotency key. + +## Manage subscriptions + +The following uses an already configured `client` and a callback URL that you +control. Only call `test` when that receiver is ready. Use a unique idempotency key +for creation, and reuse it only for the same request. + +```java +WebhookEndpoints endpoints = client.webhooks().endpoints(); +WebhookEndpointCreateResponse created = endpoints.create( + WebhookEndpointCreateParams.builder().url(callbackUrl) + .events(Collections.singletonList("forward.schedule.archived")).build(), + RequestOptions.builder().idempotencyKey(createKey).build()); +// Persist created.signingSecret() securely; never log the response. +endpoints.retrieve(created.id()); +Map patch = new HashMap<>(); +patch.put("old_key", null); +endpoints.update(created.id(), WebhookEndpointUpdateParams.builder().active(false).metadata(patch).build()); +endpoints.enable(created.id()); +WebhookEndpointTestResponse accepted = endpoints.test(created.id()); +for (WebhookEndpoint endpoint : endpoints.list()) System.out.println(endpoint.id()); +endpoints.disable(created.id()); +endpoints.delete(created.id()); +``` + +Import `com.qoder.cloudagents.forward.*`, `com.qoder.cloudagents.core.RequestOptions` +and `java.util.*`. `client.async().webhooks().endpoints()` provides cancellable +`CompletableFuture` operations. `.withResponse()` retains status, headers and request IDs. + +## Verify deliveries + +See [the runnable example](../examples/src/main/java/com/qoder/cloudagents/examples/WebhooksExample.java): + +```java +WebhookSignatures.verify(rawBody, requestHeaders, signingSecret); +JsonValue event = JsonValue.parse(new String(rawBody, StandardCharsets.UTF_8)); +``` + +Use `com.qoder.cloudagents.forward.WebhookSignatures`. Verification needs no +HTTP client and uses Java 8 cryptography APIs. + +Pass the **original HTTP request body bytes**, all request headers, and the stored +`whsec_...` secret. Verification is local and makes no API call. It checks +`Webhook-ID`, `Webhook-Timestamp` and `Webhook-Signature`, decodes the secret with +standard Base64, and verifies HMAC-SHA256 over `id.timestamp.raw_body`. It accepts +any matching `v1` signature in the whitespace-separated header, checks a fixed +300-second past/future window, and rejects malformed or mismatched input with a +webhook verification error. Keep the receiver clock synchronized. + +Parse JSON only after verification; do not reserialize it before checking the +signature. Preserve unknown event types and fields. Ordinary events contain +`data.type`; the synthetic test event instead has `endpoint_id`, `emitted_at` and +`note`, with no required `data`. The `Webhook-Event-Type` header is not part of the +signed message; use the verified payload for business decisions. + +In a real HTTP handler, reject verification failures, bound the body size, store +accepted events durably, and deduplicate using `Webhook-ID` before acknowledging +with 2xx. Retries share the same ID. The SDK does not supply a queue or a deduplication +database. The examples only parse an authenticated delivery and list endpoints; +they do not start a production receiver or create subscriptions automatically. + +## Validation boundary + +Offline tests cover all eight HTTP contracts, errors, cursor paging, omitted/null/ +false updates, empty bodies, signature vectors, tampering, expired/future timestamps, +multiple signature candidates and unknown fields. Live CN/Global callback delivery +requires a reachable receiver controlled by the application team and is not implied +by these tests. + +References: [API overview](https://docs.qoder.com/zh/cloud-agents/api/webhooks/overview), +[receiving events](https://docs.qoder.com/zh/cloud-agents/api/webhooks/receive). diff --git a/examples/README.md b/examples/README.md index 996b50e..93517a6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -242,3 +242,5 @@ Default verification uses mock services. The opt-in [live runner](../docs/live-t ## Repeatable live acceptance See [the opt-in live runner](../docs/live-testing.md) for the `read`, `resources`, `session`, `realtime-text`, `realtime-pcm`, `schedule`, `batch`, `usage`, `sa-tokens` and `realtime-work` groups. The dedicated Schedule/Batch checks wait for completion and validate output and Session history; the smaller lifecycle examples above intentionally return earlier. This runner consumes the installed JAR and is never launched by the default Maven test lifecycle. + +Forward Webhook listing and receiver verification: see [Webhook guide](../docs/webhooks.md). The example does not create subscriptions or emit test deliveries. diff --git a/examples/src/main/java/com/qoder/cloudagents/examples/WebhooksExample.java b/examples/src/main/java/com/qoder/cloudagents/examples/WebhooksExample.java new file mode 100644 index 0000000..2338636 --- /dev/null +++ b/examples/src/main/java/com/qoder/cloudagents/examples/WebhooksExample.java @@ -0,0 +1,24 @@ +package com.qoder.cloudagents.examples; + +import com.qoder.cloudagents.core.JsonValue; +import com.qoder.cloudagents.forward.*; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** Lists endpoints. parseDelivery is the framework-independent receiver integration point. */ +public final class WebhooksExample { + private WebhooksExample() {} + + public static JsonValue parseDelivery( + byte[] rawBody, Map headers, String secret) { + WebhookSignatures.verify(rawBody, headers, secret); + return JsonValue.parse(new String(rawBody, StandardCharsets.UTF_8)); + } + + public static void main(String[] args) { + try (ExampleClient setup = ExampleClient.fromEnvironment()) { + for (WebhookEndpoint endpoint : setup.client().webhooks().endpoints().list()) + System.out.println("endpoint=" + endpoint.id() + " active=" + endpoint.active()); + } + } +} diff --git a/examples/src/test/java/com/qoder/cloudagents/examples/WebhooksConsumerTest.java b/examples/src/test/java/com/qoder/cloudagents/examples/WebhooksConsumerTest.java new file mode 100644 index 0000000..a3e6c31 --- /dev/null +++ b/examples/src/test/java/com/qoder/cloudagents/examples/WebhooksConsumerTest.java @@ -0,0 +1,49 @@ +package com.qoder.cloudagents.examples; + +import static org.junit.jupiter.api.Assertions.*; + +import com.qoder.cloudagents.ForwardClient; +import com.qoder.cloudagents.core.JsonValue; +import com.qoder.cloudagents.forward.WebhookVerificationException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; + +class WebhooksConsumerTest { + @Test + void receiverUsesPackagedSdkAndPreservesPayload() throws Exception { + assertTrue( + ForwardClient.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .getPath() + .endsWith(".jar")); + byte[] key = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8); + String secret = "whsec_" + Base64.getEncoder().encodeToString(key); + for (String json : + Arrays.asList( + "{\"type\":\"event\",\"data\":{\"type\":\"future.event\",\"extra\":42}}", + "{\"type\":\"event\",\"endpoint_id\":\"id\",\"note\":\"synthetic\"}")) { + byte[] body = json.getBytes(StandardCharsets.UTF_8); + String timestamp = Long.toString(Instant.now().getEpochSecond()); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + mac.update(("whe_test." + timestamp + ".").getBytes(StandardCharsets.UTF_8)); + Map headers = new HashMap<>(); + headers.put("Webhook-ID", "whe_test"); + headers.put("Webhook-Timestamp", timestamp); + headers.put( + "Webhook-Signature", "v1," + Base64.getEncoder().encodeToString(mac.doFinal(body))); + JsonValue result = WebhooksExample.parseDelivery(body, headers, secret); + assertEquals(json, result.toJson()); + assertThrows( + WebhookVerificationException.class, + () -> + WebhooksExample.parseDelivery(Arrays.copyOf(body, body.length + 1), headers, secret)); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/AsyncForwardClient.java b/src/main/java/com/qoder/cloudagents/AsyncForwardClient.java index e3df025..7b26947 100644 --- a/src/main/java/com/qoder/cloudagents/AsyncForwardClient.java +++ b/src/main/java/com/qoder/cloudagents/AsyncForwardClient.java @@ -20,6 +20,7 @@ import com.qoder.cloudagents.forward.AsyncTemplates; import com.qoder.cloudagents.forward.AsyncUsage; import com.qoder.cloudagents.forward.AsyncVaults; +import com.qoder.cloudagents.forward.AsyncWebhooks; import com.qoder.cloudagents.forward.realtime.AsyncRealtime; import java.util.Collections; import java.util.concurrent.CompletableFuture; @@ -27,6 +28,7 @@ /** Native asynchronous Forward client. Build with ForwardClient.builder().buildAsync(). */ public final class AsyncForwardClient implements AutoCloseable { + private final AsyncWebhooks webhooks; private final AsyncUsage usage; private final AsyncServiceAccountTokens serviceAccountTokens; private final AsyncDrives drives; @@ -56,6 +58,7 @@ public final class AsyncForwardClient implements AutoCloseable { drives = new AsyncDrives(engine); serviceAccountTokens = new AsyncServiceAccountTokens(engine); usage = new AsyncUsage(engine); + webhooks = new AsyncWebhooks(engine); this.ownsEngine = ownsEngine; models = new AsyncModels(engine); files = new AsyncFiles(engine); @@ -73,6 +76,10 @@ public final class AsyncForwardClient implements AutoCloseable { memoryStores = new AsyncMemoryStores(engine); } + public AsyncWebhooks webhooks() { + return webhooks; + } + public AsyncRealtime realtime() { return realtime; } diff --git a/src/main/java/com/qoder/cloudagents/ForwardClient.java b/src/main/java/com/qoder/cloudagents/ForwardClient.java index f434ee9..7f9f667 100644 --- a/src/main/java/com/qoder/cloudagents/ForwardClient.java +++ b/src/main/java/com/qoder/cloudagents/ForwardClient.java @@ -29,6 +29,7 @@ import com.qoder.cloudagents.forward.Templates; import com.qoder.cloudagents.forward.Usage; import com.qoder.cloudagents.forward.Vaults; +import com.qoder.cloudagents.forward.Webhooks; import com.qoder.cloudagents.forward.realtime.Realtime; import java.time.Duration; import java.util.ArrayList; @@ -42,6 +43,7 @@ * Thread-safe Forward client. Close it after its synchronous and asynchronous views are finished. */ public final class ForwardClient implements AutoCloseable { + private final Webhooks webhooks; private final Usage usage; private final ServiceAccountTokens serviceAccountTokens; private final Drives drives; @@ -73,6 +75,7 @@ private ForwardClient(ClientEngine engine, Builder configuration) { drives = new Drives(async.drives()); serviceAccountTokens = new ServiceAccountTokens(async.serviceAccountTokens()); usage = new Usage(async.usage()); + webhooks = new Webhooks(async.webhooks()); files = new Files(async.files()); channelPairings = new ChannelPairings(async.channelPairings()); channels = new Channels(async.channels()); @@ -88,6 +91,10 @@ private ForwardClient(ClientEngine engine, Builder configuration) { memoryStores = new MemoryStores(async.memoryStores()); } + public Webhooks webhooks() { + return webhooks; + } + public Realtime realtime() { return realtime; } diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncWebhookEndpoints.java b/src/main/java/com/qoder/cloudagents/forward/AsyncWebhookEndpoints.java new file mode 100644 index 0000000..e435c9c --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncWebhookEndpoints.java @@ -0,0 +1,247 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.core.internal.*; +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +/** Webhook management using a PAT or administrator SAT. */ +public final class AsyncWebhookEndpoints { + private final ClientEngine engine; + + public AsyncWebhookEndpoints(ClientEngine engine) { + this.engine = engine; + } + + public CompletableFuture create( + WebhookEndpointCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public CompletableFuture create( + WebhookEndpointCreateParams params, RequestOptions options) { + return Futures.map(withResponse().create(params, options), ApiResponse::data); + } + + public CompletableFuture list() { + return list(WebhookEndpointListParams.builder().build()); + } + + public CompletableFuture list(RequestOptions options) { + return list(WebhookEndpointListParams.builder().build(), options); + } + + public CompletableFuture list(WebhookEndpointListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public CompletableFuture list( + WebhookEndpointListParams params, RequestOptions options) { + return Futures.map(withResponse().list(params, options), ApiResponse::data); + } + + public CompletableFuture retrieve(String endpointId) { + return retrieve(endpointId, RequestOptions.DEFAULT); + } + + public CompletableFuture retrieve(String endpointId, RequestOptions options) { + return Futures.map(withResponse().retrieve(endpointId, options), ApiResponse::data); + } + + public CompletableFuture update( + String endpointId, WebhookEndpointUpdateParams params) { + return update(endpointId, params, RequestOptions.DEFAULT); + } + + public CompletableFuture update( + String endpointId, WebhookEndpointUpdateParams params, RequestOptions options) { + return Futures.map(withResponse().update(endpointId, params, options), ApiResponse::data); + } + + public CompletableFuture delete(String endpointId) { + return delete(endpointId, RequestOptions.DEFAULT); + } + + public CompletableFuture delete(String endpointId, RequestOptions options) { + return Futures.map(withResponse().delete(endpointId, options), ApiResponse::data); + } + + public CompletableFuture enable(String endpointId) { + return enable(endpointId, RequestOptions.DEFAULT); + } + + public CompletableFuture enable(String endpointId, RequestOptions options) { + return Futures.map(withResponse().enable(endpointId, options), ApiResponse::data); + } + + public CompletableFuture disable(String endpointId) { + return disable(endpointId, RequestOptions.DEFAULT); + } + + public CompletableFuture disable(String endpointId, RequestOptions options) { + return Futures.map(withResponse().disable(endpointId, options), ApiResponse::data); + } + + public CompletableFuture test(String endpointId) { + return test(endpointId, RequestOptions.DEFAULT); + } + + public CompletableFuture test( + String endpointId, RequestOptions options) { + return Futures.map(withResponse().test(endpointId, options), ApiResponse::data); + } + + public WithResponse withResponse() { + return new WithResponse(); + } + + public final class WithResponse { + public CompletableFuture> create( + WebhookEndpointCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> create( + WebhookEndpointCreateParams params, RequestOptions options) { + if (params == null) throw new ConfigurationException("Request parameters are required"); + return Futures.map( + engine.request( + "POST", + new String[] {"webhook", "endpoints"}, + Collections.emptyMap(), + params.toJsonValue(), + true, + options), + r -> r.map(v -> new WebhookEndpointCreateResponse(v, r.requestId()))); + } + + public CompletableFuture> list() { + return list(WebhookEndpointListParams.builder().build()); + } + + public CompletableFuture> list( + RequestOptions options) { + return list(WebhookEndpointListParams.builder().build(), options); + } + + public CompletableFuture> list( + WebhookEndpointListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public CompletableFuture> list( + WebhookEndpointListParams params, RequestOptions options) { + if (params == null) throw new ConfigurationException("Request parameters are required"); + return CursorRequests.list( + engine, + new String[] {"webhook", "endpoints"}, + params.query(), + options, + WebhookEndpointListResponse::new); + } + + public CompletableFuture> retrieve(String endpointId) { + return retrieve(endpointId, RequestOptions.DEFAULT); + } + + public CompletableFuture> retrieve( + String endpointId, RequestOptions options) { + return Futures.map( + engine.request( + "GET", + new String[] {"webhook", "endpoints", endpointId}, + Collections.emptyMap(), + null, + true, + options), + r -> r.map(v -> new WebhookEndpoint(v, r.requestId()))); + } + + public CompletableFuture> update( + String endpointId, WebhookEndpointUpdateParams params) { + return update(endpointId, params, RequestOptions.DEFAULT); + } + + public CompletableFuture> update( + String endpointId, WebhookEndpointUpdateParams params, RequestOptions options) { + if (params == null) throw new ConfigurationException("Request parameters are required"); + return Futures.map( + engine.request( + "PUT", + new String[] {"webhook", "endpoints", endpointId}, + Collections.emptyMap(), + params.toJsonValue(), + true, + options), + r -> r.map(v -> new WebhookEndpoint(v, r.requestId()))); + } + + public CompletableFuture> delete(String endpointId) { + return delete(endpointId, RequestOptions.DEFAULT); + } + + public CompletableFuture> delete(String endpointId, RequestOptions options) { + return Futures.map( + engine.request( + "DELETE", + new String[] {"webhook", "endpoints", endpointId}, + Collections.emptyMap(), + null, + true, + options), + r -> r.map(v -> null)); + } + + public CompletableFuture> enable(String endpointId) { + return enable(endpointId, RequestOptions.DEFAULT); + } + + public CompletableFuture> enable( + String endpointId, RequestOptions options) { + return Futures.map( + engine.request( + "POST", + new String[] {"webhook", "endpoints", endpointId, "enable"}, + Collections.emptyMap(), + null, + true, + options), + r -> r.map(v -> new WebhookEndpoint(v, r.requestId()))); + } + + public CompletableFuture> disable(String endpointId) { + return disable(endpointId, RequestOptions.DEFAULT); + } + + public CompletableFuture> disable( + String endpointId, RequestOptions options) { + return Futures.map( + engine.request( + "POST", + new String[] {"webhook", "endpoints", endpointId, "disable"}, + Collections.emptyMap(), + null, + true, + options), + r -> r.map(v -> new WebhookEndpoint(v, r.requestId()))); + } + + public CompletableFuture> test(String endpointId) { + return test(endpointId, RequestOptions.DEFAULT); + } + + public CompletableFuture> test( + String endpointId, RequestOptions options) { + return Futures.map( + engine.request( + "POST", + new String[] {"webhook", "endpoints", endpointId, "test"}, + Collections.emptyMap(), + null, + true, + options), + r -> r.map(v -> new WebhookEndpointTestResponse(v, r.requestId()))); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/AsyncWebhooks.java b/src/main/java/com/qoder/cloudagents/forward/AsyncWebhooks.java new file mode 100644 index 0000000..dce5f02 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/AsyncWebhooks.java @@ -0,0 +1,15 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.internal.ClientEngine; + +public final class AsyncWebhooks { + private final AsyncWebhookEndpoints endpoints; + + public AsyncWebhooks(ClientEngine engine) { + endpoints = new AsyncWebhookEndpoints(engine); + } + + public AsyncWebhookEndpoints endpoints() { + return endpoints; + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/WebhookEndpoint.java b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpoint.java new file mode 100644 index 0000000..dcd0a2a --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpoint.java @@ -0,0 +1,59 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable webhook response preserving unknown fields. */ +public final class WebhookEndpoint extends JsonModel { + public WebhookEndpoint(JsonValue value, String requestId) { + super(value, requestId); + } + + public String id() { + return optional("id", JsonValue::asString); + } + + public String url() { + return optional("url", JsonValue::asString); + } + + public String description() { + return optional("description", JsonValue::asString); + } + + public List events() { + return optionalList("events", JsonValue::asString); + } + + public Map metadata() { + return optionalMap("metadata", v -> v); + } + + public Boolean active() { + return optional("active", JsonValue::asBoolean); + } + + public String createdAt() { + return optional("created_at", JsonValue::asString); + } + + public String updatedAt() { + return optional("updated_at", JsonValue::asString); + } + + public String lastSuccessAt() { + return optional("last_success_at", JsonValue::asString); + } + + public String lastFailureAt() { + return optional("last_failure_at", JsonValue::asString); + } + + public String disabledReason() { + return optional("disabled_reason", JsonValue::asString); + } + + public Long consecutiveFail() { + return optional("consecutive_fail", JsonValue::asLong); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointCreateParams.java b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointCreateParams.java new file mode 100644 index 0000000..17406e8 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointCreateParams.java @@ -0,0 +1,60 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable request. Unset fields are omitted; null metadata entries remove keys on update. */ +public final class WebhookEndpointCreateParams extends JsonObject { + private WebhookEndpointCreateParams(JsonValue value) { + super(value); + } + + public static WebhookEndpointCreateParams fromJson(JsonValue value) { + return new WebhookEndpointCreateParams(value); + } + + public String url() { + return optional("url", JsonValue::asString); + } + + public List events() { + return optionalList("events", JsonValue::asString); + } + + public String description() { + return optional("description", JsonValue::asString); + } + + public Map metadata() { + return optionalMap("metadata", v -> v.isNull() ? null : v.asString()); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private Builder() {} + + public Builder url(String value) { + return put("url", value); + } + + public Builder events(List value) { + return put("events", value); + } + + public Builder description(String value) { + return put("description", value); + } + + public Builder metadata(Map value) { + return put("metadata", value); + } + + public WebhookEndpointCreateParams build() { + require("url", "events"); + return new WebhookEndpointCreateParams(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointCreateResponse.java b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointCreateResponse.java new file mode 100644 index 0000000..9285f73 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointCreateResponse.java @@ -0,0 +1,44 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable webhook response preserving unknown fields. */ +public final class WebhookEndpointCreateResponse extends JsonModel { + public WebhookEndpointCreateResponse(JsonValue value, String requestId) { + super(value, requestId); + } + + public String id() { + return optional("id", JsonValue::asString); + } + + public String url() { + return optional("url", JsonValue::asString); + } + + public String description() { + return optional("description", JsonValue::asString); + } + + public List events() { + return optionalList("events", JsonValue::asString); + } + + public Map metadata() { + return optionalMap("metadata", v -> v); + } + + public Boolean active() { + return optional("active", JsonValue::asBoolean); + } + + public String createdAt() { + return optional("created_at", JsonValue::asString); + } + + /** Returned only at creation. Store securely; do not log. */ + public String signingSecret() { + return optional("signing_secret", JsonValue::asString); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointListParams.java b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointListParams.java new file mode 100644 index 0000000..82078f5 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointListParams.java @@ -0,0 +1,43 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.errors.ConfigurationException; +import java.util.*; + +public final class WebhookEndpointListParams { + private final Map query; + + private WebhookEndpointListParams(Builder b) { + query = Collections.unmodifiableMap(new LinkedHashMap<>(b.values)); + } + + public static Builder builder() { + return new Builder(); + } + + Map query() { + return query; + } + + public static final class Builder { + private final Map values = new LinkedHashMap<>(); + + public Builder afterId(String value) { + return put("after_id", value); + } + + public Builder limit(int value) { + if (value < 1 || value > 100) throw new ConfigurationException("limit must be 1..100"); + return put("limit", Integer.toString(value)); + } + + private Builder put(String name, String value) { + if (value == null) throw new ConfigurationException("List parameters cannot be null"); + values.put(name, value); + return this; + } + + public WebhookEndpointListParams build() { + return new WebhookEndpointListParams(this); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointListResponse.java b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointListResponse.java new file mode 100644 index 0000000..33e01c3 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointListResponse.java @@ -0,0 +1,35 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; + +public final class WebhookEndpointListResponse + extends CursorPage { + public WebhookEndpointListResponse(JsonValue value, String requestId) { + this(value, requestId, Collections.emptyMap(), Collections.emptySet(), null); + } + + WebhookEndpointListResponse( + JsonValue value, + String requestId, + Map query, + Set seen, + BiFunction, Set, CompletableFuture> + fetcher) { + super( + value, + requestId, + query, + seen, + fetcher, + item -> new WebhookEndpoint(item, requestId), + false); + } + + @Override + protected WebhookEndpointListResponse self() { + return this; + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointTestResponse.java b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointTestResponse.java new file mode 100644 index 0000000..d33c6e1 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointTestResponse.java @@ -0,0 +1,19 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable webhook response preserving unknown fields. */ +public final class WebhookEndpointTestResponse extends JsonModel { + public WebhookEndpointTestResponse(JsonValue value, String requestId) { + super(value, requestId); + } + + public Long eventId() { + return optional("event_id", JsonValue::asLong); + } + + public Long deliveryRows() { + return optional("delivery_rows", JsonValue::asLong); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointUpdateParams.java b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointUpdateParams.java new file mode 100644 index 0000000..2acbbae --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpointUpdateParams.java @@ -0,0 +1,67 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import java.util.*; + +/** Immutable request. Unset fields are omitted; null metadata entries remove keys on update. */ +public final class WebhookEndpointUpdateParams extends JsonObject { + private WebhookEndpointUpdateParams(JsonValue value) { + super(value); + } + + public static WebhookEndpointUpdateParams fromJson(JsonValue value) { + return new WebhookEndpointUpdateParams(value); + } + + public String url() { + return optional("url", JsonValue::asString); + } + + public List events() { + return optionalList("events", JsonValue::asString); + } + + public String description() { + return optional("description", JsonValue::asString); + } + + public Map metadata() { + return optionalMap("metadata", v -> v.isNull() ? null : v.asString()); + } + + public Boolean active() { + return optional("active", JsonValue::asBoolean); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends JsonObjectBuilder { + private Builder() {} + + public Builder url(String value) { + return put("url", value); + } + + public Builder events(List value) { + return put("events", value); + } + + public Builder description(String value) { + return put("description", value); + } + + public Builder active(Boolean value) { + return put("active", value); + } + + public Builder metadata(Map value) { + return put("metadata", value); + } + + public WebhookEndpointUpdateParams build() { + return new WebhookEndpointUpdateParams(json()); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/WebhookEndpoints.java b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpoints.java new file mode 100644 index 0000000..2647ed1 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/WebhookEndpoints.java @@ -0,0 +1,172 @@ +package com.qoder.cloudagents.forward; + +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.core.internal.*; +import java.util.*; + +/** Webhook management using a PAT or administrator SAT. */ +public final class WebhookEndpoints { + private final AsyncWebhookEndpoints async; + + public WebhookEndpoints(AsyncWebhookEndpoints async) { + this.async = async; + } + + public WebhookEndpointCreateResponse create(WebhookEndpointCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public WebhookEndpointCreateResponse create( + WebhookEndpointCreateParams params, RequestOptions options) { + return Futures.await(async.create(params, options)); + } + + public WebhookEndpointListResponse list() { + return list(WebhookEndpointListParams.builder().build()); + } + + public WebhookEndpointListResponse list(RequestOptions options) { + return list(WebhookEndpointListParams.builder().build(), options); + } + + public WebhookEndpointListResponse list(WebhookEndpointListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public WebhookEndpointListResponse list( + WebhookEndpointListParams params, RequestOptions options) { + return Futures.await(async.list(params, options)); + } + + public WebhookEndpoint retrieve(String endpointId) { + return retrieve(endpointId, RequestOptions.DEFAULT); + } + + public WebhookEndpoint retrieve(String endpointId, RequestOptions options) { + return Futures.await(async.retrieve(endpointId, options)); + } + + public WebhookEndpoint update(String endpointId, WebhookEndpointUpdateParams params) { + return update(endpointId, params, RequestOptions.DEFAULT); + } + + public WebhookEndpoint update( + String endpointId, WebhookEndpointUpdateParams params, RequestOptions options) { + return Futures.await(async.update(endpointId, params, options)); + } + + public Void delete(String endpointId) { + return delete(endpointId, RequestOptions.DEFAULT); + } + + public Void delete(String endpointId, RequestOptions options) { + return Futures.await(async.delete(endpointId, options)); + } + + public WebhookEndpoint enable(String endpointId) { + return enable(endpointId, RequestOptions.DEFAULT); + } + + public WebhookEndpoint enable(String endpointId, RequestOptions options) { + return Futures.await(async.enable(endpointId, options)); + } + + public WebhookEndpoint disable(String endpointId) { + return disable(endpointId, RequestOptions.DEFAULT); + } + + public WebhookEndpoint disable(String endpointId, RequestOptions options) { + return Futures.await(async.disable(endpointId, options)); + } + + public WebhookEndpointTestResponse test(String endpointId) { + return test(endpointId, RequestOptions.DEFAULT); + } + + public WebhookEndpointTestResponse test(String endpointId, RequestOptions options) { + return Futures.await(async.test(endpointId, options)); + } + + public WithResponse withResponse() { + return new WithResponse(); + } + + public final class WithResponse { + public ApiResponse create(WebhookEndpointCreateParams params) { + return create(params, RequestOptions.DEFAULT); + } + + public ApiResponse create( + WebhookEndpointCreateParams params, RequestOptions options) { + return Futures.await(async.withResponse().create(params, options)); + } + + public ApiResponse list() { + return list(WebhookEndpointListParams.builder().build()); + } + + public ApiResponse list(RequestOptions options) { + return list(WebhookEndpointListParams.builder().build(), options); + } + + public ApiResponse list(WebhookEndpointListParams params) { + return list(params, RequestOptions.DEFAULT); + } + + public ApiResponse list( + WebhookEndpointListParams params, RequestOptions options) { + return Futures.await(async.withResponse().list(params, options)); + } + + public ApiResponse retrieve(String endpointId) { + return retrieve(endpointId, RequestOptions.DEFAULT); + } + + public ApiResponse retrieve(String endpointId, RequestOptions options) { + return Futures.await(async.withResponse().retrieve(endpointId, options)); + } + + public ApiResponse update( + String endpointId, WebhookEndpointUpdateParams params) { + return update(endpointId, params, RequestOptions.DEFAULT); + } + + public ApiResponse update( + String endpointId, WebhookEndpointUpdateParams params, RequestOptions options) { + return Futures.await(async.withResponse().update(endpointId, params, options)); + } + + public ApiResponse delete(String endpointId) { + return delete(endpointId, RequestOptions.DEFAULT); + } + + public ApiResponse delete(String endpointId, RequestOptions options) { + return Futures.await(async.withResponse().delete(endpointId, options)); + } + + public ApiResponse enable(String endpointId) { + return enable(endpointId, RequestOptions.DEFAULT); + } + + public ApiResponse enable(String endpointId, RequestOptions options) { + return Futures.await(async.withResponse().enable(endpointId, options)); + } + + public ApiResponse disable(String endpointId) { + return disable(endpointId, RequestOptions.DEFAULT); + } + + public ApiResponse disable(String endpointId, RequestOptions options) { + return Futures.await(async.withResponse().disable(endpointId, options)); + } + + public ApiResponse test(String endpointId) { + return test(endpointId, RequestOptions.DEFAULT); + } + + public ApiResponse test( + String endpointId, RequestOptions options) { + return Futures.await(async.withResponse().test(endpointId, options)); + } + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/WebhookSignatures.java b/src/main/java/com/qoder/cloudagents/forward/WebhookSignatures.java new file mode 100644 index 0000000..491a727 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/WebhookSignatures.java @@ -0,0 +1,82 @@ +package com.qoder.cloudagents.forward; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Verifies Forward delivery signatures using only Java 8 platform APIs. */ +public final class WebhookSignatures { + private WebhookSignatures() {} + + /** + * Verify original body bytes before parsing. Accept any matching v1 signature within 300 seconds. + * Deduplicate accepted events using Webhook-ID. + */ + public static void verify(byte[] payload, Map headers, String secret) { + verifyAt(payload, headers, secret, Instant.now().getEpochSecond()); + } + + static void verifyAt(byte[] payload, Map headers, String secret, long now) { + if (payload == null || headers == null || secret == null) + throw new WebhookVerificationException(); + String id = header(headers, "Webhook-ID"), timestamp = header(headers, "Webhook-Timestamp"); + if (id.isEmpty() + || id.contains(".") + || id.contains("\r") + || id.contains("\n") + || !timestamp.matches("[0-9]{1,19}") + || !secret.startsWith("whsec_")) throw new WebhookVerificationException(); + long ts; + try { + ts = Long.parseLong(timestamp); + } catch (NumberFormatException e) { + throw new WebhookVerificationException(); + } + if (ts < now - 300 || ts > now + 300) throw new WebhookVerificationException(); + byte[] key; + try { + key = Base64.getDecoder().decode(secret.substring(6)); + } catch (IllegalArgumentException e) { + throw new WebhookVerificationException(); + } + if (key.length < 24 + || key.length > 64 + || !Base64.getEncoder().encodeToString(key).equals(secret.substring(6))) + throw new WebhookVerificationException(); + byte[] expected; + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8)); + expected = mac.doFinal(payload); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("HMAC-SHA256 unavailable", e); + } + for (String signature : header(headers, "Webhook-Signature").split("\\s+")) { + if (!signature.startsWith("v1,")) continue; + try { + if (MessageDigest.isEqual(expected, Base64.getDecoder().decode(signature.substring(3)))) + return; + } catch (IllegalArgumentException ignored) { + /* Try the remaining signatures. */ + } + } + throw new WebhookVerificationException(); + } + + private static String header(Map headers, String name) { + String value = null; + for (Map.Entry e : headers.entrySet()) { + if (name.equalsIgnoreCase(e.getKey())) { + if (value != null || e.getValue() == null) throw new WebhookVerificationException(); + value = e.getValue(); + } + } + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/WebhookVerificationException.java b/src/main/java/com/qoder/cloudagents/forward/WebhookVerificationException.java new file mode 100644 index 0000000..3eabfc8 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/WebhookVerificationException.java @@ -0,0 +1,8 @@ +package com.qoder.cloudagents.forward; + +/** Missing, invalid, expired or mismatched delivery authentication. */ +public final class WebhookVerificationException extends IllegalArgumentException { + public WebhookVerificationException() { + super("webhook signature verification failed"); + } +} diff --git a/src/main/java/com/qoder/cloudagents/forward/Webhooks.java b/src/main/java/com/qoder/cloudagents/forward/Webhooks.java new file mode 100644 index 0000000..b24a9b2 --- /dev/null +++ b/src/main/java/com/qoder/cloudagents/forward/Webhooks.java @@ -0,0 +1,13 @@ +package com.qoder.cloudagents.forward; + +public final class Webhooks { + private final WebhookEndpoints endpoints; + + public Webhooks(AsyncWebhooks async) { + endpoints = new WebhookEndpoints(async.endpoints()); + } + + public WebhookEndpoints endpoints() { + return endpoints; + } +} diff --git a/src/test/java/com/qoder/cloudagents/WebhooksTest.java b/src/test/java/com/qoder/cloudagents/WebhooksTest.java new file mode 100644 index 0000000..1a9f5e7 --- /dev/null +++ b/src/test/java/com/qoder/cloudagents/WebhooksTest.java @@ -0,0 +1,353 @@ +package com.qoder.cloudagents; + +import static org.junit.jupiter.api.Assertions.*; + +import com.qoder.cloudagents.auth.AuthMode; +import com.qoder.cloudagents.core.*; +import com.qoder.cloudagents.core.internal.*; +import com.qoder.cloudagents.errors.*; +import com.qoder.cloudagents.forward.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Stream; +import okhttp3.mockwebserver.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.*; + +@Timeout(15) +class WebhooksTest { + MockWebServer api; + ForwardClient client; + + @BeforeEach + void setup() throws Exception { + api = new MockWebServer(); + api.start(); + client = + ForwardClient.builder() + .baseUrl(api.url("/api/v1/forward/").toString()) + .authMode(AuthMode.SA) + .accessToken("test-sat") + .maxRetries(0) + .build(); + } + + @AfterEach + void close() throws Exception { + client.close(); + api.close(); + } + + MockResponse json(String body, int status) { + return new MockResponse() + .setResponseCode(status) + .setHeader("Content-Type", "application/json") + .setHeader("X-Request-Id", "req-webhook") + .setBody(body); + } + + static Stream routes() { + return Stream.of( + Arguments.of( + "create", + "POST", + "/api/v1/forward/webhook/endpoints", + "{\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\"],\"metadata\":{\"team\":\"sdk\"}}", + "{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"signing_secret\":\"whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=\"}", + 201, + false), + Arguments.of( + "create", + "POST", + "/api/v1/forward/webhook/endpoints", + "{\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\"],\"metadata\":{\"team\":\"sdk\"}}", + "{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"signing_secret\":\"whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=\"}", + 201, + true), + Arguments.of( + "list", + "GET", + "/api/v1/forward/webhook/endpoints", + "", + "{\"data\":[{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"updated_at\":\"2026-10-22T00:01:00Z\",\"last_success_at\":null,\"last_failure_at\":\"2026-10-22T00:01:00Z\",\"consecutive_fail\":0,\"disabled_reason\":\"manual\",\"future_field\":{\"preserved\":true}}],\"first_id\":\"endpoint_1\",\"last_id\":\"endpoint_1\",\"has_more\":false}", + 200, + false), + Arguments.of( + "list", + "GET", + "/api/v1/forward/webhook/endpoints", + "", + "{\"data\":[{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"updated_at\":\"2026-10-22T00:01:00Z\",\"last_success_at\":null,\"last_failure_at\":\"2026-10-22T00:01:00Z\",\"consecutive_fail\":0,\"disabled_reason\":\"manual\",\"future_field\":{\"preserved\":true}}],\"first_id\":\"endpoint_1\",\"last_id\":\"endpoint_1\",\"has_more\":false}", + 200, + true), + Arguments.of( + "retrieve", + "GET", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25", + "", + "{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"updated_at\":\"2026-10-22T00:01:00Z\",\"last_success_at\":null,\"last_failure_at\":\"2026-10-22T00:01:00Z\",\"consecutive_fail\":0,\"disabled_reason\":\"manual\",\"future_field\":{\"preserved\":true}}", + 200, + false), + Arguments.of( + "retrieve", + "GET", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25", + "", + "{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"updated_at\":\"2026-10-22T00:01:00Z\",\"last_success_at\":null,\"last_failure_at\":\"2026-10-22T00:01:00Z\",\"consecutive_fail\":0,\"disabled_reason\":\"manual\",\"future_field\":{\"preserved\":true}}", + 200, + true), + Arguments.of( + "update", + "PUT", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25", + "{\"active\":false,\"events\":[\"future.event\"],\"metadata\":{\"remove\":null,\"team\":\"sdk\"}}", + "{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"updated_at\":\"2026-10-22T00:01:00Z\",\"last_success_at\":null,\"last_failure_at\":\"2026-10-22T00:01:00Z\",\"consecutive_fail\":0,\"disabled_reason\":\"manual\",\"future_field\":{\"preserved\":true}}", + 200, + false), + Arguments.of( + "update", + "PUT", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25", + "{\"active\":false,\"events\":[\"future.event\"],\"metadata\":{\"remove\":null,\"team\":\"sdk\"}}", + "{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"updated_at\":\"2026-10-22T00:01:00Z\",\"last_success_at\":null,\"last_failure_at\":\"2026-10-22T00:01:00Z\",\"consecutive_fail\":0,\"disabled_reason\":\"manual\",\"future_field\":{\"preserved\":true}}", + 200, + true), + Arguments.of( + "delete", + "DELETE", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25", + "", + "", + 204, + false), + Arguments.of( + "delete", + "DELETE", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25", + "", + "", + 204, + true), + Arguments.of( + "enable", + "POST", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25/enable", + "", + "{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"updated_at\":\"2026-10-22T00:01:00Z\",\"last_success_at\":null,\"last_failure_at\":\"2026-10-22T00:01:00Z\",\"consecutive_fail\":0,\"disabled_reason\":\"manual\",\"future_field\":{\"preserved\":true}}", + 200, + false), + Arguments.of( + "enable", + "POST", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25/enable", + "", + "{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"updated_at\":\"2026-10-22T00:01:00Z\",\"last_success_at\":null,\"last_failure_at\":\"2026-10-22T00:01:00Z\",\"consecutive_fail\":0,\"disabled_reason\":\"manual\",\"future_field\":{\"preserved\":true}}", + 200, + true), + Arguments.of( + "disable", + "POST", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25/disable", + "", + "{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"updated_at\":\"2026-10-22T00:01:00Z\",\"last_success_at\":null,\"last_failure_at\":\"2026-10-22T00:01:00Z\",\"consecutive_fail\":0,\"disabled_reason\":\"manual\",\"future_field\":{\"preserved\":true}}", + 200, + false), + Arguments.of( + "disable", + "POST", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25/disable", + "", + "{\"id\":\"endpoint_1\",\"url\":\"https://hooks.example.com/qca\",\"events\":[\"forward.schedule.archived\",\"future.event\"],\"description\":\"test\",\"metadata\":{\"team\":\"sdk\",\"created_by\":\"platform\"},\"active\":false,\"created_at\":\"2026-10-22T00:00:00Z\",\"updated_at\":\"2026-10-22T00:01:00Z\",\"last_success_at\":null,\"last_failure_at\":\"2026-10-22T00:01:00Z\",\"consecutive_fail\":0,\"disabled_reason\":\"manual\",\"future_field\":{\"preserved\":true}}", + 200, + true), + Arguments.of( + "test", + "POST", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25/test", + "", + "{\"event_id\":7,\"delivery_rows\":0}", + 202, + false), + Arguments.of( + "test", + "POST", + "/api/v1/forward/webhook/endpoints/opaque%2F%3F%23%25/test", + "", + "{\"event_id\":7,\"delivery_rows\":0}", + 202, + true)); + } + + CompletableFuture asyncCall(String method) { + AsyncWebhookEndpoints r = client.async().webhooks().endpoints(); + switch (method) { + case "list": + return r.withResponse().list(); + case "create": + return r.withResponse() + .create( + WebhookEndpointCreateParams.builder() + .url("https://hooks.example.com/qca") + .events(Arrays.asList("forward.schedule.archived")) + .metadata(Collections.singletonMap("team", "sdk")) + .build(), + RequestOptions.builder().idempotencyKey("once").build()); + case "update": + Map patch = new LinkedHashMap<>(); + patch.put("remove", null); + patch.put("team", "sdk"); + return r.withResponse() + .update( + "opaque/?#%", + WebhookEndpointUpdateParams.builder() + .active(false) + .events(Arrays.asList("future.event")) + .metadata(patch) + .build()); + case "retrieve": + return r.withResponse().retrieve("opaque/?#%"); + case "enable": + return r.withResponse().enable("opaque/?#%"); + case "disable": + return r.withResponse().disable("opaque/?#%"); + case "test": + return r.withResponse().test("opaque/?#%"); + default: + return r.withResponse().delete("opaque/?#%"); + } + } + + Object syncCall(String method) { + WebhookEndpoints r = client.webhooks().endpoints(); + switch (method) { + case "list": + return r.withResponse().list(); + case "create": + return r.withResponse() + .create( + WebhookEndpointCreateParams.builder() + .url("https://hooks.example.com/qca") + .events(Arrays.asList("forward.schedule.archived")) + .metadata(Collections.singletonMap("team", "sdk")) + .build(), + RequestOptions.builder().idempotencyKey("once").build()); + case "update": + Map patch = new LinkedHashMap<>(); + patch.put("remove", null); + patch.put("team", "sdk"); + return r.withResponse() + .update( + "opaque/?#%", + WebhookEndpointUpdateParams.builder() + .active(false) + .events(Arrays.asList("future.event")) + .metadata(patch) + .build()); + case "retrieve": + return r.withResponse().retrieve("opaque/?#%"); + case "enable": + return r.withResponse().enable("opaque/?#%"); + case "disable": + return r.withResponse().disable("opaque/?#%"); + case "test": + return r.withResponse().test("opaque/?#%"); + default: + return r.withResponse().delete("opaque/?#%"); + } + } + + @ParameterizedTest + @MethodSource("routes") + void contracts( + String method, + String verb, + String path, + String body, + String response, + int status, + boolean async) + throws Exception { + api.enqueue(json(response, status)); + ApiResponse result = + (ApiResponse) (async ? asyncCall(method).get(5, TimeUnit.SECONDS) : syncCall(method)); + RecordedRequest req = api.takeRequest(2, TimeUnit.SECONDS); + assertNotNull(req); + assertEquals(verb, req.getMethod()); + assertEquals(path, req.getPath()); + assertEquals("Bearer test-sat", req.getHeader("Authorization")); + if (body.isEmpty()) assertEquals(0, req.getBodySize()); + else + assertEquals( + JsonValue.parse(body).toJson(), JsonValue.parse(req.getBody().readUtf8()).toJson()); + assertEquals("req-webhook", result.requestId()); + if (method.equals("delete")) assertNull(result.data()); + else + assertEquals( + JsonValue.parse(response).toJson(), ((JsonObject) result.data()).toJsonValue().toJson()); + if (method.equals("create")) { + assertEquals("once", req.getHeader("Idempotency-Key")); + assertFalse(result.data().toString().contains("whsec_")); + } + } + + @ParameterizedTest + @MethodSource("routes") + void errors( + String method, + String verb, + String path, + String body, + String response, + int status, + boolean async) + throws Exception { + api.enqueue(json("{\"error\":{\"message\":\"forbidden\"}}", 403)); + if (async) assertThrows(ApiStatusException.class, () -> Futures.await(asyncCall(method))); + else assertThrows(ApiStatusException.class, () -> syncCall(method)); + } + + @Test + void pagination() throws Exception { + api.enqueue( + json( + "{\"data\":[{\"id\":\"z\"},{\"id\":\"a\"}],\"last_id\":\"opaque/+?\",\"has_more\":true}", + 200)); + api.enqueue(json("{\"data\":[],\"last_id\":null,\"first_id\":null,\"has_more\":false}", 200)); + WebhookEndpointListResponse page = + client.webhooks().endpoints().list(WebhookEndpointListParams.builder().limit(2).build()); + assertEquals("z", page.data().get(0).id()); + assertEquals("a", page.data().get(1).id()); + WebhookEndpointListResponse next = page.getNextPage(); + assertTrue(next.data().isEmpty()); + assertFalse(next.hasNextPage()); + assertEquals("/api/v1/forward/webhook/endpoints?limit=2", api.takeRequest().getPath()); + RecordedRequest request = api.takeRequest(); + assertEquals("opaque/+?", request.getRequestUrl().queryParameter("after_id")); + assertNull(request.getRequestUrl().queryParameter("page")); + } + + @Test + void asyncCancellation() throws Exception { + api.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)); + CompletableFuture future = + client.async().webhooks().endpoints().retrieve("endpoint"); + assertNotNull(api.takeRequest(2, TimeUnit.SECONDS)); + assertTrue(future.cancel(true)); + assertTrue(future.isCancelled()); + } + + @Test + void testIsNotReplayed() { + try (ForwardClient retry = + ForwardClient.builder() + .baseUrl(api.url("/").toString()) + .pat("test") + .maxRetries(2) + .build()) { + api.enqueue(json("{\"error\":{\"message\":\"unavailable\"}}", 503)); + assertThrows(ApiException.class, () -> retry.webhooks().endpoints().test("id")); + assertEquals(1, api.getRequestCount()); + } + } +} diff --git a/src/test/java/com/qoder/cloudagents/forward/WebhookSignaturesTest.java b/src/test/java/com/qoder/cloudagents/forward/WebhookSignaturesTest.java new file mode 100644 index 0000000..d937b28 --- /dev/null +++ b/src/test/java/com/qoder/cloudagents/forward/WebhookSignaturesTest.java @@ -0,0 +1,56 @@ +package com.qoder.cloudagents.forward; + +import static org.junit.jupiter.api.Assertions.*; + +import com.qoder.cloudagents.core.JsonValue; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import org.junit.jupiter.api.Test; + +class WebhookSignaturesTest { + @Test + void sharedVectorAndFailures() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (InputStream in = getClass().getResourceAsStream("/webhook-signature.json")) { + byte[] b = new byte[1024]; + int n; + while ((n = in.read(b)) != -1) out.write(b, 0, n); + } + JsonValue v = JsonValue.parse(new String(out.toByteArray(), StandardCharsets.UTF_8)); + String secret = v.get("secret").asString(); + byte[] body = v.get("payload").asString().getBytes(StandardCharsets.UTF_8); + Map h = new LinkedHashMap<>(); + h.put("webhook-id", v.get("id").asString()); + h.put("Webhook-Timestamp", v.get("timestamp").asString()); + h.put("Webhook-Signature", "v2,unknown v1,invalid " + v.get("signature").asString()); + for (long delta : new long[] {-300, 0, 300}) + WebhookSignatures.verifyAt(body, h, secret, 1767225600L + delta); + for (long delta : new long[] {-301, 301}) + assertThrows( + WebhookVerificationException.class, + () -> WebhookSignatures.verifyAt(body, h, secret, 1767225600L + delta)); + for (String[] update : + new String[][] { + {"Webhook-Timestamp", "9223372036854775807"}, + {"Webhook-Timestamp", "-1"}, + {"Webhook-Signature", "v2,abc"}, + {"webhook-id", "other"} + }) { + Map changed = new LinkedHashMap<>(h); + changed.put(update[0], update[1]); + assertThrows( + WebhookVerificationException.class, + () -> WebhookSignatures.verifyAt(body, changed, secret, 1767225600L)); + } + assertThrows( + WebhookVerificationException.class, + () -> + WebhookSignatures.verifyAt( + Arrays.copyOf(body, body.length + 1), h, secret, 1767225600L)); + for (String bad : new String[] {"", "whsec_", "whsec_bad!", "whsec_dG9vIHNob3J0"}) + assertThrows( + WebhookVerificationException.class, + () -> WebhookSignatures.verifyAt(body, h, bad, 1767225600L)); + } +} diff --git a/src/test/resources/webhook-signature.json b/src/test/resources/webhook-signature.json new file mode 100644 index 0000000..0e78223 --- /dev/null +++ b/src/test/resources/webhook-signature.json @@ -0,0 +1,7 @@ +{ + "secret": "whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", + "id": "whe_0123456789abcdefghijkl", + "timestamp": "1767225600", + "payload": "{\"type\":\"session.created\",\"data\":{\"id\":\"sess_01\"}}", + "signature": "v1,lMKooyqBqXRe60OmeImJYaDFJ10DGzkw1CMAyVjLbFs=" +} From 38bf1bd7ed995666f236f33d2c4a826de4349f07 Mon Sep 17 00:00:00 2001 From: moonyue-w <300878504+moonyue-w@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:06:19 +0800 Subject: [PATCH 2/2] docs(webhooks): wait for delivery before endpoint cleanup --- docs/webhooks.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/webhooks.md b/docs/webhooks.md index 363bdc5..f5175a6 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -52,8 +52,6 @@ endpoints.update(created.id(), WebhookEndpointUpdateParams.builder().active(fals endpoints.enable(created.id()); WebhookEndpointTestResponse accepted = endpoints.test(created.id()); for (WebhookEndpoint endpoint : endpoints.list()) System.out.println(endpoint.id()); -endpoints.disable(created.id()); -endpoints.delete(created.id()); ``` Import `com.qoder.cloudagents.forward.*`, `com.qoder.cloudagents.core.RequestOptions` @@ -92,6 +90,18 @@ with 2xx. Retries share the same ID. The SDK does not supply a queue or a dedupl database. The examples only parse an authenticated delivery and list endpoints; they do not start a production receiver or create subscriptions automatically. +## Clean up a test endpoint + +Run this step separately for a temporary test endpoint, after your receiver has +confirmed receipt and successfully verified the expected test delivery. Keep the +endpoint active while waiting: disabling or deleting it can discard queued +deliveries. HTTP 202 and `delivery_rows` confirm queuing, not receipt. + +```java +endpoints.disable(created.id()); +endpoints.delete(created.id()); +``` + ## Validation boundary Offline tests cover all eight HTTP contracts, errors, cursor paging, omitted/null/