Java samples
bubbl3 has no Java SDK. These samples call the API with the JDK's own HTTP client
(Java 17 or later, no dependencies) and are checked against the same OpenAPI document as the API
reference on every change. Copy them into your project and use a test key (b3_test_...) and
test numbers while you build, so messages only reach the simulator.
The client
Every call sends your key as Authorization: Bearer <key>. Writes send an Idempotency-Key
named after what you're doing, so a retry after a timeout can't send twice; see
idempotency. The small JSON helper keeps the
samples dependency-free; in your own code, use your JSON library.
package bubbl3sample;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.AbstractMap;
import java.util.Map;
import java.util.StringJoiner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Calls the bubbl3 API with the JDK's HTTP client (Java 17). Use a test key (b3_test_...) while you build. */
public final class Bubbl3Client {
public static final String API_URL = "https://api.bubbl3.com";
private final String apiKey;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
public Bubbl3Client(String apiKey) {
this.apiKey = apiKey;
}
/** A failed call: bubbl3's error body is {"error": {"code", "message", "request_id"}}. */
public static final class ApiException extends IOException {
public final int status;
ApiException(int status, String body) {
super("bubbl3 " + status + " " + Json.string(body, "code") + ": " + Json.string(body, "message")
+ " (request " + Json.string(body, "request_id") + ")");
this.status = status;
}
}
/**
* Sends one request and returns the response body. A write passes an idempotency key named after
* what it does, so a retry after a timeout can't send twice.
*/
public String request(String method, String path, String jsonBody, String idempotencyKey)
throws IOException, InterruptedException {
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(API_URL + path))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + apiKey);
if (jsonBody != null) {
req.header("Content-Type", "application/json");
}
if (idempotencyKey != null) {
req.header("Idempotency-Key", idempotencyKey);
}
req.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> res = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 300) {
throw new ApiException(res.statusCode(), res.body());
}
return res.body();
}
/** Just enough JSON for the samples. In your own code, use your JSON library (Jackson, Gson). */
public static final class Json {
private Json() {}
/** A field; one whose value is null is left out. */
public static Map.Entry<String, Object> field(String name, Object value) {
return new AbstractMap.SimpleImmutableEntry<>(name, value);
}
@SafeVarargs
public static String object(Map.Entry<String, Object>... fields) {
StringJoiner out = new StringJoiner(",", "{", "}");
for (Map.Entry<String, Object> f : fields) {
if (f.getValue() != null) {
out.add(quote(f.getKey()) + ":" + value(f.getValue()));
}
}
return out.toString();
}
private static String value(Object v) {
if (v instanceof String[] list) {
StringJoiner out = new StringJoiner(",", "[", "]");
for (String s : list) {
out.add(quote(s));
}
return out.toString();
}
return quote(String.valueOf(v));
}
public static String quote(String s) {
StringBuilder out = new StringBuilder("\"");
for (char c : s.toCharArray()) {
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
default -> {
if (c < 0x20) {
out.append(String.format("\\u%04x", (int) c));
} else {
out.append(c);
}
}
}
}
return out.append('"').toString();
}
/** The first string value named `name` anywhere in `json`, or null. */
public static String string(String json, String name) {
Matcher m = Pattern.compile("\"" + Pattern.quote(name) + "\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"")
.matcher(json);
return m.find() ? m.group(1) : null;
}
}
}
Send a message
POST /v1/messages/check says first whether bubbl3 would send, hold or refuse the text, and
why. The send itself goes through the same consent, opt-out, quiet-hours and pacing checks
whatever the check said.
package bubbl3sample;
import static bubbl3sample.Bubbl3Client.Json.field;
import static bubbl3sample.Bubbl3Client.Json.object;
import static bubbl3sample.Bubbl3Client.Json.string;
/**
* Checks a text first, then sends it. bubbl3 applies the same consent, opt-out, quiet-hours and
* pacing checks to the send whatever the check said; the check only lets you stop early and say why.
*/
public final class SendMessage {
public static String send(Bubbl3Client client, String to, String body, String idempotencyKey)
throws Exception {
String request = object(field("to", to), field("body", body));
String check = client.request("POST", "/v1/messages/check", request, null);
if ("reject".equals(string(check, "decision"))) {
throw new IllegalStateException("bubbl3 won't send this text: " + string(check, "reason"));
}
String message = client.request("POST", "/v1/messages", request, idempotencyKey);
return string(message, "id") + " " + string(message, "status");
}
/** Texts a test number, which only reaches bubbl3's device simulator. */
public static void main(String[] args) throws Exception {
Bubbl3Client client = new Bubbl3Client(System.getenv("BUBBL3_API_KEY"));
System.out.println(send(client, "+19175550142", "Hi from Java", "order-1042-confirmation"));
}
}
Upsert a contact
Finds the contact by phone number with GET /v1/contacts?phone=, then updates it with
PATCH /v1/contacts/{id} or creates it with POST /v1/contacts. A number that opted out stays
opted out.
package bubbl3sample;
import static bubbl3sample.Bubbl3Client.Json.field;
import static bubbl3sample.Bubbl3Client.Json.object;
import static bubbl3sample.Bubbl3Client.Json.string;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* Finds a contact by phone number and updates it, or creates it. A number that opted out stays
* opted out: nothing here changes consent.
*/
public final class UpsertContact {
public static String upsert(Bubbl3Client client, String phone, String firstName, String email,
String idempotencyKey) throws Exception {
String fields = object(
field("phone", phone),
field("first_name", firstName),
field("email", email),
field("labels", new String[] {"java-sample"}));
String found = client.request(
"GET", "/v1/contacts?phone=" + URLEncoder.encode(phone, StandardCharsets.UTF_8), null, null);
String id = string(found, "id");
String contact = id != null
? client.request("PATCH", "/v1/contacts/" + id, fields, idempotencyKey)
: client.request("POST", "/v1/contacts", fields, idempotencyKey);
return string(contact, "id");
}
public static void main(String[] args) throws Exception {
Bubbl3Client client = new Bubbl3Client(System.getenv("BUBBL3_API_KEY"));
System.out.println(upsert(client, "+19175550143", "Ada", "ada@example.com", "crm-contact-7781"));
}
}
Receive and verify a webhook
The same check as the Node SDK's verifyWebhook: an HMAC-SHA256 of <t>.<raw body> with your
endpoint's secret, compared in constant time with each v1 in the bubbl3-signature header,
and a timestamp within 300 seconds. See the webhooks quick-start for retries
and event ids.
package bubbl3sample;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* Checks a delivery's bubbl3-signature header, "t=<unix seconds>,v1=<hex>", where each v1 is
* HMAC-SHA256(secret, "<t>.<raw body>"). Pass the raw body, before parsing JSON. During a secret
* rotation a delivery carries two v1 values; pass both secrets until you switch.
*/
public final class WebhookVerifier {
public static final String SIGNATURE_HEADER = "bubbl3-signature";
public static final long TOLERANCE_SECONDS = 300;
public static boolean verify(byte[] rawBody, String header, List<String> secrets, Instant now) {
if (header == null || secrets == null || secrets.isEmpty()) {
return false;
}
String t = null;
List<String> signatures = new ArrayList<>();
for (String part : header.split(",")) {
int eq = part.indexOf('=');
if (eq <= 0) {
return false;
}
String key = part.substring(0, eq).trim();
String value = part.substring(eq + 1).trim();
if (key.equals("t")) {
t = value;
} else if (key.equals("v1")) {
signatures.add(value);
}
}
if (t == null || !t.matches("\\d{1,12}") || signatures.isEmpty()) {
return false;
}
if (Math.abs(now.getEpochSecond() - Long.parseLong(t)) > TOLERANCE_SECONDS) {
return false;
}
for (String secret : secrets) {
// A missing secret (an unset environment variable) must never let a delivery through.
if (secret == null || secret.isBlank()) {
return false;
}
}
String signed = t + "." + new String(rawBody, StandardCharsets.UTF_8);
for (String secret : secrets) {
byte[] want = hmac(secret, signed);
for (String signature : signatures) {
try {
if (MessageDigest.isEqual(HexFormat.of().parseHex(signature), want)) {
return true;
}
} catch (IllegalArgumentException notHex) {
// Not this one.
}
}
}
return false;
}
static byte[] hmac(String secret, String text) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
Post a lead to a lead source
A Lead URL (an inbound source of kind webhook in the API) takes leads signed with its own secret, no API key. Send a stable
event_id (or Idempotency-Key) so a retry is the same lead. See
the lead sources quick-start.
package bubbl3sample;
import static bubbl3sample.Bubbl3Client.Json.field;
import static bubbl3sample.Bubbl3Client.Json.object;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* Sends a lead to a webhook lead source, signed with the source's secret. No API key: the
* signature is the proof. The source's consent setting and workflow decide what happens next.
*/
public final class PostLead {
public static void post(String sourceId, String sourceSecret, String eventId, String phone,
String firstName) throws Exception {
String body = object(field("event_id", eventId), field("phone", phone), field("first_name", firstName));
String t = Long.toString(System.currentTimeMillis() / 1000);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(sourceSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String v1 = HexFormat.of().formatHex(mac.doFinal((t + "." + body).getBytes(StandardCharsets.UTF_8)));
HttpRequest req = HttpRequest.newBuilder(URI.create(Bubbl3Client.API_URL + "/v1/inbound/leads/" + sourceId))
.header("Content-Type", "application/json")
.header("bubbl3-signature", "t=" + t + ",v1=" + v1)
.header("Idempotency-Key", eventId)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
throw new IllegalStateException("bubbl3 refused the lead: " + res.statusCode());
}
}
public static void main(String[] args) throws Exception {
post(System.getenv("BUBBL3_SOURCE_ID"), System.getenv("BUBBL3_SOURCE_SECRET"), "order-1042",
"+19175550142", "Ada");
}
}