A lightweight, PostgreSQL-backed event / job queue with HTTP delivery.
Other services enqueue jobs (a job_type + an arbitrary payload + a
callback_url). A background processor picks up each job and delivers it by
calling the job's callback_url over HTTP, retrying with exponential backoff
and recording every delivery attempt.
- A producer enqueues a job via the REST API (or the Go client).
- The job is stored in Postgres with status
pending. - A background processor polls for pending jobs and marks them
processing. - It delivers the job by issuing an HTTP GET to the job's
callback_urlwithjob_typeandpayloadas query parameters:GET <callback_url>?job_type=<type>&payload=<payload> - A
2xxresponse marks the jobdone. Otherwise it retries with exponential backoff (defaults: 3 attempts, 2s initial backoff, 10s HTTP timeout). If all attempts fail, the job is markedfailed. - Every attempt — status code, error, and duration — is recorded so you can inspect delivery history.
Status lifecycle: pending → processing → done (or failed).
Go 1.25 · chi router · pgx/v5 · PostgreSQL · golang-migrate (migrations run automatically on startup).
You need Go 1.25+ and a PostgreSQL database.
# 1. Start Postgres (a docker-compose.yml is provided for local dev)
docker compose up -d
# 2. Configure the database connection
export DATABASE_URL="postgres://events:events@localhost:5432/events?sslmode=disable"
# 3. Run the service (run from the repo root — migrations load from ./db/migrations)
go run ./cmd/serverThe service listens on :3000, runs the processor in the background, and
serves a small HTML dashboard at /. Migrations are applied automatically at
startup.
| Variable | Used by | Description |
|---|---|---|
DATABASE_URL |
the service | Postgres connection string (required) |
POSTGRES_USER |
docker-compose | Postgres username |
POSTGRES_PASSWORD |
docker-compose | Postgres password |
POSTGRES_DB |
docker-compose | Postgres database name |
POSTGRES_CONTAINER_NAME |
docker-compose | Container name for the Postgres service |
POSTGRES_PORT |
docker-compose | Host port mapped to Postgres 5432 |
SERVICE_CONTAINER_NAME |
docker-compose | Container name for the events service |
SERVICE_PORT |
docker-compose | Host port mapped to the events container :3000
|
A local .env is loaded automatically by the service. Example:
DATABASE_URL=postgres://events:events@localhost:5432/events?sslmode=disable
POSTGRES_USER=events
POSTGRES_PASSWORD=events
POSTGRES_DB=events
POSTGRES_CONTAINER_NAME=events-postgresThe provided Dockerfile builds a static binary and copies db/migrations/
and templates/ next to it (both are read at runtime relative to the working
directory), so the image is self-contained. docker-compose.yml (prod) and
docker-compose.staging.yml (staging) run Postgres + the events service and
are driven entirely by a gitignored stack.env so the two stacks coexist in
Portainer. Both join the shared external network
financer-transactions_transactions-network so sibling services (e.g. reports)
can reach the callback endpoint by container name.
Example prod stack.env:
POSTGRES_USER=events
POSTGRES_PASSWORD=change-me
POSTGRES_DB=events
POSTGRES_CONTAINER_NAME=events-postgres
POSTGRES_PORT=5432
SERVICE_CONTAINER_NAME=events
SERVICE_PORT=3000Staging stack.env uses distinct names/ports, e.g.
SERVICE_CONTAINER_NAME=events-staging, SERVICE_PORT=3001,
POSTGRES_CONTAINER_NAME=events-staging-postgres, POSTGRES_PORT=5433.
# prod
docker compose --env-file stack.env up -d --build
# staging
docker compose -f docker-compose.staging.yml --env-file stack.env up -d --buildIn Portainer, create two stacks — one per compose file — each with its own
stack.env values (the staging SERVICE_CONTAINER_NAME / SERVICE_PORT must
differ from prod to avoid container name already in use / port conflicts).
Enqueue a job by POSTing to /api/events:
curl -X POST http://localhost:3000/api/events \
-H "Content-Type: application/json" \
-d '{
"job_type": "email-send",
"payload": "{\"to\":\"user@example.com\",\"template\":\"welcome\"}",
"callback_url": "http://my-service:8080/jobs/email"
}'Response (201 Created):
{
"id": 42,
"job_type": "email-send",
"payload": "{\"to\":\"user@example.com\",\"template\":\"welcome\"}",
"callback_url": "http://my-service:8080/jobs/email",
"status": "pending",
"created_at": "2026-06-26T10:30:00Z"
}The callback contract: when the job runs, the events service calls your
callback_url with an HTTP GET, passing job_type and payload as query
parameters. Return a 2xx status to mark the job done; any other status (or a
network error) triggers a retry. A minimal handler in any framework — here in
Go — looks like:
// Receives: GET /jobs/email?job_type=email-send&payload=%7B...%7D
http.HandleFunc("/jobs/email", func(w http.ResponseWriter, r *http.Request) {
jobType := r.URL.Query().Get("job_type")
payload := r.URL.Query().Get("payload") // your JSON string, url-decoded
if err := doWork(jobType, payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) // -> retried
return
}
w.WriteHeader(http.StatusOK) // 2xx -> job marked "done"
})The repo ships a small Go client in the client package:
package main
import (
"log"
"github.com/ArthurWerle/events/client"
)
func main() {
c := client.New("http://events-service:3000")
event, err := c.Enqueue(
"email-send", // job_type
`{"to":"user@example.com","template":"welcome"}`, // payload (any string)
"http://my-service:8080/jobs/email", // callback_url
)
if err != nil {
log.Fatalf("enqueue failed: %v", err)
}
log.Printf("enqueued job %d with status %s", event.ID, event.Status)
}Importing the Go client from another module: this module is named
github.com/ArthurWerle/events(seego.mod), matching its GitHub repository, so other Go modules cango get github.com/ArthurWerle/eventsandimport "github.com/ArthurWerle/events/client"directly. The REST API above remains the language-agnostic path for non-Go consumers.
Look up jobs by status (defaults to pending):
curl "http://localhost:3000/api/events?status=done"Valid statuses: pending, processing, done, failed.
Inspect the delivery attempts for a specific job:
curl "http://localhost:3000/api/executions?event_id=42"[
{
"id": 1,
"event_id": 42,
"attempted_at": "2026-06-26T10:31:00Z",
"status_code": 200,
"error": null,
"duration_ms": 245
}
]| Method | Path | Description |
|---|---|---|
POST |
/api/events |
Enqueue a job (job_type, payload, callback_url). Returns 201 + the created event. |
GET |
/api/events?status=<status> |
List events by status (defaults to pending). |
GET |
/api/executions?event_id=<id> |
List delivery attempts for an event. |
GET |
/ |
HTML dashboard. |