Build a Digital Credentialing Platform with Quarkus
Issue signed badges, verify them with stable public URLs, and secure partner webhooks with HMAC in one Quarkus application
Most badge systems look simple at first. Store a learner row, attach a PNG, send an email, done. That works until the first real trust question shows up. Who issued this credential? Was it an a partner accidentally, or deliberately, issue 10,000 badges through one weak webhook?
This is where many “badge platforms” stop being platforms and start looking like decorative metadata stores. A credential is closer to an invoice, a certificate, or an audit record. It needs identity, issuer proof, stable URLs, replay protection, and a clean story for revocation. If any of that is missing, the badge still renders nicely in a browser, but it does not hold up when another system tries to trust it.
The production problem is usually not the JSON shape. The production problem is the trust boundary. I have seen systems where anyone with a guessed callback URL could trigger issuance. I have seen systems where the signed artifact and the hosted public JSON disagreed on recipient identity because hashing logic lived in two different classes. I have seen schema changes break partner mappings because the Java embeddable key no longer matched the database primary key.
In this tutorial we build TheMainThread Academy, a single Quarkus application that issues Open Badge 2.0 style credentials. We define badge templates, issue signed assertions, expose verifier-facing JSON at stable URLs, render earner-facing HTML with Qute, and accept signed partner callbacks using HMAC-SHA256. The important part is not only that it works. The important part is that it fails in predictable ways when something is wrong.
The stack is deliberately boring in the right places: Hibernate ORM with Panache for persistence, Flyway for schema control, SmallRye JWT for signing assertions, Quarkus Mailer with Dev Services Mailpit for local email, and PostgreSQL from Dev Services so ./mvnw quarkus:dev is enough to get a working system on a laptop with Podman or Docker.
Prerequisites
You should be comfortable reading JAX-RS resources, JPA entities, and SQL migrations. The steps assume a Unix shell for curl and openssl.
Java 21 installed (the generated module targets release 21)
Maven 3.9+ or the included
./mvnwin the moduleQuarkus CLI optional but recommended (
quarkus create app)Podman or Docker for Dev Services (PostgreSQL and Mailpit)
Project setup
Create the application from the Quarkus CLI so everyone lands on the same extension IDs as the current platform stream.
You can also directly start from my Github repository.
quarkus create app academy.themainthread:badge-platform \
--package-name=academy.themainthread \
-B \
--extensions=rest,rest-jackson,rest-qute,hibernate-orm-panache,jdbc-postgresql,smallrye-jwt,mailer,quarkus-mailpit,qute,hibernate-validator,smallrye-openapi,scheduler,flyway
cd badge-platformExtensions explained:
restandrest-jackson: JSON admin APIs and JacksonObjectMapperfor webhook parsingrest-qute: returnTemplateInstancefrom the same resource classes that serve JSONhibernate-orm-panache: active record style entities for earners, templates, assertions, partnersjdbc-postgresql: production driver plus Agroal pool (Dev Services wires a container automatically)smallrye-jwt: sign assertion JWTs with an RSA private key from the classpathmailer: send award notificationsquarkus-mailpit: Dev Email UI for testingqute: server-side HTML for humanshibernate-validator: request body validation on admin and webhook payloadssmallrye-openapi: Swagger UI for operatorsscheduler: reserved for future housekeeping (expiry sweeps, webhook retries)flyway: versioned schema, no reliance on Hibernate auto-DDL in any profile
Configuration
Create src/main/resources/application.properties with the keys below. Each line matters: missing JWT signing material fails startup, a wrong issuer string breaks interoperability with off-the-shelf verifiers, and an oversized webhook body becomes a cheap DoS handle.
# Datasource — Dev Services starts PostgreSQL in dev and test
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy=none
quarkus.flyway.migrate-at-start=true
# JWT verify (unused on most endpoints today, but keeps SmallRye JWT config consistent)
mp.jwt.verify.issuer=https://academy.themainthread.dev
mp.jwt.verify.public-key.location=META-INF/resources/public.pem
smallrye.jwt.sign.key.location=META-INF/resources/private.pem
smallrye.jwt.new-token.issuer=https://academy.themainthread.dev
smallrye.jwt.new-token.lifespan=315360000
# Canonical base URL embedded in assertion and badge identifiers
academy.base-url=http://localhost:8080
# Mailer — Dev Services Mailpit in dev; mocked in tests
quarkus.mailer.from=badges@academy.themainthread.dev
# OpenAPI
quarkus.smallrye-openapi.info-title=TheMainThread Academy Badge API
quarkus.smallrye-openapi.info-version=1.0.0
# Webhook hardening
quarkus.http.limits.max-body-size=1M
%test.quarkus.mailer.mock=trueEach setting explained:
quarkus.datasource.db-kind=postgresql: selects the PostgreSQL dialect and driver. Without a JDBC URL in dev, Dev Services supplies one. In production you addquarkus.datasource.username,quarkus.datasource.password, andquarkus.datasource.jdbc.url. If those are wrong, the pool never connects and health checks go red instead of silently falling back to H2.quarkus.hibernate-orm.schema-management.strategy=none: Hibernate must not mutate tables at runtime because Flyway owns the truth. If you flip this todrop-and-create, you will eventually run a deploy against real data and delete earners. The olderdatabase.generationkey is deprecated on current Quarkus lines; useschema-management.strategyinstead.quarkus.flyway.migrate-at-start=true: appliesdb/migrationscripts before serving traffic. If a migration fails, the process exits. That is preferable to half-applied manual DDL.mp.jwt.verify.issuerandmp.jwt.verify.public-key.location: align verification settings with what external tooling expects if you later add MP-JWT protected routes. They do not hurt issuance-only flows, but an unreadablepublic.pemfails fast at startup.smallrye.jwt.sign.key.location: path to the RSA private key PEM inside the application jar. If the file is missing, signing fails at runtime when you issue the first badge.smallrye.jwt.new-token.issuerandlifespan: issuer claim and default token lifetime for APIs that mint JWTs. Assertion signing sets its own expiry per row, but the platform property must still be valid seconds.academy.base-url: every hosted assertion URL andverification.creatorpointer is built from this string. If it does not match the hostname clients use, verifiers fetch the wrong host and hosted verification fails even when signatures are valid.quarkus.mailer.from: required envelope sender. Misconfigure SMTP in prod and Mailer throws; in dev, Mailpit accepts anything.quarkus.smallrye-openapi.*: metadata only. Wrong values confuse operators, not runtime.quarkus.http.limits.max-body-size: caps partner webhook bodies. Without a limit, a gzip bomb or megabyte-scale JSON ties up threads and disk.%test.quarkus.mailer.mock=true: keeps@QuarkusTestfrom needing real SMTP while still exercisingMailercalls.
Generate RSA keys once per environment (never reuse demo keys in production):
openssl genrsa -out src/main/resources/META-INF/resources/private.pem 2048
openssl rsa -in src/main/resources/META-INF/resources/private.pem \
-pubout -out src/main/resources/META-INF/resources/public.pemDatabase schema with Flyway
Flyway is the contract between your Java entities and what actually exists in PostgreSQL. Hibernate maps rows; Flyway guarantees indexes, uniqueness, and composite keys survive refactors.
Create src/main/resources/db/migration/V1__initial_schema.sql:
CREATE TABLE earner (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE badge_template (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
criteria TEXT NOT NULL,
image_url VARCHAR(512) NOT NULL,
skills TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE accredited_partner (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
webhook_secret VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE partner_badge_template (
partner_id UUID NOT NULL REFERENCES accredited_partner(id),
course_id VARCHAR(255) NOT NULL,
badge_template_id UUID NOT NULL REFERENCES badge_template(id),
PRIMARY KEY (partner_id, course_id)
);
CREATE TABLE badge_assertion (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
earner_id UUID NOT NULL REFERENCES earner(id),
template_id UUID NOT NULL REFERENCES badge_template(id),
issued_on TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
revoked BOOLEAN NOT NULL DEFAULT false,
revoke_reason VARCHAR(512),
signed_token TEXT NOT NULL,
salt VARCHAR(64) NOT NULL
);
CREATE TABLE webhook_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES accredited_partner(id),
idempotency_key VARCHAR(255) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED',
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
error TEXT,
UNIQUE (partner_id, idempotency_key)
);
CREATE INDEX idx_assertion_earner ON badge_assertion(earner_id);
CREATE INDEX idx_assertion_template ON badge_assertion(template_id);
CREATE INDEX idx_webhook_status ON webhook_event(status);The composite primary key on partner_badge_template is (partner_id, course_id). That matches how partners think (their course catalog), and it forces the JPA embeddable id to carry partnerId plus courseId, not badgeTemplateId. A mismatch here is the kind of bug that passes code review and explodes the first time two templates share a course code.
Implementation: domain model
Panache active record keeps the tutorial focused on behavior instead of repository interfaces. Each entity is a PanacheEntityBase subclass under academy.themainthread.domain, mirroring the Flyway tables. Earner, BadgeTemplate, BadgeAssertion, AccreditedPartner, and WebhookEvent follow the shapes shown in the repository. The two pieces that deserve extra attention in prose are the partner mapping and the assertion row.
PartnerBadgeTemplate embeds PartnerBadgeTemplateId with partnerId and courseId columns. badge_template_id is a normal foreign key column on the entity, not part of the primary key. findByCourseId is a typed query on those columns. If you model the embeddable with badgeTemplateId instead while the database uses course_id in the primary key, Hibernate will compile and your integration tests will fail in confusing ways.
BadgeAssertion uses an application-assigned UUID primary key. The signing step needs the final assertion URL before insert, and PostgreSQL rejects a nullable signed_token column. The production code therefore assigns assertion.id = UUID.randomUUID(), computes signedToken, then calls persist() once. A two-step “insert with null token, update later” pattern fails the NOT NULL constraint the moment Hibernate flushes the first insert.
The listing below is the partner mapping that must agree with the SQL primary key. Everything else in academy.themainthread.domain matches the Flyway tables line for line in the repository.
package academy.themainthread.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;
import java.util.UUID;
@Embeddable
public class PartnerBadgeTemplateId implements Serializable {
@Column(name = "partner_id", nullable = false)
public UUID partnerId;
@Column(name = "course_id", nullable = false, length = 255)
public String courseId;
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
PartnerBadgeTemplateId that = (PartnerBadgeTemplateId) o;
return Objects.equals(partnerId, that.partnerId) && Objects.equals(courseId, that.courseId);
}
@Override
public int hashCode() {
return Objects.hash(partnerId, courseId);
}
}package academy.themainthread.domain;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
@Entity
@Table(name = "partner_badge_template")
public class PartnerBadgeTemplate extends PanacheEntityBase {
@EmbeddedId
public PartnerBadgeTemplateId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("partnerId")
@JoinColumn(name = "partner_id")
public AccreditedPartner partner;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "badge_template_id", nullable = false)
public BadgeTemplate template;
public static PartnerBadgeTemplate findByCourseId(AccreditedPartner partner, String courseId) {
return find("id.partnerId = ?1 AND id.courseId = ?2", partner.id, courseId).firstResult();
}
}Implementation: recipient hashing and JWT signing
Hosted Open Badge flows expect a recipient object with type, hashed, salt, and identity where identity is sha256$ plus a lowercase hex digest of the salted email. The JWT and the public JSON endpoint must agree on that string or verifiers cannot correlate machine-readable and human-readable views.
RecipientIdentity centralizes the digest. AssertionSigner builds the JWT claims map, pulls academy.base-url for every URL-shaped claim, and caps JWT expiry using either the assertion’s expiresAt or a far-future default. Using Long.MAX_VALUE as an epoch second is a bad fit for JWT libraries and some parsers; the implementation clamps to roughly ten years when no explicit expiry is set.
Implementation: issuance and events
BadgeIssuanceService is @ApplicationScoped and transactional. It wires AssertionSigner and fires BadgeIssuedEvent after persistence so mail observers see a stable assertion id. The transaction boundary here is only the database. Mail delivery and external HTTP calls are not rolled back if SMTP later fails, which is why BadgeAwardMailer catches exceptions per message and logs them instead of pretending email is transactional.
package academy.themainthread.badge;
import academy.themainthread.domain.BadgeAssertion;
import academy.themainthread.domain.BadgeTemplate;
import academy.themainthread.domain.Earner;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
@ApplicationScoped
public class BadgeIssuanceService {
@Inject
AssertionSigner signer;
@Inject
Event<BadgeIssuedEvent> badgeIssuedEvent;
@Transactional
public BadgeAssertion issue(Earner earner, BadgeTemplate template, Instant expiresAt) {
BadgeAssertion assertion = new BadgeAssertion();
assertion.id = UUID.randomUUID();
assertion.earner = earner;
assertion.template = template;
assertion.issuedOn = Instant.now();
assertion.expiresAt = expiresAt;
assertion.salt = AssertionSigner.generateSalt();
assertion.signedToken = signer.sign(assertion);
assertion.persist();
badgeIssuedEvent.fire(new BadgeIssuedEvent(assertion.id, earner.email, earner.name, template.name));
return assertion;
}
@Transactional
public BadgeAssertion issueWithDefaultExpiry(Earner earner, BadgeTemplate template) {
Instant expires = Instant.now().plus(365L * 2L, ChronoUnit.DAYS);
return issue(earner, template, expires);
}
@Transactional
public void revoke(UUID assertionId, String reason) {
BadgeAssertion assertion = BadgeAssertion.findById(assertionId);
if (assertion == null) {
throw new IllegalArgumentException("Assertion not found: " + assertionId);
}
assertion.revoked = true;
assertion.revokeReason = reason;
assertion.persist();
}
}The issue method is the heart of the trust story. A single persist() after signing avoids a flush that writes signed_token = null, which PostgreSQL rejects. Firing BadgeIssuedEvent after persist() means downstream code can safely build URLs that hit the database.
Implementation: admin REST API
AdminResource under /admin exposes JSON endpoints for templates, earners, manual issuance, partners, and course mappings. Responses use real HTTP status codes: 409 when an earner email already exists or when a webhook replay hits the same idempotency key, 404 when foreign keys do not resolve.
The admin API is intentionally unauthenticated in this repository so the article stays inside one service. Production hardening below calls out what has to change before you expose it past localhost.
Implementation: public verification, JSON, and Qute
PublicResource serves /assertions/{id}, /badges/{id}, /earners/{id}, and /keys/1. For assertions and badges, the same paths return HTML when the client prefers text/html and JSON-LD shaped maps when the client sends Accept: application/json. Qute templates live under src/main/resources/templates/ and share layout.html.
The /keys/1 handler reads META-INF/resources/public.pem from the classpath and returns JSON with a publicKeyPem field. That is enough for readers to wire real JWK publishing later; the important part for this tutorial is that verifiers have a stable URL that returns the public half of the signing key material.
Implementation: webhook ingestion and processing
Three classes split the work so transactions behave honestly.
WebhookIngestionService exposes a single @Transactional method that inserts a WebhookEvent row. WebhookResource validates the HMAC signature, parses JSON with Jackson, validates Bean Validation constraints, checks that partnerId inside the JSON matches the X-Partner-Id header, rejects duplicates with 409, then calls the ingestion service and only afterwards fires CourseCompletionEvent.
Splitting persistence this way matters. If you fire a CDI event while still inside the same transaction that created the row, an @ObservesAsync listener can start before commit and not see the insert. The ingestion service completes and commits before fire(), so synchronous or asynchronous observers see committed data.
CourseCompletionObserver uses @Observes (synchronous) with @Transactional(TxType.REQUIRES_NEW) so it opens a clean transaction for badge issuance and webhook status updates. @ObservesAsync is attractive for a 202 Accepted story, but without a message broker you still need strict ordering between “row visible” and “handler runs”. The mail path keeps @ObservesAsync on BadgeAwardMailer so HTTP threads are not blocked on SMTP.
HmacVerifier computes HmacSHA256 over the raw bytes the partner signed and compares digests with MessageDigest.isEqual to avoid timing leaks from String.equals.
package academy.themainthread.webhook;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public final class HmacVerifier {
private HmacVerifier() {}
public static boolean verify(String payload, String signature, String secret) {
if (signature == null || !signature.startsWith("sha256=")) {
return false;
}
String provided = signature.substring(7);
String computed = compute(payload, secret);
return MessageDigest.isEqual(
provided.getBytes(StandardCharsets.UTF_8), computed.getBytes(StandardCharsets.UTF_8));
}
public static String compute(String payload, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalStateException("HMAC-SHA256 failed", e);
}
}
}WebhookIngestionService is intentionally tiny. It gives you one transaction that ends before CourseCompletionEvent fires, which keeps observers honest whether they are synchronous or asynchronous.
package academy.themainthread.webhook;
import academy.themainthread.domain.AccreditedPartner;
import academy.themainthread.domain.WebhookEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class WebhookIngestionService {
@Transactional
public WebhookEvent recordReceived(AccreditedPartner partner, String idempotencyKey, String rawBody) {
WebhookEvent event = new WebhookEvent();
event.partner = partner;
event.idempotencyKey = idempotencyKey;
event.payload = rawBody;
event.status = WebhookEvent.Status.RECEIVED;
event.persist();
return event;
}
}Implementation: mail notifications
BadgeAwardMailer listens for BadgeIssuedEvent with @ObservesAsync, builds simple HTML, and calls Mailer.send. In tests, %test.quarkus.mailer.mock=true records messages without network I/O.
Production hardening
Webhook abuse and partner trust
Partners authenticate with a shared secret and an HMAC over the exact raw body bytes. If you normalize JSON (pretty print, reorder keys) before verifying, signatures that were valid on the partner side will fail on yours. The resource method takes String rawBody intentionally. Rate limiting, IP allow lists, and per-partner quotas belong in an API gateway or filter in front of this resource. The quarkus.http.limits.max-body-size property is only a coarse backstop.
Admin surface and OIDC
Every admin endpoint is public in this demo. In production you terminate TLS at your edge, require OIDC (for example Quarkus quarkus-oidc) or mutual TLS for automation, and narrow CORS. Until then, treat localhost as the trust boundary.
Assertion privacy and rotation
Recipient email hashing protects casual scraping, but anyone who knows the email and salt can recompute the digest. Treat salts as disclosure-sensitive metadata, not a second password. Plan for key rotation by versioning /keys/{n} and keeping old public keys available until assertions signed with them expire.
Verification
Automated integration test
From badge-platform:
./mvnw testThe AcademyWorkflowTest class posts a template, registers a partner, maps a course, sends a signed webhook, then polls /admin/assertions until an assertion exists. It finally requests public JSON for the assertion and checks that recipient.identity contains the sha256$ prefix, and that /keys/1 returns PEM material.
You should see Quarkus start with the test profile, Flyway apply V1__initial_schema.sql, tests pass, and the JVM exit code 0.
Manual curl walkthrough
Start dev mode (this blocks; use a second terminal for curls):
./mvnw quarkus:devCapture IDs in shell variables so you never paste the literal string TEMPLATE_ID into JSON (that value is not a UUID, so Bean Validation rejects the request and no course mapping is stored). Without a mapping, the webhook still returns 202 Accepted because ingestion succeeded, but no assertion is issued and jq '.[0].earner.id' returns null either because the assertions list is empty or because [0] does not exist.
Create a badge template and store its id:
export TEMPLATE_ID="$(
curl -sS -X POST http://localhost:8080/admin/badges \
-H "Content-Type: application/json" \
-d '{
"name": "Quarkus Developer",
"description": "Awarded to developers who demonstrate proficiency in building cloud-native Java applications with Quarkus.",
"criteria": "Complete the Quarkus Fundamentals course and pass the practical assessment with a score of 80% or higher.",
"imageUrl": "https://design.jboss.org/quarkus/logo/final/SVG/quarkus_icon_rgb_default.svg",
"skills": "Quarkus,Java,Cloud-Native,Kubernetes,REST"
}' | jq -r .id
)"
echo "TEMPLATE_ID=$TEMPLATE_ID"Register a partner:
export PARTNER_ID="$(
curl -sS -X POST http://localhost:8080/admin/partners \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Training Platform",
"webhookSecret": "super-secret-signing-key-change-in-production"
}' | jq -r .id
)"
echo "PARTNER_ID=$PARTNER_ID"Map the partner’s course id to that template (this call must return HTTP 200 with a JSON body, not a validation error):
curl -sS -i -X POST "http://localhost:8080/admin/partners/${PARTNER_ID}/courses" \
-H "Content-Type: application/json" \
-d "{\"templateId\":\"${TEMPLATE_ID}\",\"courseId\":\"QUARKUS-FUND-101\"}"Send a signed webhook. The payload bytes must match what you pass to openssl dgst exactly (same partnerId, same courseId, same idempotencyKey if you retry):
export PAYLOAD='{"partnerId":"'"$PARTNER_ID"'","courseId":"QUARKUS-FUND-101","learnerEmail":"alice@example.com","learnerName":"Alice Smith","completedAt":"2026-04-06T14:00:00Z","idempotencyKey":"evt-001"}'
export SIG="sha256=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "super-secret-signing-key-change-in-production" | awk '{print $2}')"
curl -sS -i -X POST http://localhost:8080/webhooks/completions \
-H "Content-Type: application/json" \
-H "X-Partner-Id: $PARTNER_ID" \
-H "X-Webhook-Signature: $SIG" \
-d "$PAYLOAD"Expect HTTP/1.1 202 Accepted with a JSON body containing "status":"accepted" and an eventId.
Confirm at least one assertion exists, then read Alice’s earner id:
curl -sS http://localhost:8080/admin/assertions | jq 'length'
curl -sS http://localhost:8080/admin/assertions | jq '.[0].earner.id'If length is 0, the mapping step did not persist (wrong templateId, wrong partner path, or course id mismatch). If length is at least 1 and the second line is still null, open the raw JSON with jq .[0] and confirm earner is present (current code loads earner and template with JOIN FETCH for this list endpoint).
Open http://localhost:8080/earners/{id} in a browser for HTML, or fetch JSON for machines:
curl -sS -H 'Accept: application/json' http://localhost:8080/assertions/ASSERTION_ID | jq .Swagger UI is at http://localhost:8080/q/swagger-ui, Mailpit at http://localhost:8080/q/mailpit/.
Conclusion
We built a credentialing platform that treats badges as trust artifacts, not decorative images. Flyway owns the schema, the issuer signs before insert, public JSON and JWT claims share one recipient identity flow, webhook ingestion is authenticated and idempotent, and mail stays outside the critical transaction. Those are the details that make the system hold up once real partners and real verifiers start touching it.



