Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
4 changes: 4 additions & 0 deletions docs/forward-api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
114 changes: 114 additions & 0 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# 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<String, String> 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());
```

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.

## 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/
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).
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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<String, String> 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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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));
}
}
}
7 changes: 7 additions & 0 deletions src/main/java/com/qoder/cloudagents/AsyncForwardClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
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;
import java.util.function.Consumer;

/** 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;
Expand Down Expand Up @@ -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);
Expand All @@ -73,6 +76,10 @@ public final class AsyncForwardClient implements AutoCloseable {
memoryStores = new AsyncMemoryStores(engine);
}

public AsyncWebhooks webhooks() {
return webhooks;
}

public AsyncRealtime realtime() {
return realtime;
}
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/com/qoder/cloudagents/ForwardClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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());
Expand All @@ -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;
}
Expand Down
Loading
Loading