Skip to content

Commit e2289d1

Browse files
feat: Implement Webhooks module (beta) (#62)
1 parent 464efd7 commit e2289d1

15 files changed

+1712
-0
lines changed

src/main/java/com/resend/Resend.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.resend.services.contacts.Contacts;
88
import com.resend.services.domains.Domains;
99
import com.resend.services.emails.Emails;
10+
import com.resend.services.webhooks.Webhooks;
1011
import com.resend.services.receiving.Receiving;
1112
import com.resend.services.topics.Topics;
1213
import com.resend.services.templates.Templates;
@@ -94,6 +95,15 @@ public Broadcasts broadcasts() {
9495
}
9596

9697
/**
98+
* Returns a Webhooks object that can be used to interact with the Webhooks service.
99+
*
100+
* @return A Webhooks object.
101+
*/
102+
public Webhooks webhooks() {
103+
return new Webhooks(apiKey);
104+
}
105+
106+
/**
97107
* Returns a Receiving object that can be used to interact with the Receiving service for inbound emails.
98108
*
99109
* @return A Receiving object.
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
package com.resend.services.webhooks;
2+
3+
import com.resend.core.exception.ResendException;
4+
import com.resend.core.helper.URLHelper;
5+
import com.resend.core.net.AbstractHttpResponse;
6+
import com.resend.core.net.HttpMethod;
7+
import com.resend.core.net.ListParams;
8+
import com.resend.core.service.BaseService;
9+
import com.resend.services.webhooks.model.CreateWebhookOptions;
10+
import com.resend.services.webhooks.model.CreateWebhookResponseSuccess;
11+
import com.resend.services.webhooks.model.ListWebhooksResponseSuccess;
12+
import com.resend.services.webhooks.model.RemoveWebhookResponseSuccess;
13+
import com.resend.services.webhooks.model.UpdateWebhookOptions;
14+
import com.resend.services.webhooks.model.UpdateWebhookResponseSuccess;
15+
import com.resend.services.webhooks.model.GetWebhookResponseSuccess;
16+
import com.resend.services.webhooks.model.VerifyWebhookOptions;
17+
import okhttp3.MediaType;
18+
import javax.crypto.Mac;
19+
import javax.crypto.spec.SecretKeySpec;
20+
import java.security.MessageDigest;
21+
import java.util.Base64;
22+
23+
/**
24+
* Represents the Resend Webhooks module.
25+
*/
26+
public final class Webhooks extends BaseService {
27+
28+
/**
29+
* Constructs an instance of the {@code Webhooks} class.
30+
*
31+
* @param apiKey The apiKey used for authentication.
32+
*/
33+
public Webhooks(final String apiKey) {
34+
super(apiKey);
35+
}
36+
37+
/**
38+
* Creates a webhook based on the provided CreateWebhookOptions and returns a CreateWebhookResponseSuccess.
39+
*
40+
* @param createWebhookOptions The request object containing the webhook creation details.
41+
* @return A CreateWebhookResponseSuccess representing the result of the webhook creation operation.
42+
* @throws ResendException If an error occurs during the webhook creation process.
43+
*/
44+
public CreateWebhookResponseSuccess create(CreateWebhookOptions createWebhookOptions) throws ResendException {
45+
String payload = super.resendMapper.writeValue(createWebhookOptions);
46+
AbstractHttpResponse<String> response = httpClient.perform("/webhooks", super.apiKey, HttpMethod.POST, payload, MediaType.get("application/json"));
47+
48+
if (!response.isSuccessful()) {
49+
throw new ResendException(response.getCode(), response.getBody());
50+
}
51+
52+
String responseBody = response.getBody();
53+
return resendMapper.readValue(responseBody, CreateWebhookResponseSuccess.class);
54+
}
55+
56+
/**
57+
* Updates a webhook based on the provided webhook ID and UpdateWebhookOptions, and returns an UpdateWebhookResponseSuccess.
58+
*
59+
* @param webhookId The unique identifier of the webhook to update.
60+
* @param updateWebhookOptions The object containing the information to be updated.
61+
* @return An UpdateWebhookResponseSuccess representing the result of the webhook update operation.
62+
* @throws ResendException If an error occurs during the webhook update process.
63+
*/
64+
public UpdateWebhookResponseSuccess update(String webhookId, UpdateWebhookOptions updateWebhookOptions) throws ResendException {
65+
String payload = super.resendMapper.writeValue(updateWebhookOptions);
66+
AbstractHttpResponse<String> response = httpClient.perform("/webhooks/" + webhookId, super.apiKey, HttpMethod.PATCH, payload, MediaType.get("application/json"));
67+
68+
if (!response.isSuccessful()) {
69+
throw new ResendException(response.getCode(), response.getBody());
70+
}
71+
72+
String responseBody = response.getBody();
73+
return resendMapper.readValue(responseBody, UpdateWebhookResponseSuccess.class);
74+
}
75+
76+
/**
77+
* Retrieves a webhook based on the provided webhook ID and returns a Webhook object.
78+
*
79+
* @param webhookId The unique identifier of the webhook to retrieve.
80+
* @return A Webhook object representing the retrieved webhook.
81+
* @throws ResendException If an error occurs during the webhook retrieval process.
82+
*/
83+
public GetWebhookResponseSuccess get(String webhookId) throws ResendException {
84+
AbstractHttpResponse<String> response = httpClient.perform("/webhooks/" + webhookId, super.apiKey, HttpMethod.GET, null, MediaType.get("application/json"));
85+
86+
if (!response.isSuccessful()) {
87+
throw new ResendException(response.getCode(), response.getBody());
88+
}
89+
90+
String responseBody = response.getBody();
91+
return resendMapper.readValue(responseBody, GetWebhookResponseSuccess.class);
92+
}
93+
94+
/**
95+
* Retrieves a list of webhooks and returns a ListWebhooksResponseSuccess.
96+
*
97+
* @return A ListWebhooksResponseSuccess containing the list of webhooks.
98+
* @throws ResendException If an error occurs during the webhook list retrieval process.
99+
*/
100+
public ListWebhooksResponseSuccess list() throws ResendException {
101+
AbstractHttpResponse<String> response = this.httpClient.perform("/webhooks", super.apiKey, HttpMethod.GET, null, MediaType.get("application/json"));
102+
103+
if (!response.isSuccessful()) {
104+
throw new ResendException(response.getCode(), response.getBody());
105+
}
106+
107+
String responseBody = response.getBody();
108+
return resendMapper.readValue(responseBody, ListWebhooksResponseSuccess.class);
109+
}
110+
111+
/**
112+
* Retrieves a paginated list of webhooks and returns a ListWebhooksResponseSuccess.
113+
*
114+
* @param params The params used to customize the list.
115+
* @return A ListWebhooksResponseSuccess containing the paginated list of webhooks.
116+
* @throws ResendException If an error occurs during the webhook list retrieval process.
117+
*/
118+
public ListWebhooksResponseSuccess list(ListParams params) throws ResendException {
119+
String pathWithQuery = "/webhooks" + URLHelper.parse(params);
120+
AbstractHttpResponse<String> response = this.httpClient.perform(pathWithQuery, super.apiKey, HttpMethod.GET, null, MediaType.get("application/json"));
121+
122+
if (!response.isSuccessful()) {
123+
throw new ResendException(response.getCode(), response.getBody());
124+
}
125+
126+
String responseBody = response.getBody();
127+
return resendMapper.readValue(responseBody, ListWebhooksResponseSuccess.class);
128+
}
129+
130+
/**
131+
* Deletes a webhook based on the provided webhook ID and returns a RemoveWebhookResponseSuccess.
132+
*
133+
* @param webhookId The unique identifier of the webhook to delete.
134+
* @return A RemoveWebhookResponseSuccess representing the result of the webhook deletion operation.
135+
* @throws ResendException If an error occurs during the webhook deletion process.
136+
*/
137+
public RemoveWebhookResponseSuccess remove(String webhookId) throws ResendException {
138+
AbstractHttpResponse<String> response = httpClient.perform("/webhooks/" + webhookId, super.apiKey, HttpMethod.DELETE, "", null);
139+
140+
if (!response.isSuccessful()) {
141+
throw new ResendException(response.getCode(), response.getBody());
142+
}
143+
144+
String responseBody = response.getBody();
145+
return resendMapper.readValue(responseBody, RemoveWebhookResponseSuccess.class);
146+
}
147+
148+
/**
149+
* Verifies the signature of a webhook request to ensure it was sent by Resend.
150+
* This method validates both the HMAC-SHA256 signature and the timestamp to prevent
151+
* replay attacks.
152+
*
153+
* @param options The verification options containing payload, headers, and secret.
154+
* @throws ResendException If the signature is invalid or the timestamp is outside the tolerance window.
155+
*/
156+
public void verify(VerifyWebhookOptions options) throws ResendException {
157+
if (options == null) {
158+
throw new ResendException(400, "VerifyWebhookOptions cannot be null");
159+
}
160+
161+
if (options.getPayload() == null || options.getPayload().isEmpty()) {
162+
throw new ResendException(400, "Webhook payload cannot be null or empty");
163+
}
164+
165+
if (options.getHeaders() == null) {
166+
throw new ResendException(400, "Webhook headers cannot be null");
167+
}
168+
169+
if (options.getSecret() == null || options.getSecret().isEmpty()) {
170+
throw new ResendException(400, "Webhook secret cannot be null or empty");
171+
}
172+
173+
String id = options.getHeaders().get("svix-id");
174+
String timestamp = options.getHeaders().get("svix-timestamp");
175+
String signature = options.getHeaders().get("svix-signature");
176+
177+
if (id == null || id.isEmpty()) {
178+
throw new ResendException(400, "Webhook ID (svix-id) cannot be null or empty");
179+
}
180+
181+
if (timestamp == null || timestamp.isEmpty()) {
182+
throw new ResendException(400, "Webhook timestamp (svix-timestamp) cannot be null or empty");
183+
}
184+
185+
if (signature == null || signature.isEmpty()) {
186+
throw new ResendException(400, "Webhook signature (svix-signature) cannot be null or empty");
187+
}
188+
189+
// Validate timestamp (within 5 minutes tolerance)
190+
try {
191+
long webhookTimestamp = Long.parseLong(timestamp);
192+
long currentTimestamp = System.currentTimeMillis() / 1000;
193+
long timeDifference = Math.abs(currentTimestamp - webhookTimestamp);
194+
195+
if (timeDifference > 300) { // 5 minutes in seconds
196+
throw new ResendException(400, "Webhook timestamp is outside the tolerance window (5 minutes)");
197+
}
198+
} catch (NumberFormatException e) {
199+
throw new ResendException(400, "Invalid webhook timestamp format");
200+
}
201+
202+
// Extract the secret key (remove "whsec_" prefix)
203+
String secretKey = options.getSecret();
204+
if (secretKey.startsWith("whsec_")) {
205+
secretKey = secretKey.substring(6);
206+
}
207+
208+
try {
209+
// Decode the base64 secret
210+
byte[] decodedSecret = Base64.getDecoder().decode(secretKey);
211+
212+
// Create the signed content: {id}.{timestamp}.{payload}
213+
String signedContent = id + "." + timestamp + "." + options.getPayload();
214+
215+
// Generate HMAC-SHA256 signature
216+
Mac hmac = Mac.getInstance("HmacSHA256");
217+
SecretKeySpec secretKeySpec = new SecretKeySpec(decodedSecret, "HmacSHA256");
218+
hmac.init(secretKeySpec);
219+
byte[] hash = hmac.doFinal(signedContent.getBytes("UTF-8"));
220+
221+
// Encode to base64
222+
String expectedSignature = Base64.getEncoder().encodeToString(hash);
223+
224+
// Parse the signature header (format: "v1,signature1 v1,signature2")
225+
String[] signatureParts = signature.split(" ");
226+
boolean signatureMatches = false;
227+
228+
for (String signaturePart : signatureParts) {
229+
String[] versionAndSignature = signaturePart.split(",", 2);
230+
if (versionAndSignature.length == 2) {
231+
String version = versionAndSignature[0];
232+
String sig = versionAndSignature[1];
233+
234+
// Only support v1 for now
235+
if ("v1".equals(version)) {
236+
if (constantTimeEquals(expectedSignature, sig)) {
237+
signatureMatches = true;
238+
break;
239+
}
240+
}
241+
}
242+
}
243+
244+
if (!signatureMatches) {
245+
throw new ResendException(401, "Webhook signature verification failed");
246+
}
247+
248+
} catch (ResendException e) {
249+
throw e;
250+
} catch (Exception e) {
251+
throw new ResendException(500, "Error verifying webhook signature: " + e.getMessage());
252+
}
253+
}
254+
255+
/**
256+
* Constant-time string comparison to prevent timing attacks.
257+
*
258+
* @param a First string to compare.
259+
* @param b Second string to compare.
260+
* @return true if the strings are equal, false otherwise.
261+
*/
262+
private boolean constantTimeEquals(String a, String b) {
263+
if (a == null || b == null) {
264+
return false;
265+
}
266+
267+
byte[] aBytes = a.getBytes();
268+
byte[] bBytes = b.getBytes();
269+
270+
if (aBytes.length != bBytes.length) {
271+
return false;
272+
}
273+
274+
int result = 0;
275+
for (int i = 0; i < aBytes.length; i++) {
276+
result |= aBytes[i] ^ bBytes[i];
277+
}
278+
279+
return result == 0;
280+
}
281+
}

0 commit comments

Comments
 (0)