Skip to content

Commit 1144bed

Browse files
authored
Merge pull request #31 from Midtrans/snap-bi-webhook-vefirication
2 parents 1688023 + 38b9382 commit 1144bed

4 files changed

Lines changed: 175 additions & 5 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,8 @@ SnapBiConfig.setSnapBiPartnerId("YOUR PARTNER ID");
902902
SnapBiConfig.setSnapBiChannelId("YOUR CHANNEL ID");
903903
// Enable logging to see details of the request/response make sure to disable this on production, the default is disabled.
904904
SnapBiConfig.setEnableLogging(true);
905+
// Set your public key here if you want to verify your webhook notification, make sure to add \n on the public key, you can refer to the examples
906+
SnapBiConfig.setSnapBiPublicKey("YOUR PUBLIC KEY");
905907
```
906908

907909
### 3.2 Create Payment
@@ -1447,7 +1449,31 @@ JSONObject snapBiResponse2 = SnapBi.va()
14471449

14481450
### 3.9 Payment Notification
14491451
To implement Snap-Bi Payment Notification you can refer to this [docs](https://docs.midtrans.com/reference/payment-notification-api)
1452+
To verify the webhook notification that you recieve you can use this method below
1453+
```java
1454+
1455+
//the request body/ payload sent by the webhook
1456+
String notificationPayload = "{\"originalPartnerReferenceNo\":\"GP24043015193402809\",\"originalReferenceNo\":\"A120240430081940S9vu8gSjaRID\",\"merchantId\":\"G099333790\",\"amount\":{\"value\":\"102800.00\",\"currency\":\"IDR\"},\"latestTransactionStatus\":\"00\",\"transactionStatusDesc\":\"SUCCESS\",\"additionalInfo\":{\"refundHistory\":[]}}"; // Sample notification body, replace with actual data you receive from Midtrans
1457+
1458+
1459+
// to get the signature value, you need to retrieve it from the webhook header called X-Signature
1460+
String signature = "CgjmAyC9OZ3pB2JhBRDihL939kS86LjP1VLD1R7LgI4JkvYvskUQrPXgjhrZqU2SFkfPmLtSbcEUw21pg2nItQ0KoX582Y6Tqg4Mn45BQbxo4LTPzkZwclD4WI+aCYePQtUrXpJSTM8D32lSJQQndlloJfzoD6Rh24lNb+zjUpc+YEi4vMM6MBmS26PpCm/7FZ7/OgsVh9rlSNUsuQ/1QFpldA0F8bBNWSW4trwv9bE1NFDzliHrRAnQXrT/J3chOg5qqH0+s3E6v/W21hIrBYZVDTppyJPtTOoCWeuT1Tk9XI2HhSDiSuI3pevzLL8FLEWY/G4M5zkjm/9056LTDw==";
1461+
1462+
// to get the timeStamp value, you need to retrieve it from the webhook header called X-Timestamp
1463+
String timeStamp = "2024-10-07T15:45:22+07:00";
14501464

1465+
// the url path is based on the webhook url of the payment method for example for direct debit is `/v1.0/debit/notify`
1466+
String notificationUrlPath = "/v1.0/debit/notify";
1467+
/**
1468+
* Example verifying the webhook notification
1469+
*/
1470+
Boolean isVerified = SnapBi.notification()
1471+
.withNotificationPayload(notificationPayload)
1472+
.withSignature(signature)
1473+
.withTimeStamp(timeStamp)
1474+
.withNotificationUrlPath(notificationUrlPath)
1475+
.isWebhookNotificationVerified();
1476+
```
14511477

14521478

14531479
## 4. Examples
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.midtrans.snapbi;
2+
3+
import com.sun.net.httpserver.HttpExchange;
4+
import com.sun.net.httpserver.HttpHandler;
5+
import com.sun.net.httpserver.HttpServer;
6+
7+
import java.io.IOException;
8+
import java.io.InputStream;
9+
import java.io.OutputStream;
10+
import java.net.InetSocketAddress;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.*;
13+
14+
public class SnapBiWebhookServer {
15+
public static void main(String[] args) throws IOException {
16+
int port = 3000;
17+
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
18+
server.createContext("/webhook", new WebhookHandler());
19+
server.setExecutor(null);
20+
server.start();
21+
System.out.println("Server started on port " + port);
22+
}
23+
24+
static class WebhookHandler implements HttpHandler {
25+
@Override
26+
public void handle(HttpExchange exchange) throws IOException {
27+
if ("POST".equals(exchange.getRequestMethod())) {
28+
// Read the request body
29+
InputStream requestBody = exchange.getRequestBody();
30+
String body = new Scanner(requestBody, StandardCharsets.UTF_8.name()).useDelimiter("\\A").next();
31+
Map<String, List<String>> headers = exchange.getRequestHeaders();
32+
33+
// Get specific header values (x-signature and x-timestamp)
34+
String signature = headers.getOrDefault("X-Signature", java.util.Collections.emptyList()).stream().findFirst().orElse(null);
35+
String timestamp = headers.getOrDefault("X-Timestamp", java.util.Collections.emptyList()).stream().findFirst().orElse(null);
36+
37+
String publicKey = "-----BEGIN PUBLIC KEY-----"
38+
+"ACBDefghijkklmn/fboOoctcthr8aJ5AOEpCFLrsCSgAtmtcHxBHq9miZyHFf4juNBpvvRrVlCLzyhNOkjKDNj9PO/MZabcdefGHIJKLMN"
39+
+"-----END PUBLIC KEY-----";
40+
41+
SnapBiConfig.setSnapBiPublicKey(publicKey);
42+
// get the url path that comes after `/webhook`
43+
String requestURI = exchange.getRequestURI().toString();
44+
String basePath = "/webhook";
45+
String remainingPath = requestURI.substring(basePath.length());
46+
47+
Boolean isVerified = false;
48+
try {
49+
isVerified =
50+
SnapBi.notification()
51+
.withNotificationPayload(body)
52+
.withSignature(signature)
53+
.withTimeStamp(timestamp)
54+
.withNotificationUrlPath(remainingPath)
55+
.isWebhookNotificationVerified();
56+
} catch (Exception e) {
57+
throw new RuntimeException(e.getMessage());
58+
}
59+
String response = "Webhook received successfully, its verified = " + isVerified;
60+
exchange.sendResponseHeaders(200, response.length());
61+
try (OutputStream os = exchange.getResponseBody()) {
62+
os.write(response.getBytes());
63+
}
64+
} else {
65+
// Method not allowed
66+
exchange.sendResponseHeaders(405, -1);
67+
}
68+
}
69+
}
70+
}

library/src/main/java/com/midtrans/snapbi/SnapBi.java

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22

33
import java.nio.charset.StandardCharsets;
44
import java.security.*;
5+
import java.security.spec.X509EncodedKeySpec;
56
import java.time.Instant;
67
import java.util.Base64;
78
import java.util.HashMap;
89
import java.util.Map;
910
import java.security.spec.PKCS8EncodedKeySpec;
10-
import javax.crypto.Mac;
11-
import javax.crypto.spec.SecretKeySpec;
1211

1312
import org.json.JSONObject;
1413

@@ -41,6 +40,10 @@ public class SnapBi {
4140
private String deviceId;
4241
private String debugId;
4342
private String timeStamp;
43+
private String signature;
44+
private String notificationUrlPath;
45+
private String notificationPayload;
46+
4447

4548
public SnapBi(String paymentMethod) {
4649
this.paymentMethod = paymentMethod;
@@ -59,6 +62,10 @@ public static SnapBi qris() {
5962
return new SnapBi("qris");
6063
}
6164

65+
public static SnapBi notification() {
66+
return new SnapBi("");
67+
}
68+
6269
public SnapBi withAccessTokenHeader(Map<String, String> headers) {
6370
this.accessTokenHeader.putAll(headers);
6471
return this;
@@ -114,6 +121,26 @@ public SnapBi withDebugId(String debugId) {
114121
return this;
115122
}
116123

124+
public SnapBi withSignature(String signature) {
125+
this.signature = signature;
126+
return this;
127+
}
128+
129+
public SnapBi withTimeStamp(String timeStamp) {
130+
this.timeStamp = timeStamp;
131+
return this;
132+
}
133+
134+
public SnapBi withNotificationUrlPath(String notificationUrlPath) {
135+
this.notificationUrlPath = notificationUrlPath;
136+
return this;
137+
}
138+
139+
public SnapBi withNotificationPayload(String notificationPayload) {
140+
this.notificationPayload = notificationPayload;
141+
return this;
142+
}
143+
117144
public JSONObject createPayment(String externalId) throws Exception {
118145
this.apiPath = setupCreatePaymentApiPath(this.paymentMethod);
119146
return createConnection(externalId);
@@ -134,6 +161,43 @@ public JSONObject getStatus(String externalId) throws Exception {
134161
return createConnection(externalId);
135162
}
136163

164+
public Boolean isWebhookNotificationVerified() throws Exception {
165+
if (SnapBiConfig.getSnapBiPublicKey() == null || SnapBiConfig.getSnapBiPublicKey().trim().isEmpty()) {
166+
throw new IllegalStateException
167+
("The public key is null, You need to set the public key from SnapBiConfig.' .\n" +
168+
"For more details contact support at support@midtrans.com if you have any questions.");
169+
}
170+
String minifiedBody = minifyJson(this.notificationPayload);
171+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
172+
byte[] hashedNotificationBodyJsonString = digest.digest(minifiedBody.getBytes());
173+
String hashedNotificationBodyJsonStringHex = bytesToHex(hashedNotificationBodyJsonString)
174+
.toLowerCase();
175+
String rawStringDataToVerifyAgainstSignature = "POST" +
176+
":" +
177+
this.notificationUrlPath +
178+
":" +
179+
hashedNotificationBodyJsonStringHex +
180+
":" +
181+
this.timeStamp;
182+
Signature verifier = Signature.getInstance("SHA256withRSA");
183+
verifier.initVerify(getPublicKey(SnapBiConfig.getSnapBiPublicKey()));
184+
verifier.update(rawStringDataToVerifyAgainstSignature.getBytes(StandardCharsets.UTF_8));
185+
boolean isSignatureVerified = verifier.verify(Base64.getDecoder().decode(this.signature));
186+
187+
return isSignatureVerified;
188+
}
189+
190+
private static PublicKey getPublicKey(String publicKeyString) throws Exception {
191+
String publicKeyPEM = publicKeyString
192+
.replace("-----BEGIN PUBLIC KEY-----", "")
193+
.replace("-----END PUBLIC KEY-----", "")
194+
.replaceAll("\\s", "");
195+
byte[] keyBytes = Base64.getDecoder().decode(publicKeyPEM);
196+
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
197+
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
198+
return keyFactory.generatePublic(keySpec);
199+
}
200+
137201
public JSONObject getAccessToken() throws Exception {
138202
Map<String, String> snapBiAccessTokenHeader = buildAccessTokenHeader(this.timeStamp);
139203
Map<String, String> openApiPayload = new HashMap<>();
@@ -172,9 +236,9 @@ private Map<String, String> buildSnapBiTransactionHeader(String externalId, Stri
172236
snapBiTransactionHeader.put("Accept", "application/json");
173237
snapBiTransactionHeader.put("X-PARTNER-ID", SnapBiConfig.getSnapBiPartnerId());
174238
snapBiTransactionHeader.put("X-EXTERNAL-ID", externalId);
175-
snapBiTransactionHeader.put("X-DEVICE-ID", this.deviceId != null ? this.deviceId : "" );
239+
snapBiTransactionHeader.put("X-DEVICE-ID", this.deviceId != null ? this.deviceId : "");
176240
snapBiTransactionHeader.put("CHANNEL-ID", SnapBiConfig.getSnapBiChannelId());
177-
snapBiTransactionHeader.put("debug-id", this.debugId != null ? this.debugId : "" );
241+
snapBiTransactionHeader.put("debug-id", this.debugId != null ? this.debugId : "");
178242
snapBiTransactionHeader.put("Authorization", "Bearer " + this.accessToken);
179243
snapBiTransactionHeader.put("X-TIMESTAMP", timeStamp);
180244
snapBiTransactionHeader.put("X-SIGNATURE", SnapBi.getSymmetricSignatureHmacSh512(
@@ -229,6 +293,7 @@ public static String getSymmetricSignatureHmacSh512(String accessToken, Map<Stri
229293
throw new RuntimeException("Error generating symmetric signature", e);
230294
}
231295
}
296+
232297
private static String minifyJson(String json) {
233298
// Minify JSON by removing whitespace
234299
return json.replaceAll("\\s+", "");
@@ -245,13 +310,15 @@ private static byte[] hmacSha512(String payload, String key) throws NoSuchAlgori
245310
mac.init(secretKeySpec);
246311
return mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
247312
}
313+
248314
private static String bytesToHex(byte[] bytes) {
249315
StringBuilder sb = new StringBuilder();
250316
for (byte b : bytes) {
251317
sb.append(String.format("%02x", b));
252318
}
253319
return sb.toString();
254320
}
321+
255322
public static String getAsymmetricSignatureSha256WithRsa(String clientId, String timeStamp, String privateKey) throws Exception {
256323
String stringToSign = clientId + "|" + timeStamp;
257324

library/src/main/java/com/midtrans/snapbi/SnapBiConfig.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public class SnapBiConfig {
1212
private static String snapBiPartnerId;
1313
private static String snapBiChannelId;
1414
private static boolean enableLogging = false;
15-
15+
private static String snapBiPublicKey;
1616
public static final String SNAP_BI_SANDBOX_BASE_URL = "https://merchants.sbx.midtrans.com";
1717
public static final String SNAP_BI_PRODUCTION_BASE_URL = "https://merchants.midtrans.com";
1818

@@ -65,6 +65,13 @@ public static boolean isEnableLogging() {
6565
public static void setEnableLogging(boolean enableLogging) {
6666
SnapBiConfig.enableLogging = enableLogging;
6767
}
68+
public static String getSnapBiPublicKey() {
69+
return snapBiPublicKey;
70+
}
71+
72+
public static void setSnapBiPublicKey(String snapBiPublicKey) {
73+
SnapBiConfig.snapBiPublicKey = snapBiPublicKey;
74+
}
6875
public static String getSnapBiTransactionBaseUrl() {
6976
return isProduction ? SNAP_BI_PRODUCTION_BASE_URL : SNAP_BI_SANDBOX_BASE_URL;
7077
}

0 commit comments

Comments
 (0)