Like a boomerang — you throw something out and it comes back to you.
You POST a request. Boomerang returns a 202 in under 50 ms. Your handler runs in the background. When it's done, the result flies back to whatever URL you gave it. That's it.
No polling loops. No managing background threads. No reinventing the webhook wheel.
A Spring Boot starter that turns any method into an async, webhook-delivering job. Drop in the dependency, annotate one method, point it at Redis, and you have a production-grade async job system — JWT auth, idempotency, exponential-backoff retries, dead-letter storage, and SSRF protection included.
Client → POST /jobs { callbackUrl } ↓ 202 + jobId (< 50 ms) [background] ↓ your @BoomerangHandler runs ↓ POST callbackUrl { jobId, status, result } Any operation where making the caller wait is the wrong answer:
- Report generation — trigger a PDF/Excel export, get a webhook when the file is ready to download
- Data pipelines — kick off an ETL run, get notified when it finishes (or fails)
- ML inference — submit a batch scoring job, receive results without holding a connection open
- Media processing — transcode a video, resize a batch of images, run OCR on a document
- Third-party API calls — call a slow external service and fan results back when it responds
- Nightly reconciliation — run a scheduled sync job, push a webhook to your ops tooling when done
In each case the pattern is the same: POST a request, get a 202 back instantly, receive the result at your callback URL when the work is done.
1. Add the dependency
Replace
VERSIONwith the latest version shown in the Maven Central badge above.
<dependency> <groupId>io.github.sameerchereddy</groupId> <artifactId>boomerang-starter</artifactId> <version>VERSION</version> </dependency>2. Enable Boomerang
@SpringBootApplication @EnableBoomerang public class ReportingService { public static void main(String[] args) { SpringApplication.run(ReportingService.class, args); } }3. Write your handler
Boomerang requires exactly one @BoomerangHandler in the application. The app will refuse to start if there are zero or more than one.
@Component public class ReportHandler { @BoomerangHandler public Map<String, Object> generate(SyncContext ctx) { // ctx.getJobId() — JobId value object; call .toString() or .value() for the raw string // ctx.getCallerId() — JWT sub claim (who requested it) // ctx.getTriggeredAt() — Instant the worker picked up this job // ctx.getPayload() — JsonNode of the caller-supplied payload, or null if none was sent // ctx.getMessageVersion() — caller-supplied schema version string, or null if not sent // use this to handle payload shape changes safely mid-queue JsonNode payload = ctx.getPayload(); String version = ctx.getMessageVersion(); // e.g. "v1", "v2", or null String reportType = payload != null ? payload.get("reportType").asText() : "default"; String reportUrl = reportService.generate(reportType); return Map.of("reportUrl", reportUrl, "generatedBy", ctx.getCallerId()); } }4. Configure
The endpoint path defaults to /jobs but you can name it anything that fits your domain:
spring: data: redis: host: localhost port: 6379 boomerang: base-path: /reports # → POST /reports, GET /reports/{jobId}, etc. auth: jwt-secret: ${BOOMERANG_JWT_SECRET} # min 32 chars, HS256 callback: allowed-domains: - your-service.example.com # SSRF allowlist5. Call it
curl -X POST http://localhost:8080/reports \ -H "Authorization: Bearer <jwt>" \ -H "Content-Type: application/json" \ -d '{ "callbackUrl": "https://your-service.example.com/hooks/report-ready", "callbackSecret": "optional-hmac-secret-min-32-chars!!", "idempotencyKey": "optional-dedup-key", "payload": { "reportType": "monthly-revenue", "month": "2026-02" } }' # → 202 { "jobId": "a1b2c3..." }| Field | Type | Required | Description |
|---|---|---|---|
callbackUrl | string | Yes | HTTPS URL to POST the result to once the job completes |
callbackSecret | string | No | Min 32 chars. When present, Boomerang adds X-Signature-SHA256 to the callback |
idempotencyKey | string | No | Max 128 chars. Re-using a key within the cooldown window returns 409 Conflict |
payload | object | No | Arbitrary JSON passed through to SyncContext#getPayload() as a JsonNode |
messageVersion | string | No | Max 64 chars. Schema version of the payload (e.g. "v1"). Available in SyncContext#getMessageVersion() so handlers can adapt to schema changes mid-queue |
Your callback receives a POST when the job completes:
{ "jobId": "a1b2c3...", "status": "DONE", "result": { "reportUrl": "https://storage.example.com/reports/monthly-revenue-2026-02.pdf", "generatedBy": "user@example.com" }, "completedAt": "2026-03-22T10:15:30Z" }| Feature | Details |
|---|---|
| JWT auth | HS256 Bearer token required on all endpoints |
| Idempotency | Same caller can't enqueue twice within the cooldown window — 409 Conflict |
| Webhook retries | Exponential backoff, configurable attempts and intervals |
| HMAC signing | Optional X-Signature-SHA256 on every callback |
| SSRF protection | Callback URLs validated against an allowlist; RFC-1918 ranges blocked |
| Dead-letter store | Failed webhooks stored in Redis with replay and delete endpoints |
| Status polling | GET /{base-path}/{jobId} — only visible to the job's creator |
| Metrics | Micrometer counters and timers for jobs, webhooks, queue depth |
All paths are relative to boomerang.base-path (default /jobs). All endpoints require Authorization: Bearer <jwt>.
| Method | Path | Description |
|---|---|---|
POST | /{base-path} | Enqueue a job — returns 202 { jobId } |
GET | /{base-path}/{jobId} | Poll job status (PENDING, IN_PROGRESS, DONE, FAILED) |
GET | /{base-path}/failed-webhooks | List all dead-lettered webhook deliveries |
POST | /{base-path}/failed-webhooks/{jobId}/replay | Re-attempt a failed delivery |
DELETE | /{base-path}/failed-webhooks/{jobId} | Discard a failed delivery |
boomerang: base-path: /jobs # URL prefix for all endpoints — rename to match your domain auth: jwt-secret: "" # required; min 32 chars callback: allowed-domains: [] # e.g. ["example.com"] skip-validation: false # bypass SSRF checks in dev/test idempotency: cooldown-seconds: 300 webhook: max-attempts: 5 initial-backoff-ms: 1000 max-backoff-ms: 30000 thread-pool: core-size: 5 max-size: 20 queue-capacity: 100 job-ttl-days: 7 failed-webhook-ttl-days: 30mvn package -pl boomerang-sample -am -DskipTests cd boomerang-sample && docker compose upBoomerang doesn't issue JWTs — bring your own. Generate one with jwt-cli:
JWT=$(jwt encode --secret "boomerang-dev-secret-key-min-32-chars!!" --sub demo) curl -X POST http://localhost:8080/jobs \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{"callbackUrl":"https://webhook.site/your-unique-url"}'Add boomerang-tests as a test dependency to get a base class with a containerised Redis, WireMock, and JWT helpers:
<dependency> <groupId>io.github.sameerchereddy</groupId> <artifactId>boomerang-tests</artifactId> <version>VERSION</version> <scope>test</scope> </dependency>@SpringBootTest(webEnvironment = RANDOM_PORT, classes = MyApp.class) class MySyncTest extends BoomerangIntegrationTestBase { @Test void callbackDelivered() { stubCallbackUrl("/hooks/done"); // trigger, then... Awaitility.await().untilAsserted(() -> verifyCallbackReceived("/hooks/done")); } }See boomerang-tests/README.md for full details.
| Module | Purpose |
|---|---|
boomerang-core | Shared model and annotation |
boomerang-redis | Redis-backed job store, queue, and webhook store |
boomerang-starter | Auto-configuration, controller, service layer |
boomerang-sample | Runnable sample application |
boomerang-tests | Integration test base class for consumers |
Tag a commit — the release pipeline handles the rest:
git tag vX.Y.Z && git push origin vX.Y.ZRequired GitHub secrets: MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_TOKEN, GPG_PRIVATE_KEY, GPG_PASSPHRASE.
Apache 2.0