Someone brought me a problem that starts out looking like a mapper. Their service receives signed webhooks from several parcel carriers. Every carrier reports the same operational event, but each one names fields, nests data, and describes statuses differently. The immediate request was reasonable: use an agent to write the mapping for the next carrier instead of waiting for a service change.
The next part was the real question: how do you let an agent produce that integration code without giving it a route into the rest of the application? A carrier mapping should not authenticate the webhook, read tenant configuration, call another service, decide whether a duplicate is valid, or write to the database. It should turn one carrier payload into the event shape the service already understands.
Keeping every mapping in Java makes a partner payload change a service release. Giving a script access to CDI turns a small mapping into unreviewed application code. I wanted this to be super simple: an approved, versioned module receives one JSON string and returns one JSON string. Java makes the security and business decisions on both sides of that call.
That is why I use JavaScript here. It lets the small piece of integration logic move separately from the service, while the runtime boundary keeps it from reaching the database, our HTTP client, configuration, or tenant selection.
QuickJS4j gives us that small JavaScript runtime, and its Quarkus extension generates CDI and factory classes from a Java interface at build time. The extension is still marked experimental. Keep the integration narrow for now and run its tests on every upgrade. The published Quarkus extension is version 0.0.3 and is built with Quarkus 3.28.4.
We will build POST /webhooks/parcelbird. It verifies an HMAC signature, sends the raw body to an approved transformer, validates the canonical result, and returns the transformer version and SHA-256 digest with the shipment event. Sending the same event twice returns duplicate.
What You Need
I use Quarkus 3.28.4, Java 21, and io.quarkiverse.quickjs4j:quarkus-quickjs4j:0.0.3. Java 21 is the target release.
Java 21
Quarkus CLI
curl,jq, andopensslBasic Quarkus REST and CDI knowledge
About ☕️☕️☕️☕️☕️ (it’s JavaScript after all. Sigh)
I use two names below. A transformer maps a carrier payload to our canonical event. An approved transformer is a versioned module that passed fixed fixtures before the application accepts traffic.
Create the Application
Create an empty Maven application with Quarkus REST and Jackson or start with the finished example from my repository:
quarkus create app -B \
-P io.quarkus.platform:quarkus-bom:3.28.4 \
--maven \
--java=21 \
--no-code \
--extensions=rest-jackson \
com.themainthread:carrier-webhook-transformer
cd carrier-webhook-transformerquarkus-rest-jackson handles HTTP and JSON. QuickJS4j has its own release cadence, so I pin it separately. In the generated pom.xml add the property and dependency manually:
<!-- Add under the existing <properties> block. -->
<quarkus-quickjs4j.version>0.0.3</quarkus-quickjs4j.version>
<!-- Add under the existing <dependencies> block. -->
<dependency>
<groupId>io.quarkiverse.quickjs4j</groupId>
<artifactId>quarkus-quickjs4j</artifactId>
<version>${quarkus-quickjs4j.version}</version>
</dependency>QuickJS4j generates the proxy and CDI factory for the JavaScript interface below, so add its annotation processor inside the existing maven-compiler-plugin <onfiguration> block:
<annotationProcessorPaths>
<path>
<groupId>io.quarkiverse.quickjs4j</groupId>
<artifactId>quarkus-quickjs4j</artifactId>
<version>${quarkus-quickjs4j.version}</version>
</path>
</annotationProcessorPaths>Give JavaScript One Job
I keep the Java interface small. There is no Java context class on @ScriptInterface, so QuickJS4j generates ScriptInterfaceFactory<CarrierWebhookTransformer, Void>. The module receives no Java host functions.
Create src/main/java/com/themainthread/carrierwebhooks/transform/CarrierWebhookTransformer.java:
package com.themainthread.carrierwebhooks.transform;
import io.roastedroot.quickjs4j.annotations.ScriptInterface;
@ScriptInterface
public interface CarrierWebhookTransformer {
String normalize(String webhookJson);
}The interface accepts one String. Passing domain services or a generic Java object to guest code would give the module a back door. The transformer sees only the webhook body. Java validates the string it gets back.
Create src/main/resources/transformers/parcelbird-2026-08-29.1.js:
const statuses = {
"parcel.accepted": "PICKED_UP",
"parcel.in_transit": "IN_TRANSIT",
"parcel.delivered": "DELIVERED",
"parcel.exception": "EXCEPTION"
};
function normalize(payload) {
const source = JSON.parse(payload);
const status = statuses[source.event];
if (!source.event_id || !source.parcel || !source.parcel.tracking || !source.occurred_at || !status) {
throw new Error("ParcelBird payload is missing a required field");
}
return JSON.stringify({
carrier: "parcelbird",
eventId: source.event_id,
trackingNumber: source.parcel.tracking,
status: status,
occurredAt: source.occurred_at
});
}
export { normalize };QuickJS4j loads this as an ECMAScript module. The exported normalize name must match the Java interface method. The script maps external names into the five fields that Java accepts. It does not decide whether the webhook is authentic or save anything.
Keep the canonical model in plain Java. Create ShipmentStatus.java and NormalizedShipment.java:
package com.themainthread.carrierwebhooks.model;
public enum ShipmentStatus {
PICKED_UP,
IN_TRANSIT,
DELIVERED,
EXCEPTION
}package com.themainthread.carrierwebhooks.model;
import java.time.Instant;
public record NormalizedShipment(
String carrier,
String eventId,
String trackingNumber,
ShipmentStatus status,
Instant occurredAt) {
}I use a string boundary here. The current QuickJS4j high-level API is still developing, and JSON gives Java one compact contract to validate. A transform that returns an extra field or the wrong carrier fails. A live Java object graph would make that boundary harder to inspect and test.
Create CanonicalEventParser.java:
package com.themainthread.carrierwebhooks.transform;
import java.io.IOException;
import java.time.format.DateTimeParseException;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.themainthread.carrierwebhooks.api.WebhookProblem;
import com.themainthread.carrierwebhooks.model.NormalizedShipment;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class CanonicalEventParser {
private static final Set<String> REQUIRED_FIELDS = Set.of("carrier", "eventId", "trackingNumber", "status", "occurredAt");
private final ObjectMapper objectMapper;
public CanonicalEventParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public NormalizedShipment parse(String expectedCarrier, String transformerOutput) {
JsonNode node;
try {
node = objectMapper.readTree(transformerOutput);
} catch (IOException exception) {
throw invalidOutput("The transformer did not return JSON");
}
if (!node.isObject()) {
throw invalidOutput("The transformer must return a JSON object");
}
Set<String> fields = new HashSet<>();
Iterator<String> fieldNames = node.fieldNames();
fieldNames.forEachRemaining(fields::add);
if (!fields.equals(REQUIRED_FIELDS)) {
throw invalidOutput("The transformer output must contain only the canonical shipment fields");
}
try {
NormalizedShipment shipment = objectMapper.treeToValue(node, NormalizedShipment.class);
validate(expectedCarrier, shipment);
return shipment;
} catch (IOException | DateTimeParseException exception) {
throw invalidOutput("The transformer output does not match the canonical shipment schema");
}
}
private void validate(String expectedCarrier, NormalizedShipment shipment) {
if (!expectedCarrier.equals(shipment.carrier())) {
throw invalidOutput("The transformer returned a shipment for another carrier");
}
requireValue(shipment.eventId(), "eventId");
requireValue(shipment.trackingNumber(), "trackingNumber");
if (shipment.status() == null) {
throw invalidOutput("The transformer output is missing status");
}
if (shipment.occurredAt() == null) {
throw invalidOutput("The transformer output is missing occurredAt");
}
}
private void requireValue(String value, String field) {
if (value == null || value.isBlank() || value.length() > 128) {
throw invalidOutput("The transformer output has an invalid " + field);
}
}
private WebhookProblem invalidOutput(String message) {
return new WebhookProblem(422, "invalid_transformer_output", message);
}
}The field-name comparison closes the response contract. Mapping straight into a record would reject missing fields but could ignore a new field, depending on Jackson configuration.
Approve the Module Before It Receives Traffic
I keep approved scripts on the application class path in this example. The request path never downloads code from a partner URL. The registry reads the script, calculates its digest, runs a representative fixture, and then adds it to the carrier map.
Create TransformerDefinition.java and TransformationResult.java:
package com.themainthread.carrierwebhooks.transform;
public record TransformerDefinition(String carrier, String version, String source, String sha256) {
}package com.themainthread.carrierwebhooks.transform;
import com.themainthread.carrierwebhooks.model.NormalizedShipment;
public record TransformationResult(TransformerDefinition definition, NormalizedShipment shipment) {
}Now create ApprovedTransformerRegistry.java:
package com.themainthread.carrierwebhooks.transform;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Map;
import com.themainthread.carrierwebhooks.api.WebhookProblem;
import com.themainthread.carrierwebhooks.model.NormalizedShipment;
import com.themainthread.carrierwebhooks.model.ShipmentStatus;
import io.quarkiverse.quickjs4j.ScriptInterfaceFactory;
import io.quarkus.arc.Unremovable;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
@Unremovable
@ApplicationScoped
public class ApprovedTransformerRegistry {
private final ScriptInterfaceFactory<CarrierWebhookTransformer, Void> factory;
private final CanonicalEventParser parser;
private Map<String, TransformerDefinition> definitions;
public ApprovedTransformerRegistry(
ScriptInterfaceFactory<CarrierWebhookTransformer, Void> factory,
CanonicalEventParser parser) {
this.factory = factory;
this.parser = parser;
}
@PostConstruct
void loadApprovedTransformers() {
String source = readResource("transformers/parcelbird-2026-08-29.1.js");
TransformerDefinition parcelBird = new TransformerDefinition(
"parcelbird",
"parcelbird-2026-08-29.1",
source,
sha256(source));
verifyFixture(parcelBird, """
{"event_id":"pb-1001","parcel":{"tracking":"PB123456"},"event":"parcel.delivered","occurred_at":"2026-08-29T08:15:00Z"}
""", new NormalizedShipment("parcelbird", "pb-1001", "PB123456", ShipmentStatus.DELIVERED,
java.time.Instant.parse("2026-08-29T08:15:00Z")));
definitions = Map.of(parcelBird.carrier(), parcelBird);
}
public TransformationResult transform(String carrier, String payload) {
TransformerDefinition definition = definitions.get(carrier);
if (definition == null) {
throw new WebhookProblem(404, "unknown_carrier", "No approved transformer exists for carrier " + carrier);
}
String transformerOutput;
try {
CarrierWebhookTransformer transformer = factory.create(definition.source(), null);
transformerOutput = transformer.normalize(payload);
} catch (RuntimeException exception) {
throw new WebhookProblem(422, "transformer_failed", "The approved transformer rejected this webhook");
}
return new TransformationResult(definition, parser.parse(carrier, transformerOutput));
}
private void verifyFixture(TransformerDefinition definition, String input, NormalizedShipment expected) {
CarrierWebhookTransformer transformer = factory.create(definition.source(), null);
NormalizedShipment actual = parser.parse(definition.carrier(), transformer.normalize(input));
if (!expected.equals(actual)) {
throw new IllegalStateException("Transformer fixture failed for " + definition.version());
}
}
private String readResource(String location) {
try (InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(location)) {
if (stream == null) {
throw new IllegalStateException("Missing transformer resource " + location);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new IllegalStateException("Could not read transformer resource " + location, exception);
}
}
private String sha256(String source) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("The JVM does not provide SHA-256", exception);
}
}
}factory.create() gives the approval path a factory for a module string. A @ScriptImplementation annotation would package one known script and let CDI inject the interface directly. Here, the factory lets the registry select a specific version. The fixture runs during startup, so a bad approved bundle stops the application before it answers webhooks.
The SHA-256 digest is an identifier, not a signature. It lets an audit record name the exact module that handled an event, even when somebody reuses a friendly version label. In a real promotion flow, store the digest with an approving identity and a signature from the bundle publisher.
Keep Authentication and Idempotency in Java
The carrier signs the raw request body. Verify that signature before parsing JSON or creating the transformer. Add this setting to src/main/resources/application.properties:
quarkus.http.limits.max-body-size=64K
carrier.webhooks.shared-secret=local-demo-secret-change-before-deployThe body limit caps the HTTP input. Put the secret in an environment variable or a secret manager in a deployed service. This value only makes the local command reproducible.
Create WebhookConfiguration.java and WebhookSignatureVerifier.java:
package com.themainthread.carrierwebhooks.config;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "carrier.webhooks")
public interface WebhookConfiguration {
String sharedSecret();
}package com.themainthread.carrierwebhooks.security;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import com.themainthread.carrierwebhooks.api.WebhookProblem;
import com.themainthread.carrierwebhooks.config.WebhookConfiguration;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class WebhookSignatureVerifier {
private static final String HMAC_SHA_256 = "HmacSHA256";
private final WebhookConfiguration configuration;
public WebhookSignatureVerifier(WebhookConfiguration configuration) {
this.configuration = configuration;
}
public void verify(String signature, String payload) {
if (signature == null || !signature.startsWith("sha256=")) {
throw new WebhookProblem(401, "invalid_signature", "A sha256 webhook signature is required");
}
byte[] expected = hmac(payload);
byte[] supplied;
try {
supplied = HexFormat.of().parseHex(signature.substring("sha256=".length()));
} catch (IllegalArgumentException exception) {
throw new WebhookProblem(401, "invalid_signature", "The webhook signature is not hexadecimal");
}
if (!MessageDigest.isEqual(expected, supplied)) {
throw new WebhookProblem(401, "invalid_signature", "The webhook signature does not match the payload");
}
}
private byte[] hmac(String payload) {
try {
Mac mac = Mac.getInstance(HMAC_SHA_256);
mac.init(new SecretKeySpec(configuration.sharedSecret().getBytes(StandardCharsets.UTF_8), HMAC_SHA_256));
return mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
} catch (InvalidKeyException exception) {
throw new IllegalStateException("The configured webhook secret is invalid", exception);
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException("The JVM does not provide HmacSHA256", exception);
}
}
}MessageDigest.isEqual() compares the expected and supplied MAC values without exiting on the first mismatch. The mock carrier uses a shared-secret signature. Use the signature format and key rotation model from the real carrier.
For this demo, the ledger is small and in memory. It makes duplicate delivery visible while we focus on the transform boundary:
package com.themainthread.carrierwebhooks.ledger;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import com.themainthread.carrierwebhooks.model.NormalizedShipment;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class WebhookLedger {
private final ConcurrentMap<String, NormalizedShipment> accepted = new ConcurrentHashMap<>();
public ProcessingResult record(NormalizedShipment shipment) {
String key = shipment.carrier() + ":" + shipment.eventId();
NormalizedShipment previous = accepted.putIfAbsent(key, shipment);
return new ProcessingResult(previous != null);
}
}ConcurrentHashMap.putIfAbsent() makes one JVM accept the first delivery. The map disappears on restart and every replica has its own copy. A deployed endpoint needs a durable table with a unique (carrier, event_id) constraint before it acknowledges a webhook. Keep that constraint in Java. A script should never decide idempotency.
WebhookResource calls the signature verifier, transformer registry, and ledger. The complete project includes the small response and problem records used here:
package com.themainthread.carrierwebhooks.api;
import java.util.Locale;
import com.themainthread.carrierwebhooks.ledger.ProcessingResult;
import com.themainthread.carrierwebhooks.ledger.WebhookLedger;
import com.themainthread.carrierwebhooks.security.WebhookSignatureVerifier;
import com.themainthread.carrierwebhooks.transform.ApprovedTransformerRegistry;
import com.themainthread.carrierwebhooks.transform.TransformationResult;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/webhooks")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class WebhookResource {
private final WebhookSignatureVerifier signatureVerifier;
private final ApprovedTransformerRegistry registry;
private final WebhookLedger ledger;
public WebhookResource(
WebhookSignatureVerifier signatureVerifier,
ApprovedTransformerRegistry registry,
WebhookLedger ledger) {
this.signatureVerifier = signatureVerifier;
this.registry = registry;
this.ledger = ledger;
}
@POST
@Path("/{carrier}")
public Response receive(
@PathParam("carrier") String carrier,
@HeaderParam("X-Carrier-Signature") String signature,
String payload) {
if (payload == null || payload.isBlank()) {
throw new WebhookProblem(400, "empty_payload", "A JSON webhook payload is required");
}
signatureVerifier.verify(signature, payload);
TransformationResult transformation = registry.transform(carrier.toLowerCase(Locale.ROOT), payload);
ProcessingResult processing = ledger.record(transformation.shipment());
WebhookReceipt receipt = new WebhookReceipt(
processing.duplicate() ? "duplicate" : "accepted",
transformation.definition().version(),
transformation.definition().sha256(),
transformation.shipment());
return Response.status(processing.duplicate() ? Response.Status.OK : Response.Status.ACCEPTED)
.entity(receipt)
.build();
}
}Follow a forged event through this method. The HMAC check runs before registry.transform(), so an invalid request never starts the JavaScript runtime. A valid request with an unsupported status reaches the module and returns 422 transformer_failed.
Prove the Boundary
Start Quarkus:
./mvnw quarkus:devRun the included signed request in a second terminal:
./scripts/verify.shThe script signs JSON with the configured local secret, posts it to /webhooks/parcelbird, and checks the response with jq. It prints:
{
"result": "accepted",
"transformerVersion": "parcelbird-2026-08-29.1",
"transformerSha256": "10614b2a36c7fb1243c3ba62340a9d5b672b7a7377b994fed2fa9e254d8406b3",
"shipment": {
"carrier": "parcelbird",
"eventId": "pb-2001",
"trackingNumber": "PB200100",
"status": "IN_TRANSIT",
"occurredAt": "2026-08-29T09:30:00Z"
}
}Now run the test suite:
./mvnw testThe five tests cover signed success, duplicate delivery, a rejected signature, an event the transformer cannot classify, and one capability check. The last test creates a dynamic module that returns typeof fetch. The expected result is undefined.
The test only checks one capability. It shows that the module does not receive the browser network API by default. It does not prove every sandbox property or make unbounded code safe for production. The QuickJS4j project documents its default lack of filesystem and network access, but this application still needs an execution budget before it accepts untrusted code. An infinite loop can consume a request worker. Use runtime limits you have tested for this version, or run the transform in a separate worker with a deadline and memory limit.
Keep the Approval Path Boring
I suggest to keep the production flow simple. A partner or agent can propose a module and fixtures in a pull request. Continuous integration runs the fixture suite, records the source digest, and signs or otherwise approves the bundle. Deployment makes that immutable bundle available to the registry. A request selects an already-approved carrier and version.
Do not fetch JavaScript during a webhook request. That adds a supply-chain call and an availability dependency. It also leaves an incident question that needs a clear answer: which code processed this event? The version and digest in the receipt answer it. The durable audit record should contain those values, the carrier event ID, and the decision outcome.
The same pattern works for incoming queue messages or files. Replace the HTTP signature verifier with sender authentication for that transport, keep the raw input and canonical output boundary, and persist idempotency next to the Java write. The transformer stays a small adapter. It does not become a second application hidden in a .js file.
Conclusion
We built a Quarkus webhook endpoint where QuickJS4j runs a versioned carrier normalizer and Java keeps authentication, schema validation, idempotency, and the final write path. I use JavaScript here only for portable integration code with a small contract. The service still contains the code that decides whether an event is accepted and what happens after it is.


