diff --git a/api/README.md b/api/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..010c529cf90d9feaa49d2e37b4b2c878c83fdcc3
--- /dev/null
+++ b/api/README.md
@@ -0,0 +1,25 @@
+# TO TEST
+- mage run and local run
+- claims
+- ctx values, read from handler and set in handler then retrieve later in handler
+
+# TODO
+- sqs
+- cron
+- combined local service with api,sqs and cron
+- use in v3 and shiplogic
+- config for local running
+- db connection
+
+
+- when handler returns an error to indicate HTTP code, then it is wrapped and user gets:
+
+```{"message":"error from handler, because user not found HTTP(404:Not Found), because user does not exist"}``` instead of just:
+```{"message":"user does not exist"}```
+
+- api-docs not yet working here - and need to provide HTML option or per-endpoint options at least
+
+- add path parameters, e.g. /user/{user_id} - make sure it works the same way as lambda does - but we're not using it at present, I assume because the old simple map[][] router did not support it.
+- load config for local running
+- document with examples and templates
+- scheduled tasks from the db or some other scheduler from AWS?
\ No newline at end of file
diff --git a/api/api.go b/api/api.go
new file mode 100644
index 0000000000000000000000000000000000000000..5fcf8ca2a6d8c390533fe8dcd143726efa6d9c30
--- /dev/null
+++ b/api/api.go
@@ -0,0 +1,202 @@
+package api
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+	"regexp"
+	"sync"
+	"time"
+
+	"github.com/aws/aws-lambda-go/events"
+	"github.com/aws/aws-lambda-go/lambda"
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+	"gitlab.com/uafrica/go-utils/queues"
+	queues_mem "gitlab.com/uafrica/go-utils/queues/mem"
+	queues_sqs "gitlab.com/uafrica/go-utils/queues/sqs"
+	"gitlab.com/uafrica/go-utils/service"
+)
+
+//LEGACY: global variable is set only for backward compatibility
+//When handlers are changed to accept context, they should get this from the context
+var CurrentRequestID *string
+
+//New creates the API with the specified routes keys on [path][method]
+//value could be any of the handler function signatures supported by the api.Router
+//requestIDHeaderKey is defined in the response header to match the requestID from the request
+func New(requestIDHeaderKey string, routes map[string]map[string]interface{}) Api {
+	env := os.Getenv("ENVIRONMENT") //todo: support config loading for local dev and env for lambda in prod
+	if env == "" {
+		env = "dev"
+	}
+	if requestIDHeaderKey == "" {
+		requestIDHeaderKey = "request-id"
+	}
+
+	router, err := NewRouter(routes)
+	if err != nil {
+		panic(fmt.Sprintf("cannot create router: %+v", err))
+	}
+
+	return Api{
+		ILogger:            logger.New().WithFields(map[string]interface{}{"env": env}),
+		env:                env,
+		router:             router,
+		requestIDHeaderKey: requestIDHeaderKey,
+		checks:             []check{},
+		crashReporter:      defaultCrashReporter{},
+		auditor:            noAudit{},
+	}
+}
+
+type Api struct {
+	logger.ILogger     //for logging outside of context
+	env                string
+	router             Router
+	requestIDHeaderKey string
+
+	//options:
+	localPort     int //==0 for lambda, >0 for http.ListenAndServe
+	eventHandlers map[string]interface{}
+	crashReporter ICrashReporter
+	cors          ICORS
+	producer      queues.IProducer
+	dbConn        service.IDatabaseConnector
+	checks        []check
+	auditor       IAuditor
+}
+
+//checks are executed at the start of each request
+//to stop processing, return an error, preferably with an HTTP status code (see errors.HTTP(...))
+//all values returned will be logged and added to the context for retrieval by any handler
+//the values are added with the check's name as prefix: "<check.name>_<value.name>"
+//and cannot be changed afterwards
+type IChecker interface {
+	Check(req events.APIGatewayProxyRequest) (values map[string]interface{}, err error)
+}
+
+type check struct {
+	name    string
+	checker IChecker
+}
+
+const namePattern = `[a-z]([a-z0-9_]*[a-z0-9])*`
+
+var nameRegex = regexp.MustCompile("^" + namePattern + "$")
+
+//check name must be lowercase with optional underscores, e.g. "rate_limiter" or "claims"
+func (app Api) WithCheck(name string, checker IChecker) Api {
+	if !nameRegex.MatchString(name) {
+		panic(errors.Errorf("invalid name for check(%s)", name))
+	}
+	if checker == nil {
+		panic(errors.Errorf("check(%s) is nil", name))
+	}
+	for _, check := range app.checks {
+		if check.name == name {
+			panic(errors.Errorf("check(%s) already registered", name))
+		}
+	}
+	app.checks = append(app.checks, check{name: name, checker: checker})
+	return app
+}
+
+func (api Api) WithDb(dbConn service.IDatabaseConnector) Api {
+	api.dbConn = dbConn
+	return api
+}
+
+func (api Api) WithCORS(cors ICORS) Api {
+	api.cors = cors
+	return api
+}
+
+func (api Api) WithProducer(producer queues.IProducer) Api {
+	api.producer = producer
+	return api
+}
+
+type ICORS interface {
+	CORS() map[string]string //return CORS headers
+}
+
+func (api Api) WithCrashReported(crashReporter ICrashReporter) Api {
+	if crashReporter != nil {
+		api.crashReporter = crashReporter
+	}
+	return api
+}
+
+type ICrashReporter interface {
+	Catch(ctx Context) //Report(method string, path string, crash interface{})
+}
+
+func (api Api) WithLocalPort(localPortPtr *int, eventHandlers map[string]interface{}) Api {
+	if localPortPtr != nil && *localPortPtr > 0 {
+		api.localPort = *localPortPtr
+	}
+	api.eventHandlers = eventHandlers
+	return api
+}
+
+//run and panic on error
+func (api Api) Run() {
+	//decide local of lambda
+	if api.localPort > 0 {
+		//running locally with standard HTTP server
+
+		if api.eventHandlers != nil {
+			//when running locally - we want to send and process SQS events locally using channels
+			//here we create a SQS chan and start listening to it
+			//again: this is quick hack... will make this part of framework once it works well
+			api.Debugf("Creating local queue consumer/producer...")
+			memConsumer := queues_mem.NewConsumer(api.eventHandlers)
+			api.producer = queues_mem.NewProducer(memConsumer)
+
+			sqsEventChan := make(chan events.SQSEvent)
+			sqsWaitGroup := sync.WaitGroup{}
+			sqsWaitGroup.Add(1)
+			go func() {
+				for event := range sqsEventChan {
+					logger.Debug("NOT YET PROCESSING SQS Event: %+v", event)
+				}
+				sqsWaitGroup.Done()
+			}()
+
+			//when we terminate, close the sqs chan and wait for it to complete processing
+			defer func() {
+				close(sqsEventChan)
+				sqsWaitGroup.Wait()
+			}()
+		} else {
+			//use SQS for events
+			api.producer = queues_sqs.NewProducer(api.requestIDHeaderKey)
+		}
+
+		err := http.ListenAndServe(fmt.Sprintf(":%d", api.localPort), api) //calls app.ServeHTTP() which calls app.Handler()
+		if err != nil {
+			panic(err)
+		}
+	} else {
+		api.producer = queues_sqs.NewProducer(api.requestIDHeaderKey)
+		lambda.Start(api.Handler) //calls app.Handler directly
+	}
+}
+
+type defaultCrashReporter struct{}
+
+func (defaultCrashReporter) Catch(ctx Context) {
+	// crash := recover()
+	// if crash != nil {
+	// 	ctx.Errorf("CRASH: (%T) %+v\n", crash, crash)
+	// }
+}
+
+type IAuditor interface {
+	Audit(startTime, endTime time.Time, values map[string]interface{})
+}
+
+type noAudit struct{}
+
+func (noAudit) Audit(startTime, endTime time.Time, values map[string]interface{}) {} //do nothing
diff --git a/api/context.go b/api/context.go
new file mode 100644
index 0000000000000000000000000000000000000000..ec961f0e43ded2b0629d785900922002048f4370
--- /dev/null
+++ b/api/context.go
@@ -0,0 +1,239 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"reflect"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/aws/aws-lambda-go/events"
+	"github.com/uptrace/bun"
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+	"gitlab.com/uafrica/go-utils/queues"
+	"gitlab.com/uafrica/go-utils/service"
+)
+
+type IContext interface {
+	context.Context
+	logger.ILogger
+	queues.IProducer
+	StartTime() time.Time
+	MillisecondsSinceStart() int64
+	CheckValues(checkName string) interface{}
+	CheckValue(checkName, valueName string) interface{}
+}
+
+type Context struct {
+	service.Context
+	queues.IProducer
+	Request          events.APIGatewayProxyRequest
+	RequestID        string
+	ValuesFromChecks map[string]map[string]interface{} //also in context.Value(), but cannot retrieve iteratively from there for logging...
+	DB               *bun.DB
+}
+
+func (ctx Context) CheckValues(checkName string) interface{} {
+	if cv, ok := ctx.ValuesFromChecks[checkName]; ok {
+		return cv
+	}
+	return nil
+}
+
+func (ctx Context) CheckValue(checkName, valueName string) interface{} {
+	if cv, ok := ctx.ValuesFromChecks[checkName]; ok {
+		if v, ok := cv[valueName]; ok {
+			return v
+		}
+	}
+	return nil
+}
+
+// func (ctx Context) Audit(org, new interface{}, eventType types.AuditEventType) {
+// 	//call old function for now - should become part of context ONLY
+// 	audit.SaveAuditEvent(org, new, ctx.Claims, eventType, &ctx.RequestID)
+// }
+
+//todo: change to be a ctx method that defer to log so it does not have to be called explicitly
+//it should also capture metrics for the handler and automaticlaly write the audit record,
+//(but still allow for audit to be suppressed may be in some cases)
+func (ctx Context) LogAPIRequestAndResponse(res events.APIGatewayProxyResponse, err error) {
+	fields := map[string]interface{}{
+		"path":                   ctx.Request.Path,
+		"method":                 ctx.Request.HTTPMethod,
+		"status_code":            res.StatusCode,
+		"api_gateway_request_id": ctx.RequestID,
+	}
+
+	if ctx.Request.HTTPMethod == "GET" {
+		fields["req-query"] = ctx.Request.QueryStringParameters
+	}
+
+	statusOK := res.StatusCode >= 200 && res.StatusCode <= 299
+	if err != nil || !statusOK {
+		fields["error"] = err
+		fields["req-body"] = ctx.Request.Body
+		fields["req-query"] = ctx.Request.QueryStringParameters
+		fields["res-body"] = res.Body
+		for checkName, checkValues := range ctx.ValuesFromChecks {
+			for name, value := range checkValues {
+				fields[checkName+"_"+name] = value
+			}
+		}
+	}
+	ctx.WithFields(fields).Infof("Request & Response: err=%+v", err)
+}
+
+//allocate struct for params, populate it from the URL parameters then validate and return the struct
+func (ctx Context) GetRequestParams(paramsStructType reflect.Type) (interface{}, error) {
+	paramValues := map[string]interface{}{}
+	for n, v := range ctx.Request.QueryStringParameters {
+		paramValues[n] = v
+	}
+	paramsStructValuePtr := reflect.New(paramsStructType)
+	for i := 0; i < paramsStructType.NumField(); i++ {
+		f := paramsStructType.Field(i)
+		n := (strings.SplitN(f.Tag.Get("json"), ",", 2))[0]
+		if n == "" {
+			n = strings.ToLower(f.Name)
+		}
+		if n == "" || n == "-" {
+			continue
+		}
+
+		//get value(s) from query string
+		var paramStrValues []string
+		if paramStrValue, isDefined := ctx.Request.QueryStringParameters[n]; isDefined {
+			paramStrValues = []string{paramStrValue} //single value
+		} else {
+			paramStrValues = ctx.Request.MultiValueQueryStringParameters[n]
+		}
+		if len(paramStrValues) == 0 {
+			continue //param has no value specified in URL
+		}
+
+		//param is defined >=1 times in URL
+		if f.Type.Kind() == reflect.Slice {
+			//iterate over all specified values
+			for index, paramStrValue := range paramStrValues {
+				newValuePtr := reflect.New(f.Type.Elem())
+				if err := setParamFromStr(fmt.Sprintf("%s[%d]", n, index),
+					//paramsStructValuePtr.Elem().Field(i).Index(index),
+					newValuePtr.Elem(), //.Elem() to dereference
+					paramStrValue); err != nil {
+					return nil, errors.Wrapf(err, "failed to set %s[%d]=%s", n, i, paramStrValues[0])
+				}
+				paramsStructValuePtr.Elem().Field(i).Set(reflect.Append(paramsStructValuePtr.Elem().Field(i), newValuePtr.Elem()))
+			}
+		} else {
+			if len(paramStrValues) > 1 {
+				return nil, errors.Errorf("%s does not support >1 values(%v)", n, strings.Join(paramStrValues, ","))
+			}
+			//single value specified
+			if err := setParamFromStr(n, paramsStructValuePtr.Elem().Field(i), paramStrValues[0]); err != nil {
+				return nil, errors.Wrapf(err, "failed to set %s=%s", n, paramStrValues[0])
+			}
+		}
+	} //for each param struct field
+
+	if validator, ok := paramsStructValuePtr.Interface().(IValidator); ok {
+		if err := validator.Validate(); err != nil {
+			return nil, errors.Wrapf(err, "invalid params")
+		}
+	}
+
+	return paramsStructValuePtr.Elem().Interface(), nil
+}
+
+func setParamFromStr(fieldName string, fieldValue reflect.Value, paramStrValue string) error {
+	logger.Debugf("Set(%s,%v,%v,%v)", fieldName, fieldValue.Type(), fieldValue.Kind(), paramStrValue)
+	switch fieldValue.Type().Kind() {
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		//parse to int for this struct field
+		if i64, err := strconv.ParseInt(paramStrValue, 10, 64); err != nil {
+			return errors.Errorf("%s is not a number", paramStrValue)
+		} else {
+			switch fieldValue.Type().Kind() {
+			case reflect.Int:
+				fieldValue.Set(reflect.ValueOf(int(i64)))
+			case reflect.Int8:
+				fieldValue.Set(reflect.ValueOf(int8(i64)))
+			case reflect.Int16:
+				fieldValue.Set(reflect.ValueOf(int16(i64)))
+			case reflect.Int32:
+				fieldValue.Set(reflect.ValueOf(int32(i64)))
+			case reflect.Int64:
+				fieldValue.Set(reflect.ValueOf(i64))
+			}
+		}
+
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		//parse to int for this struct field
+		if u64, err := strconv.ParseUint(paramStrValue, 10, 64); err != nil {
+			return errors.Errorf("%s is not a number", paramStrValue)
+		} else {
+			switch fieldValue.Type().Kind() {
+			case reflect.Uint:
+				fieldValue.Set(reflect.ValueOf(uint(u64)))
+			case reflect.Uint8:
+				fieldValue.Set(reflect.ValueOf(uint8(u64)))
+			case reflect.Uint16:
+				fieldValue.Set(reflect.ValueOf(uint16(u64)))
+			case reflect.Uint32:
+				fieldValue.Set(reflect.ValueOf(uint32(u64)))
+			case reflect.Uint64:
+				fieldValue.Set(reflect.ValueOf(u64))
+			}
+		}
+
+	case reflect.Bool:
+		bs := strings.ToLower(paramStrValue)
+		if bs == "true" || bs == "yes" || bs == "1" {
+			fieldValue.Set(reflect.ValueOf(true))
+		}
+
+	case reflect.String:
+		fieldValue.Set(reflect.ValueOf(paramStrValue))
+
+	case reflect.Float32:
+		if f32, err := strconv.ParseFloat(paramStrValue, 32); err != nil {
+			return errors.Wrapf(err, "invalid float")
+		} else {
+			fieldValue.Set(reflect.ValueOf(float32(f32)))
+		}
+
+	case reflect.Float64:
+		if f64, err := strconv.ParseFloat(paramStrValue, 64); err != nil {
+			return errors.Wrapf(err, "invalid float")
+		} else {
+			fieldValue.Set(reflect.ValueOf(f64))
+		}
+
+	default:
+		return errors.Errorf("unsupported type %v", fieldValue.Type().Kind())
+	} //switch param struct field
+	return nil
+}
+
+func (ctx Context) GetRequestBody(requestStructType reflect.Type) (interface{}, error) {
+	requestStructValuePtr := reflect.New(requestStructType)
+	err := json.Unmarshal([]byte(ctx.Request.Body), requestStructValuePtr.Interface())
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to JSON request body")
+	}
+
+	if validator, ok := requestStructValuePtr.Interface().(IValidator); ok {
+		if err := validator.Validate(); err != nil {
+			return nil, errors.Wrapf(err, "invalid request body")
+		}
+	}
+
+	return requestStructValuePtr.Elem().Interface(), nil
+}
+
+type IValidator interface {
+	Validate() error
+}
diff --git a/api/handler.go b/api/handler.go
new file mode 100644
index 0000000000000000000000000000000000000000..8e4e785684117135fe33f1a6b3c980d4b21f7365
--- /dev/null
+++ b/api/handler.go
@@ -0,0 +1,92 @@
+package api
+
+import (
+	"reflect"
+
+	"gitlab.com/uafrica/go-utils/errors"
+)
+
+type handler struct {
+	RequestParamsType reflect.Type
+	RequestBodyType   reflect.Type
+	ResponseType      reflect.Type
+	FuncValue         reflect.Value
+}
+
+func NewHandler(fnc interface{}) (handler, error) {
+	h := handler{}
+
+	fncType := reflect.TypeOf(fnc)
+	if fncType.NumIn() < 2 || fncType.NumIn() > 3 {
+		return h, errors.Errorf("takes %d args instead of (Context, Params[, Body])", fncType.NumIn())
+	}
+	if fncType.NumOut() < 1 || fncType.NumOut() > 2 {
+		return h, errors.Errorf("returns %d results instead of ([Response,] error)", fncType.NumOut())
+	}
+
+	//arg[0] must implement interface lambda_helpers.Context
+	if _, ok := reflect.New(fncType.In(0)).Interface().(IContext); !ok {
+		return h, errors.Errorf("first arg %v does not implement api.IContext", fncType.In(0))
+	}
+
+	//arg[1] must be a struct for params. It may be an empty struct, but
+	//all public fields require a json tag which we will use to math the URL param name
+	if err := validateStructType(fncType.In(1)); err != nil {
+		return h, errors.Errorf("second arg %v is not valid params struct type", fncType.In(1))
+	}
+	h.RequestParamsType = fncType.In(1)
+
+	//arg[2] is optional and must be a struct for request body. It may be an empty struct, but
+	//all public fields require a json tag which we will use to unmarshal the request body from JSON
+	if fncType.NumIn() >= 3 {
+		if fncType.In(2).Kind() == reflect.Slice {
+			if err := validateStructType(fncType.In(2).Elem()); err != nil {
+				return h, errors.Errorf("third arg %v is not valid body []struct type", fncType.In(2))
+			}
+		} else {
+			if err := validateStructType(fncType.In(2)); err != nil {
+				return h, errors.Errorf("third arg %v is not valid body struct type", fncType.In(2))
+			}
+		}
+
+		//todo: check special fields for claims, and see if also applies to params struct...
+		//AccountID must be int64 or *int64 with tag =???
+		//UserID must be int64 or *int64 with tag =???
+		//Username must be string with tag =???
+
+		h.RequestBodyType = fncType.In(2)
+	}
+
+	//if 2 results, first must be response struct or array of response structs that will be marshalled to JSON
+	if fncType.NumOut() > 1 {
+		if fncType.Out(0).Kind() == reflect.Slice {
+			if err := validateStructType(fncType.Out(0).Elem()); err != nil {
+				return h, errors.Errorf("first result %v is not valid response []struct type", fncType.Out(0))
+			}
+		} else {
+			if err := validateStructType(fncType.Out(0)); err != nil {
+				return h, errors.Errorf("first result %v is not valid response struct type", fncType.Out(0))
+			}
+		}
+		h.ResponseType = fncType.Out(0)
+	}
+
+	//last result must be error
+	if _, ok := reflect.New(fncType.Out(fncType.NumOut() - 1)).Interface().(*error); !ok {
+		return h, errors.Errorf("last result %v is not error type", fncType.Out(fncType.NumOut()-1))
+	}
+
+	h.FuncValue = reflect.ValueOf(fnc)
+	return h, nil
+}
+
+func validateStructType(t reflect.Type) error {
+	if t.Kind() != reflect.Struct {
+		return errors.Errorf("%v is %v, not a struct", t, t.Kind())
+	}
+	// for i := 0; i < t.NumField(); i++ {
+	// 	f := t.Field(i)
+	// 	if f.... check tags recursively... for now, not too strict ... add checks if we see issues that break the API, to help dev to fix before we deploy, or to prevent bad habits...
+	// }
+	return nil
+}
diff --git a/api/lambda.go b/api/lambda.go
new file mode 100644
index 0000000000000000000000000000000000000000..5f4836ebf9e6f1376a2a52256cdfdf11fda8fed9
--- /dev/null
+++ b/api/lambda.go
@@ -0,0 +1,261 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"math/rand"
+	"net/http"
+	"reflect"
+	"time"
+
+	"github.com/aws/aws-lambda-go/events"
+	"github.com/aws/aws-lambda-go/lambdacontext"
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/service"
+)
+
+//this is native handler for lambda passed into lambda.Start()
+//to run locally, this is called from app.ServeHTTP()
+func (api Api) Handler(baseCtx context.Context, apiGatewayProxyReq events.APIGatewayProxyRequest) (res events.APIGatewayProxyResponse, err error) {
+	res = events.APIGatewayProxyResponse{
+		StatusCode: http.StatusInternalServerError,
+		Body:       "undefined response",
+		Headers:    map[string]string{},
+	}
+
+	// Replace the proxy resource with the path, has some edge cases but works for our current API implementation
+	// Edge case being that if have path params then specify those routes explicitly
+	if apiGatewayProxyReq.Resource == "/{proxy+}" {
+		apiGatewayProxyReq.Resource = apiGatewayProxyReq.Path
+	}
+
+	//setup context
+	requestID := apiGatewayProxyReq.RequestContext.RequestID
+	if lambdaContext, ok := lambdacontext.FromContext(baseCtx); ok && lambdaContext != nil {
+		requestID = lambdaContext.AwsRequestID
+	}
+	ctx := Context{
+		Context: service.NewContext(baseCtx, map[string]interface{}{
+			"env":        api.env,
+			"request_id": requestID,
+		}),
+		IProducer:        api.producer,
+		Request:          apiGatewayProxyReq,
+		RequestID:        requestID,
+		ValuesFromChecks: map[string]map[string]interface{}{},
+	}
+
+	//report handler crashes
+	defer api.crashReporter.Catch(ctx)
+
+	defer func() {
+		//set CORS headers on every response
+		if api.cors != nil {
+			for n, v := range api.cors.CORS() {
+				res.Headers[n] = v
+			}
+		}
+	}()
+
+	defer func() {
+		ctx.LogAPIRequestAndResponse(res, err)
+		if err != nil {
+			if withCause, ok := err.(errors.ErrorWithCause); ok && withCause.Code() != 0 {
+				res.StatusCode = withCause.Code()
+			}
+			errorMessage := fmt.Sprintf("%c", err)
+			jsonError, _ := json.Marshal(map[string]interface{}{"message": errorMessage})
+			res.Body = string(jsonError)
+			err = nil //never pass error back to lambda or http server
+		}
+		if api.requestIDHeaderKey != "" {
+			res.Headers[api.requestIDHeaderKey] = ctx.RequestID
+		}
+		api.auditor.Audit(ctx.StartTime(), time.Now(), map[string]interface{}{
+			"request_id": ctx.RequestID,
+			"request":    ctx.Request,
+			"response":   res},
+		)
+	}()
+
+	if api.dbConn != nil {
+		ctx.DB, err = api.dbConn.Connect()
+		if err != nil {
+			err = errors.Wrapf(err, "failed to connect to db")
+			return
+		}
+	}
+
+	//do checks before proceed (may use db connection)
+	//(typical maintenance mode, rate limits, authentication/claims, ...)
+	for _, check := range api.checks {
+		var checkValues map[string]interface{}
+		checkValues, err = check.checker.Check(apiGatewayProxyReq)
+		//for n, v := range checkValues {
+		//ctx.Context = ctx.Context.WithValue(check.name+"_"+n, v)
+		//ctx.Debugf("Defined value[%s]=(%T)%v", check.name+"_"+n, v, v)
+		//}
+		ctx.ValuesFromChecks[check.name] = checkValues
+		ctx.Debugf("Defined ValuesFromChecks[%s]=(%T)%v", check.name, checkValues, checkValues)
+		if err != nil {
+			err = errors.Wrapf(err, "check(%s) failed", check.name)
+			return
+		}
+	}
+
+	//Early return OPTIONS call
+	if apiGatewayProxyReq.HTTPMethod == "OPTIONS" {
+		res.StatusCode = http.StatusNoContent
+		err = nil
+		return
+	}
+
+	rand.Seed(time.Now().Unix())
+
+	//LEGACY: delete this as soon as all handlers accepts context
+	//this does not support concurrent execution!
+	CurrentRequestID = &ctx.Request.RequestContext.RequestID
+
+	ctx.Debugf("HTTP %s %s ...\n", apiGatewayProxyReq.HTTPMethod, apiGatewayProxyReq.Resource)
+	ctx.WithFields(map[string]interface{}{
+		"http_method":                ctx.Request.HTTPMethod,
+		"path":                       ctx.Request.Path,
+		"api_gateway_request_id":     ctx.Request.RequestContext.RequestID,
+		"user_cognito_auth_provider": ctx.Request.RequestContext.Identity.CognitoAuthenticationProvider,
+		"user_arn":                   ctx.Request.RequestContext.Identity.UserArn,
+	}).Infof("Start API Handler")
+
+	//TODO:
+	// // Get claims and check the status of the user
+	// ctx.Claims, err = api.RetrieveClaims(&apiGatewayProxyReq)
+	// if err != nil {
+	// 	return events.APIGatewayProxyResponse{
+	// 		StatusCode: http.StatusBadRequest,
+	// 		Body:       fmt.Sprintf("%v\n", err),
+	// 		Headers:    utils.CorsHeaders(),
+	// 	}, nil
+	// }
+
+	// if ctx.Claims.UserID != nil {
+	// 	userStatusResponse := checkUserStatus(ctx.Claims)
+	// 	if userStatusResponse != nil {
+	// 		return *userStatusResponse, nil
+	// 	}
+	// }
+
+	// permissionString := fmt.Sprintf("API_%s%s:%s", os.Getenv("MICRO_SERVICE_API_BASE_PATH"), apiGatewayProxyReq.Resource, apiGatewayProxyReq.HTTPMethod)
+	// if !permissions.HasPermission(ctx.Claims.Role, permissionString) {
+	// 	response, _ := apierr.ClientError(http.StatusUnauthorized, fmt.Sprintf("You do not have access to the requested resource: %s", permissionString))
+	// 	if ctx.Claims.Role == nil {
+	// 		ctx.Errorf("%d :: %s: %v", *ctx.Claims.RoleID, permissionString, fmt.Errorf("you have no role"))
+	// 	} else if ctx.Claims.RoleID == nil {
+	// 		ctx.Errorf("%s: you have no role ID", permissionString)
+	// 	}
+	// 	return response, nil
+	// }
+
+	//route on method and path
+	resourceHandler, err := api.router.Route(apiGatewayProxyReq.Resource, apiGatewayProxyReq.HTTPMethod)
+	if err != nil {
+		err = errors.Wrapf(err, "invalid route")
+		return
+	}
+
+	if legacyHandlerFunc, ok := resourceHandler.(func(req events.APIGatewayProxyRequest) (response events.APIGatewayProxyResponse, err error)); ok {
+		ctx.Debugf("Calling legacy handler...")
+		return legacyHandlerFunc(apiGatewayProxyReq)
+	}
+
+	handler, ok := resourceHandler.(handler)
+	if !ok {
+		//should not get here if validateAPIEndpoints() is properly checking!
+		err = errors.HTTP(http.StatusInternalServerError, errors.Errorf("invalid handler function %T", resourceHandler), "invalid routing")
+	}
+
+	//new type of handler function
+	//allocate, populate and validate params struct
+	paramsStruct, paramsErr := ctx.GetRequestParams(handler.RequestParamsType)
+	if paramsErr != nil {
+		err = errors.HTTP(http.StatusBadRequest, paramsErr, "invalid parameters")
+		return
+	}
+	//apply claims to params struct - TODO: Removed - see if cannot force to get claims from context always
+	// if err = ctx.Claims.FillOnObject(ctx.Request, &paramsStruct); err != nil {
+	// 	err = errors.HTTP(http.StatusInternalServerError, err, "claims failed on parameters")
+	// 	return
+	// }
+	ctx.Debugf("Params: (%T) %+v", paramsStruct, paramsStruct)
+
+	args := []reflect.Value{
+		reflect.ValueOf(ctx),
+		reflect.ValueOf(paramsStruct),
+	}
+
+	var bodyStruct interface{}
+	if handler.RequestBodyType != nil {
+		//allocate, populate and validate request struct
+		bodyStruct, err = ctx.GetRequestBody(handler.RequestBodyType)
+		if err != nil {
+			err = errors.HTTP(http.StatusBadRequest, err, "invalid body")
+			return
+		}
+
+		//apply claims to request struct - TODO: Removed - see if cannot force to get claims from context always
+		// if err = ctx.Claims.FillOnObject(ctx.Request, &bodyStruct); err != nil {
+		// 	err = errors.HTTP(http.StatusInternalServerError, err, "claims failed on body")
+		// 	return
+		// }
+
+		ctx.Debugf("Body: (%T) %+v", bodyStruct, bodyStruct)
+		args = append(args, reflect.ValueOf(bodyStruct))
+	}
+
+	//call handler in a func with defer to catch potential crash
+	ctx.Debugf("calling handler")
+	var results []reflect.Value
+	results, err = func() (results []reflect.Value, err error) {
+		defer func() {
+			if crashDump := recover(); crashDump != nil {
+				err = errors.Errorf("handler function crashed: %+v", crashDump)
+				return
+			}
+		}()
+		results = handler.FuncValue.Call(args)
+		return results, nil
+	}()
+	if err != nil {
+		err = errors.Wrapf(err, "handler failed")
+		return
+	}
+
+	ctx.Debugf("handler -> results: %v", results)
+
+	//see if handler failed using last result of type error
+	lastResultValue := results[len(results)-1]
+	if !lastResultValue.IsNil() {
+		err = lastResultValue.Interface().(error)
+		if err != nil {
+			err = errors.Wrapf(err, "error from handler")
+			return
+		}
+	}
+
+	//handler succeeded, some handler does not have a response data (typically post/put/patch/delete)
+	err = nil
+	res.StatusCode = http.StatusOK
+
+	if len(results) > 1 {
+		responseStruct := results[0].Interface()
+		ctx.Debugf("Response type: %T", responseStruct)
+
+		var bodyBytes []byte
+		bodyBytes, err = json.Marshal(responseStruct)
+		if err != nil {
+			err = errors.Wrapf(err, "failed to encode response content")
+			return
+		}
+		res.Body = string(bodyBytes)
+	}
+	return
+}
diff --git a/api/local.go b/api/local.go
new file mode 100644
index 0000000000000000000000000000000000000000..fb4b14293d35cdf90bf868b288c74b2a0da1373c
--- /dev/null
+++ b/api/local.go
@@ -0,0 +1,99 @@
+package api
+
+import (
+	"bytes"
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/aws/aws-lambda-go/events"
+	"github.com/google/uuid"
+	"gitlab.com/uafrica/go-utils/errors"
+)
+
+//use this in http.ListenAndServe() to test locally
+func (api Api) ServeHTTP(httpRes http.ResponseWriter, httpReq *http.Request) {
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
+	defer cancel()
+
+	req := events.APIGatewayProxyRequest{
+		Resource:                        httpReq.URL.Path, //RequestURI cannot be used - it includes the url params
+		Path:                            httpReq.URL.Path,
+		HTTPMethod:                      httpReq.Method,
+		Headers:                         map[string]string{},
+		MultiValueHeaders:               nil, // map[string][]string           `json:"multiValueHeaders"`
+		QueryStringParameters:           map[string]string{},
+		MultiValueQueryStringParameters: map[string][]string{},
+		PathParameters:                  nil, //                  map[string]string             `json:"pathParameters"`
+		StageVariables:                  nil, //                  map[string]string             `json:"stageVariables"`
+		IsBase64Encoded:                 false,
+	}
+
+	//copy significant headers
+	//todo: option to let app specify other list
+	for _, name := range []string{"Content-Type", "request-id"} {
+		if value := httpReq.Header.Get(name); value != "" {
+			req.Headers[name] = value
+		}
+	}
+
+	requestID := httpReq.Header.Get("request-id")
+	if requestID == "" {
+		//define a random request-id
+		requestID = uuid.New().String()
+		req.Headers["request-id"] = requestID
+	}
+
+	for n, v := range httpReq.URL.Query() {
+		if len(v) == 1 {
+			req.QueryStringParameters[n] = v[0]
+		}
+		if len(v) > 1 {
+			req.MultiValueQueryStringParameters[n] = v
+		}
+	}
+
+	req.RequestContext = events.APIGatewayProxyRequestContext{
+		AccountID:        "",                                 //string                    `json:"accountId"`
+		ResourceID:       "",                                 //string                    `json:"resourceId"`
+		OperationName:    "",                                 //string                    `json:"operationName,omitempty"`
+		Stage:            "",                                 //string                    `json:"stage"`
+		DomainName:       "",                                 //string                    `json:"domainName"`
+		DomainPrefix:     "",                                 //string                    `json:"domainPrefix"`
+		RequestID:        requestID,                          //string                    `json:"requestId"`
+		Protocol:         "",                                 //string                    `json:"protocol"`
+		Identity:         events.APIGatewayRequestIdentity{}, // `json:"identity"`
+		ResourcePath:     "",                                 //string                    `json:"resourcePath"`
+		Authorizer:       nil,                                //map[string]interface{}    `json:"authorizer"`
+		HTTPMethod:       "",                                 //string                    `json:"httpMethod"`
+		RequestTime:      "",                                 //string                    `json:"requestTime"`
+		RequestTimeEpoch: time.Now().Unix(),                  //                     `json:"requestTimeEpoch"`
+		APIID:            "",                                 //string                    `json:"apiId"` // The API Gateway rest API Id
+	}
+
+	bodyBytes := bytes.NewBuffer(nil)
+	if _, err := bodyBytes.ReadFrom(httpReq.Body); err != nil {
+		http.Error(httpRes, errors.Wrapf(err, "failed to read body").Error(), http.StatusInternalServerError)
+		return
+	}
+	req.Body = bodyBytes.String()
+
+	res, err := api.Handler(ctx, req)
+	if err != nil {
+		http.Error(httpRes, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	for n, v := range res.Headers {
+		httpRes.Header().Set(n, v)
+	}
+	if res.StatusCode < 200 || res.StatusCode >= 300 {
+		http.Error(httpRes, res.Body, res.StatusCode)
+		return
+	}
+
+	if res.Body != "" {
+		httpRes.Header().Set("Content-Type", "application/json")
+		httpRes.Write([]byte(res.Body))
+	}
+}
diff --git a/api/router.go b/api/router.go
new file mode 100644
index 0000000000000000000000000000000000000000..5095e9bb0caceeddf9394effb41b3fe560af75ed
--- /dev/null
+++ b/api/router.go
@@ -0,0 +1,90 @@
+package api
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/aws/aws-lambda-go/events"
+	"gitlab.com/uafrica/go-utils/errors"
+)
+
+type Router struct {
+	endpoints map[string]map[string]interface{}
+}
+
+func (r Router) Endpoints() map[string]map[string]interface{} {
+	return r.endpoints
+}
+
+func (r Router) Route(path, method string) (interface{}, error) {
+	if methods, ok := r.endpoints[path]; !ok {
+		return nil, errors.HTTP(http.StatusNotFound, errors.Errorf("%s not found", path), "unknown resource path")
+	} else {
+		if handler, ok := methods[method]; !ok {
+			return nil, errors.HTTP(http.StatusMethodNotAllowed, errors.Errorf("%s not allowed on %s", method, path), "method not allowed")
+		} else {
+			return handler, nil
+		}
+	}
+}
+
+//check that all API endpoints are correctly defined using one of the supported handler types
+//return updated endpoints with additional information
+func NewRouter(endpoints map[string]map[string]interface{}) (Router, error) {
+	countLegacy := 0
+	countHandler := 0
+	for resource, methodHandlers := range endpoints {
+		if resource == "" {
+			return Router{}, errors.Errorf("blank resource")
+		}
+		if resource == "/api-docs" {
+			return Router{}, errors.Errorf("%s may not be a defined endpoint - it is reserved", resource)
+		}
+		for method, handlerFunc := range methodHandlers {
+			switch method {
+			case "GET":
+			case "POST":
+			case "PUT":
+			case "PATCH":
+			case "DELETE":
+			default:
+				return Router{}, errors.Errorf("nvalid method:\"%s\" on resource \"%s\"", method, resource)
+			}
+			if handlerFunc == nil {
+				return Router{}, errors.Errorf("nil handler on %s %s", method, resource)
+			}
+
+			if _, ok := handlerFunc.(func(req events.APIGatewayProxyRequest) (response events.APIGatewayProxyResponse, err error)); ok {
+				//ok - leave as is - we support this legacyHandler
+				fmt.Printf("%10s %s: OK (legacy handler)\n", method, resource)
+				countLegacy++
+			} else {
+				handler, err := NewHandler(handlerFunc)
+				if err != nil {
+					return Router{}, errors.Wrapf(err, "%s %s has invalid handler %T", method, resource, handlerFunc)
+				}
+				//replace the endpoint value so we can quickly call this handler
+				endpoints[resource][method] = handler
+				fmt.Printf("%10s %s: OK (params: %v, request: %v)\n", method, resource, handler.RequestParamsType, handler.RequestBodyType)
+				countHandler++
+			}
+		}
+	}
+	fmt.Printf("Checked %d legacy and %d new handlers\n", countLegacy, countHandler)
+
+	//add reserved endpoint to generate documentation
+	r := Router{
+		endpoints: endpoints,
+	}
+
+	// {
+	// 	docsHandler, err := NewHandler(GETApiDocs(r)) //endpoints))
+	// 	if err != nil {
+	// 		return Router{}, errors.Wrapf(err, "failed to define handler for docs")
+	// 	}
+	// 	endpoints["/api-docs"] = map[string]interface{}{
+	// 		"GET": docsHandler,
+	// 	}
+	// }
+	return r, nil
+}
diff --git a/api/test.go b/api/test.go
new file mode 100644
index 0000000000000000000000000000000000000000..778f64ec17cd4fd767e18d43231361d3aff70366
--- /dev/null
+++ b/api/test.go
@@ -0,0 +1 @@
+package api
diff --git a/config/README.md b/config/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..e93574569cadf1b18936eb95f82c636c9f411d25
--- /dev/null
+++ b/config/README.md
@@ -0,0 +1,10 @@
+# Config
+
+Only used for local development on the terminal console, to set ENV from a JSON file, that simulates the environment created by AWS for our lambda images.
+
+## How it works:
+When api/cron/sqs starts (see V3),
+They check if running local for testing (i.e. command line option used),
+If so they look for config.local.json in the current directory or any parent directory
+They generally find the one in the project repo top level directory
+Then set all the values in that file in the env
diff --git a/config/local.go b/config/local.go
new file mode 100644
index 0000000000000000000000000000000000000000..59f5251506e0261dfa887c74cdf920394c2d499c
--- /dev/null
+++ b/config/local.go
@@ -0,0 +1,47 @@
+package config
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path"
+
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+)
+
+func LoadLocal() error {
+	configDir, err := os.Getwd()
+	if err != nil {
+		return errors.Wrapf(err, "cannot get working directory")
+	}
+	configFilename := "config.local.json"
+	for {
+		fn := configDir + "/" + configFilename
+		f, err := os.Open(fn)
+		if err != nil {
+			logger.Debugf("%s not found in %s", configFilename, configDir)
+			parentDir := path.Dir(configDir)
+			if parentDir == configDir {
+				return errors.Errorf("did not find file %s in working dir or any parent dir", configFilename)
+			}
+			configDir = parentDir
+			continue
+		}
+
+		defer f.Close()
+
+		var config map[string]interface{}
+		if err := json.NewDecoder(f).Decode(&config); err != nil {
+			return errors.Wrapf(err, "failed to decode JSON from file %s", fn)
+		}
+
+		for n, v := range config {
+			vs := fmt.Sprintf("%v", v)
+			os.Setenv(n, vs)
+			logger.Debugf("Defined local config %s=%s", n, vs)
+		}
+
+		return nil
+	}
+} //LoadLocal()
diff --git a/cron/context.go b/cron/context.go
new file mode 100644
index 0000000000000000000000000000000000000000..734dbcd220e9593e53c9ff63c100146a1bf360d3
--- /dev/null
+++ b/cron/context.go
@@ -0,0 +1,23 @@
+package cron
+
+import (
+	"context"
+	"time"
+
+	"github.com/uptrace/bun"
+	"gitlab.com/uafrica/go-utils/logger"
+	"gitlab.com/uafrica/go-utils/service"
+)
+
+type IContext interface {
+	context.Context
+	logger.ILogger
+	StartTime() time.Time
+	MillisecondsSinceStart() int64
+}
+
+type Context struct {
+	service.Context
+	Name string //cron function name
+	DB   *bun.DB
+}
diff --git a/cron/cron.go b/cron/cron.go
new file mode 100644
index 0000000000000000000000000000000000000000..0683c14646278dd8aaafad68bc2a693363fb65c5
--- /dev/null
+++ b/cron/cron.go
@@ -0,0 +1,93 @@
+package cron
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/aws/aws-lambda-go/lambda"
+	"github.com/aws/aws-lambda-go/lambdacontext"
+	"github.com/google/uuid"
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+	"gitlab.com/uafrica/go-utils/service"
+)
+
+func New(functions map[string]func(Context) error) Cron {
+	env := os.Getenv("ENVIRONMENT") //todo: support config loading for local dev and env for lambda in prod
+	if env == "" {
+		env = "dev"
+	}
+
+	router, err := NewRouter(functions)
+	if err != nil {
+		panic(fmt.Sprintf("cannot create router: %+v", err))
+	}
+
+	return Cron{
+		ILogger:       logger.New().WithFields(map[string]interface{}{"env": env}),
+		env:           env,
+		router:        router,
+		crashReporter: defaultCrashReporter{},
+	}
+}
+
+type Cron struct {
+	logger.ILogger
+	env           string
+	router        Router
+	dbConn        service.IDatabaseConnector
+	crashReporter ICrashReporter
+}
+
+func (cron Cron) WithCrashReported(crashReporter ICrashReporter) Cron {
+	if crashReporter != nil {
+		cron.crashReporter = crashReporter
+	}
+	return cron
+}
+
+type ICrashReporter interface {
+	Catch(ctx Context) //Report(method string, path string, crash interface{})
+}
+
+type defaultCrashReporter struct{}
+
+func (defaultCrashReporter) Catch(ctx Context) {
+	// crash := recover()
+	// if crash != nil {
+	// 	ctx.Errorf("CRASH: (%T) %+v\n", crash, crash)
+	// }
+}
+
+func (cron Cron) WithDb(dbConn service.IDatabaseConnector) Cron {
+	cron.dbConn = dbConn
+	return cron
+}
+
+func (cron Cron) Run(invokeArn *string) {
+	if invokeArn != nil && *invokeArn != "" {
+		//just run this handler and terminate - for testing on a terminal
+		logger.Infof("Invoking ARN=%s locally for testing ...", *invokeArn)
+
+		lambdaContext := lambdacontext.NewContext(
+			context.Background(),
+			&lambdacontext.LambdaContext{
+				AwsRequestID:       uuid.New().String(),
+				InvokedFunctionArn: *invokeArn,
+				// Identity           CognitoIdentity
+				// ClientContext      ClientContext
+			},
+		)
+		err := cron.Handler(lambdaContext)
+		if err != nil {
+			panic(errors.Errorf("cron failed: %+v", err))
+		} else {
+			logger.Debugf("cron success")
+		}
+		return
+	}
+
+	//production
+	lambda.Start(cron.Handler)
+}
diff --git a/cron/handler.go b/cron/handler.go
new file mode 100644
index 0000000000000000000000000000000000000000..dd3772bbfc261435d79ca0d97437b2d7f47d3939
--- /dev/null
+++ b/cron/handler.go
@@ -0,0 +1,51 @@
+package cron
+
+import (
+	"reflect"
+
+	"gitlab.com/uafrica/go-utils/errors"
+)
+
+type Handler struct {
+	RecordType reflect.Type
+	FuncValue  reflect.Value
+}
+
+func NewHandler(fnc interface{}) (Handler, error) {
+	h := Handler{}
+
+	fncType := reflect.TypeOf(fnc)
+	if fncType.NumIn() != 2 {
+		return h, errors.Errorf("takes %d args instead of (Context, Record)", fncType.NumIn())
+	}
+	if fncType.NumOut() != 1 {
+		return h, errors.Errorf("returns %d results instead of (error)", fncType.NumOut())
+	}
+
+	//arg[0] must implement interface sqs.IContext
+	if _, ok := reflect.New(fncType.In(0)).Interface().(IContext); !ok {
+		return h, errors.Errorf("first arg %v does not implement sqs.IContext", fncType.In(0))
+	}
+
+	//arg[1] must be a struct for the message record body. It may be an empty struct, but
+	//all public fields require a json tag which we will use to math the URL param name
+	if err := validateStructType(fncType.In(1)); err != nil {
+		return h, errors.Errorf("second arg %v is not valid record struct type", fncType.In(1))
+	}
+	h.RecordType = fncType.In(1)
+
+	//result must be error
+	if _, ok := reflect.New(fncType.Out(0)).Interface().(*error); !ok {
+		return h, errors.Errorf("result %v is not error type", fncType.Out(0))
+	}
+
+	h.FuncValue = reflect.ValueOf(fnc)
+	return h, nil
+}
+
+func validateStructType(t reflect.Type) error {
+	if t.Kind() != reflect.Struct {
+		return errors.Errorf("%v is %v, not a struct", t, t.Kind())
+	}
+	return nil
+}
diff --git a/cron/lambda.go b/cron/lambda.go
new file mode 100644
index 0000000000000000000000000000000000000000..ea2fb6fd3ca09ffa74bc989b6f7b2d37e4b136a8
--- /dev/null
+++ b/cron/lambda.go
@@ -0,0 +1,60 @@
+package cron
+
+import (
+	"context"
+	"math/rand"
+	"time"
+
+	"github.com/aws/aws-lambda-go/lambdacontext"
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/service"
+)
+
+type LambdaCronHandler func(lambdaCtx context.Context) error
+
+func (cron Cron) Handler(lambdaCtx context.Context) (err error) {
+	lc, _ := lambdacontext.FromContext(lambdaCtx)
+	requestID := lc.AwsRequestID
+
+	cronName, cronFunc := cron.router.Route(lc.InvokedFunctionArn)
+	if cronFunc == nil {
+		return errors.Errorf("request-id:%s unknown cron function(%s)", requestID, lc.InvokedFunctionArn)
+	}
+
+	//got a handler, prepare to run:
+	rand.Seed(time.Now().Unix())
+
+	ctx := Context{
+		Context: service.NewContext(lambdaCtx, map[string]interface{}{
+			"env":        cron.env,
+			"request-id": requestID,
+			"cron":       cronName,
+		}),
+		Name: cronName,
+	}
+
+	defer func() {
+		if err != nil {
+			ctx.Errorf("failed: %+v", err)
+		}
+	}()
+
+	if cron.dbConn != nil {
+		ctx.DB, err = cron.dbConn.Connect()
+		if err != nil {
+			err = errors.Wrapf(err, "failed to connect to db")
+			return
+		}
+	}
+
+	//report handler crashes
+	defer cron.crashReporter.Catch(ctx)
+
+	//todo: set log level, trigger log on conditions, sync at end of transaction - after log level was determined
+	ctx.Infof("Start CRON Handler")
+
+	if err := cronFunc(ctx); err != nil {
+		return errors.Wrapf(err, "Cron(%s) failed", cronName)
+	}
+	return nil
+}
diff --git a/cron/router.go b/cron/router.go
new file mode 100644
index 0000000000000000000000000000000000000000..c58bf7f85b5243aaa3574678cb7de70bdf4346f0
--- /dev/null
+++ b/cron/router.go
@@ -0,0 +1,65 @@
+package cron
+
+import (
+	"fmt"
+	"strings"
+
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+)
+
+type Router struct {
+	endpoints map[string]func(Context) error
+}
+
+func (r Router) Endpoints() map[string]func(Context) error {
+	return r.endpoints
+}
+
+func (r Router) Route(arn string) (string, func(Context) error) {
+	for name, hdlr := range r.endpoints {
+		if strings.Contains(arn, name) {
+			return name, hdlr
+		} else {
+			logger.Debugf("ARN(%s) does not contain cronName(%s)", arn, name)
+		}
+	}
+	return "", nil
+}
+
+//check that all endpoints are correctly defined using one of the supported handler types
+//return updated endpoints with additional information
+func NewRouter(endpoints map[string]func(Context) error) (Router, error) {
+	countLegacyEvent := 0
+	countLegacyMessage := 0
+	countHandler := 0
+	for messageType, handlerFunc := range endpoints {
+		if messageType == "" {
+			return Router{}, errors.Errorf("blank messageType")
+		}
+		if messageType == "/sqs-docs" {
+			return Router{}, errors.Errorf("%s may not be a defined endpoint - it is reserved", messageType)
+		}
+		if handlerFunc == nil {
+			return Router{}, errors.Errorf("nil handler on %s", messageType)
+		}
+		fmt.Printf("%30.30s: OK\n", messageType)
+	}
+	fmt.Printf("Checked %d legacy event and %d legacy message and %d new handlers\n", countLegacyEvent, countLegacyMessage, countHandler)
+
+	//add reserved endpoint to generate documentation
+	r := Router{
+		endpoints: endpoints,
+	}
+
+	// {
+	// 	docsHandler, err := NewHandler(GETApiDocs(r)) //endpoints))
+	// 	if err != nil {
+	// 		return Router{}, errors.Wrapf(err, "failed to define handler for docs")
+	// 	}
+	// 	endpoints["/api-docs"] = map[string]interface{}{
+	// 		"GET": docsHandler,
+	// 	}
+	// }
+	return r, nil
+}
diff --git a/errors/error.go b/errors/error.go
index 31b442d016f3d408f946688ef0895b5addeee3e3..b4baf2fd88e6da367aaf783f53ffd42da1c85b66 100644
--- a/errors/error.go
+++ b/errors/error.go
@@ -177,6 +177,14 @@ func pkgStack(err error, opts FormattingOptions) string {
 				s += fmt.Sprintf("%s", e)
 			}
 			return s
+		} else {
+			//no stack tracer...
+			if opts.NewLines {
+				s += ", because \n\t"
+			} else {
+				s += ", because "
+			}
+			s += e.Error()
 		}
 		errWithCause, ok := e.(ErrorWithCause)
 		if !ok {
diff --git a/go.mod b/go.mod
index fe6f44948b78ab85574bb5a38dc5c5c5e0ffde8b..e90b1e04ffa5949a3f40256c3df0f0053af4e0d0 100644
--- a/go.mod
+++ b/go.mod
@@ -6,18 +6,32 @@ require (
 	github.com/go-pg/pg/v10 v10.10.5
 	github.com/pkg/errors v0.9.1
 	github.com/thoas/go-funk v0.9.1
-	golang.org/x/text v0.3.3
+	golang.org/x/text v0.3.6
 )
 
 require (
+	github.com/aws/aws-lambda-go v1.26.0
 	github.com/go-pg/zerochecker v0.2.0 // indirect
+	github.com/google/uuid v1.3.0
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
+	github.com/uptrace/bun v1.0.8
 	github.com/vmihailenco/bufpool v0.1.11 // indirect
-	github.com/vmihailenco/msgpack/v5 v5.3.1 // indirect
+	github.com/vmihailenco/msgpack/v5 v5.3.4 // indirect
 	github.com/vmihailenco/tagparser v0.1.2 // indirect
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
 	golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
-	golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 // indirect
+	golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
 	mellium.im/sasl v0.2.1 // indirect
 )
+
+require (
+	github.com/aws/aws-sdk-go v1.40.50 // indirect
+	github.com/cespare/xxhash/v2 v2.1.1 // indirect
+	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+	github.com/fatih/color v1.13.0 // indirect
+	github.com/go-redis/redis/v8 v8.11.3 // indirect
+	github.com/jmespath/go-jmespath v0.4.0 // indirect
+	github.com/mattn/go-colorable v0.1.9 // indirect
+	github.com/mattn/go-isatty v0.0.14 // indirect
+)
diff --git a/go.sum b/go.sum
index 105598b3630f121dbed75d4ae7c8e1fd27343926..732bbf4426935a37d6b94894adb28668d189f9e1 100644
--- a/go.sum
+++ b/go.sum
@@ -1,12 +1,24 @@
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/aws/aws-lambda-go v1.26.0 h1:6ujqBpYF7tdZcBvPIccs98SpeGfrt/UOVEiexfNIdHA=
+github.com/aws/aws-lambda-go v1.26.0/go.mod h1:jJmlefzPfGnckuHdXX7/80O3BvUUi12XOkbv4w9SGLU=
+github.com/aws/aws-sdk-go v1.40.50 h1:QP4NC9EZWBszbNo2UbG6bbObMtN35kCFb4h0r08q884=
+github.com/aws/aws-sdk-go v1.40.50/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
+github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
@@ -14,6 +26,9 @@ github.com/go-pg/pg/v10 v10.10.5 h1:RRW8NqxVu4vgzN9k05TT9rM5X+2VQHcIBRLeK9djMBE=
 github.com/go-pg/pg/v10 v10.10.5/go.mod h1:EmoJGYErc+stNN/1Jf+o4csXuprjxcRztBnn6cHe38E=
 github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
 github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
+github.com/go-redis/redis/v8 v8.11.3 h1:GCjoYp8c+yQTJfc0n69iwSiHjvuAdruxl7elnZCxgt8=
+github.com/go-redis/redis/v8 v8.11.3/go.mod h1:xNJ9xDG09FsIPwh3bWdk+0oDWHbtF9rPN0F/oD9XeKc=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -26,36 +41,55 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
 github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
 github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
 github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M=
 github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
+github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
 github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
 github.com/onsi/gomega v1.10.3 h1:gph6h/qe9GSUw1NhH1gp+qb+h8rXD8Cy60Z32Qw3ELA=
 github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc=
+github.com/onsi/gomega v1.15.0 h1:WjP/FQ/sk43MRmnEcT+MlDw2TFvkrXlprrPST/IudjU=
+github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
@@ -66,16 +100,23 @@ github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
 github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
 github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
 github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
+github.com/uptrace/bun v1.0.8 h1:uWrBnun83dfNGP//xdLAdGA3UGISQtpdSzjZHUNmZh0=
+github.com/uptrace/bun v1.0.8/go.mod h1:aL6D9vPw8DXaTQTwGrEPtUderBYXx7ShUmPfnxnqscw=
+github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
 github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=
 github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=
 github.com/vmihailenco/msgpack/v5 v5.3.1 h1:0i85a4dsZh8mC//wmyyTEzidDLPQfQAxZIOLtafGbFY=
 github.com/vmihailenco/msgpack/v5 v5.3.1/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
+github.com/vmihailenco/msgpack/v5 v5.3.4 h1:qMKAwOV+meBw2Y8k9cVwAy7qErtYCwBzZ2ellBfvnqc=
+github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
 github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc=
 github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
 github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
@@ -83,20 +124,26 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
+golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -104,24 +151,38 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 h1:iGu644GcxtEcrInvDsQRCwJjtCIOlT2V7IRt6ah2Whw=
 golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@@ -139,6 +200,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
@@ -148,10 +211,13 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 mellium.im/sasl v0.2.1 h1:nspKSRg7/SyO0cRGY71OkfHab8tf9kCts6a6oTDut0w=
diff --git a/logger/global.go b/logger/global.go
index e584038c5070908c8c182fab348d792e6e3648d0..f65afc41deb7a35be4195ce740d381874fc64b99 100644
--- a/logger/global.go
+++ b/logger/global.go
@@ -4,6 +4,8 @@ import (
 	"fmt"
 	"os"
 
+	"github.com/fatih/color"
+
 	"gitlab.com/uafrica/go-utils/errors"
 )
 
@@ -14,6 +16,8 @@ func init() {
 		level:  LevelDebug,
 		writer: os.Stderr,
 		data:   map[string]interface{}{},
+		fg:     color.FgWhite,
+		bg:     color.BgBlack,
 	}
 	//	InitLogs(nil, nil)
 }
diff --git a/logger/logger.go b/logger/logger.go
index 0af9a4c471731bb2b894b464efe64ae97fc325f5..8347fc56399c102740390e07446c965415e998fc 100644
--- a/logger/logger.go
+++ b/logger/logger.go
@@ -1,12 +1,14 @@
 package logger
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
 	"io"
 	"strings"
 	"time"
 
+	"github.com/fatih/color"
 	"gitlab.com/uafrica/go-utils/errors"
 )
 
@@ -29,6 +31,71 @@ type Logger struct {
 	level  Level
 	writer io.Writer
 	data   map[string]interface{}
+
+	fg color.Attribute
+	bg color.Attribute
+}
+
+func (l Logger) WithForeground(fg color.Attribute) Logger {
+	l.fg = fg
+	return l
+}
+
+func (l Logger) WithBackground(bg color.Attribute) Logger {
+	l.bg = bg
+	return l
+}
+
+type textColor struct {
+	fg color.Attribute
+	bg color.Attribute
+}
+
+var (
+	fgColors = []color.Attribute{
+		color.FgWhite,
+		color.FgRed,
+		color.FgGreen,
+		color.FgYellow,
+		color.FgBlue,
+		color.FgMagenta,
+		color.FgCyan,
+		color.FgBlack,
+	}
+	bgColors = []color.Attribute{
+		color.BgBlack,
+		color.BgWhite,
+		color.BgRed,
+		color.BgGreen,
+		color.BgYellow,
+		color.BgBlue,
+		color.BgMagenta,
+		color.BgCyan,
+	}
+	nextFg = 0
+	nextBg = 0
+)
+
+func (l Logger) NextColor() Logger {
+	l.fg = 0
+	l.bg = 0
+	for l.fg == l.bg {
+		l.bg = bgColors[nextBg]
+		l.fg = fgColors[nextFg]
+		incColor()
+	}
+	return l
+}
+
+func incColor() {
+	nextFg++
+	if nextFg >= len(fgColors) {
+		nextFg = 0
+		nextBg++
+		if nextBg >= len(bgColors) {
+			nextBg++
+		}
+	}
 }
 
 func (l Logger) WithFields(data map[string]interface{}) Logger {
@@ -36,6 +103,8 @@ func (l Logger) WithFields(data map[string]interface{}) Logger {
 		level:  l.level,
 		writer: l.writer,
 		data:   map[string]interface{}{},
+		fg:     l.fg,
+		bg:     l.bg,
 	}
 	for n, v := range l.data {
 		newLogger.data[n] = v
@@ -102,20 +171,43 @@ func (l Logger) log(level Level, skip int, msg string) {
 		// }
 		// l.writer.Write(append(jsonEntry, []byte("\n")...))
 
-		source := fmt.Sprintf("%s(%d)", entry.Caller.File, entry.Caller.Line)
-		if len(source) > 25 {
-			source = source[len(source)-25:]
+		source := fmt.Sprintf("%s/%s:%d", entry.Caller.Package, entry.Caller.File, entry.Caller.Line)
+		if len(source) > 40 {
+			source = source[len(source)-40:]
+		}
+
+		buffer := bytes.NewBuffer(nil)
+		red := color.New(color.FgRed).FprintfFunc()
+		magenta := color.New(color.FgMagenta).FprintfFunc()
+		yellow := color.New(color.FgYellow).FprintfFunc()
+		blue := color.New(color.FgBlue).FprintfFunc()
+		green := color.New(color.FgGreen).FprintfFunc()
+		cyan := color.New(color.FgCyan).FprintfFunc()
+		blue(buffer, entry.Timestamp.Format("2006-01-02 15:04:05"))
+		levelString := fmt.Sprintf(" %5.5s", entry.Level)
+		switch entry.Level {
+		case LevelFatal:
+			red(buffer, levelString)
+		case LevelError:
+			red(buffer, levelString)
+		case LevelWarn:
+			magenta(buffer, levelString)
+		case LevelInfo:
+			yellow(buffer, levelString)
+		case LevelDebug:
+			green(buffer, levelString)
 		}
-		textEntry := fmt.Sprintf("%s %5.5s %-25.25s %s",
-			entry.Timestamp.Format("2006-01-02 15:04:05"),
-			entry.Level,
-			source,
-			entry.Message)
+		cyan(buffer, fmt.Sprintf(" %-40.40s| ", source))
+
+		base := color.New(l.fg, l.bg).FprintfFunc()
+		base(buffer, entry.Message) //buffer.Write([]byte(entry.Message))
+
 		if len(entry.Data) > 0 {
 			jsonData, _ := json.Marshal(entry.Data)
-			textEntry += " " + string(jsonData)
+			green(buffer, " "+string(jsonData))
 		}
-		l.writer.Write(append([]byte(textEntry), []byte("\n")...))
+		buffer.WriteString("\n")
+		l.writer.Write(buffer.Bytes())
 	}
 }
 
diff --git a/logger/logs_test.go b/logger/logs_test.go
index 0887d95059f36f44a56c9ff6bd7d8221912db5d0..8ebb965bd9b07f0309aa027cbc73caed5d74ed7c 100644
--- a/logger/logs_test.go
+++ b/logger/logs_test.go
@@ -4,6 +4,7 @@ import (
 	"os"
 	"testing"
 
+	"github.com/fatih/color"
 	"gitlab.com/uafrica/go-utils/errors"
 	"gitlab.com/uafrica/go-utils/logger"
 )
@@ -34,3 +35,12 @@ func TestLogs(t *testing.T) {
 	//logs.Errorf("Debugging %d!", 456)
 	//logs.Error("Info")
 }
+
+func TestColor(t *testing.T) {
+	blue := color.New(color.FgBlue).FprintfFunc()
+	blue(os.Stdout, "important notice: %s", "ssss")
+
+	// Mix up with multiple attributes
+	success := color.New(color.Bold, color.FgGreen).FprintlnFunc()
+	success(os.Stdout, " don't forget this...")
+}
diff --git a/queues/consumer.go b/queues/consumer.go
new file mode 100644
index 0000000000000000000000000000000000000000..7549b85ea66e253c1a1a4665181b2ed773468431
--- /dev/null
+++ b/queues/consumer.go
@@ -0,0 +1,9 @@
+package queues
+
+import "gitlab.com/uafrica/go-utils/service"
+
+type IConsumer interface {
+	WithDb(dbConn service.IDatabaseConnector) IConsumer
+	Run()
+	ProcessFile(filename string) error
+}
diff --git a/queues/context.go b/queues/context.go
new file mode 100644
index 0000000000000000000000000000000000000000..335bb870f959d97d9cb2180b3377b8bf77d1a29c
--- /dev/null
+++ b/queues/context.go
@@ -0,0 +1,50 @@
+package queues
+
+import (
+	"context"
+	"encoding/json"
+	"reflect"
+	"time"
+
+	"github.com/uptrace/bun"
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+	"gitlab.com/uafrica/go-utils/service"
+)
+
+type IContext interface {
+	context.Context
+	logger.ILogger
+	IProducer
+	StartTime() time.Time
+	MillisecondsSinceStart() int64
+	//DB() *bun.DB
+}
+
+type Context struct {
+	service.Context
+	IProducer //todo: would be nice to have a method to requeue same event with delay and incrementing attempt nr
+	Event     Event
+	RequestID string
+	DB        *bun.DB
+}
+
+func (ctx Context) GetRecord(recordType reflect.Type) (interface{}, error) {
+	recordValuePtr := reflect.New(recordType)
+	err := json.Unmarshal([]byte(ctx.Event.BodyJSON), recordValuePtr.Interface())
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to JSON decode message body")
+	}
+
+	if validator, ok := recordValuePtr.Interface().(IValidator); ok {
+		if err := validator.Validate(); err != nil {
+			return nil, errors.Wrapf(err, "invalid message body")
+		}
+	}
+
+	return recordValuePtr.Elem().Interface(), nil
+}
+
+type IValidator interface {
+	Validate() error
+}
diff --git a/queues/event.go b/queues/event.go
new file mode 100644
index 0000000000000000000000000000000000000000..6f4e0f960798c8004ead9eb06b77ddb421001675
--- /dev/null
+++ b/queues/event.go
@@ -0,0 +1,103 @@
+package queues
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+)
+
+type IProducerLogger interface {
+	IProducer
+	logger.ILogger
+}
+
+func NewEvent(producer IProducerLogger, queueName string) Event {
+	if producer == nil {
+		panic(errors.Errorf("NewEvent(producer=nil)"))
+	}
+
+	return Event{
+		producer:       producer,
+		QueueName:      queueName,
+		TypeName:       "",
+		DueTime:        time.Now(),
+		RequestIDValue: "",
+		ParamValues:    map[string]string{},
+		BodyJSON:       "",
+	}
+}
+
+type Event struct {
+	producer       IProducerLogger
+	MessageID      string            //assigned by implementation (AWS/mem/..)
+	QueueName      string            //queue determine sequencing, items in same queue are delivered one-after-the-other, other queues may deliver concurrent to this queue
+	TypeName       string            //type determines which handler processes the event
+	DueTime        time.Time         //do not process before this time
+	RequestIDValue string            //service request-id that sends the event - for tracing
+	ParamValues    map[string]string //parameters
+	BodyJSON       string            //expecting a JSON string
+}
+
+func (event Event) Format(f fmt.State, c rune) {
+	f.Write([]byte(fmt.Sprintf("{queue:%s,type:%s,due:%s,request-id:%s,params:%v,bodyJSON:%20.20s...,msg-id:%s}",
+		event.QueueName,
+		event.TypeName,
+		event.DueTime.Format("2006-01-02 15:04:05"),
+		event.RequestIDValue,
+		event.ParamValues,
+		event.BodyJSON,
+		event.MessageID,
+	)))
+}
+
+func (event Event) Delay(dur time.Duration) Event {
+	if dur >= 0 {
+		event.DueTime = time.Now().Add(dur)
+	}
+	return event
+}
+
+func (event Event) Type(typeName string) Event {
+	if typeName != "" {
+		event.TypeName = typeName
+	}
+	return event
+}
+
+func (event Event) RequestID(requestID string) Event {
+	if requestID != "" {
+		event.RequestIDValue = requestID
+	}
+	return event
+}
+
+func (event Event) Params(params map[string]string) Event {
+	for n, v := range params {
+		event.ParamValues[n] = v
+	}
+	return event
+}
+
+func (event Event) Send(value interface{}) (string, error) {
+	event.producer.Debugf("Queue(%s) Send SQS Event: %v",
+		event.QueueName,
+		event)
+	if value != nil {
+		jsonBody, err := json.Marshal(value)
+		if err != nil {
+			return "", errors.Wrapf(err, "failed to JSON encode event body")
+		}
+		event.BodyJSON = string(jsonBody)
+	}
+	if event.producer == nil {
+		return "", errors.Errorf("Send with producer==nil")
+	}
+	if msgID, err := event.producer.Send(event); err != nil {
+		return "", errors.Wrapf(err, "failed to send event")
+	} else {
+		return msgID, nil
+	}
+}
diff --git a/queues/handler.go b/queues/handler.go
new file mode 100644
index 0000000000000000000000000000000000000000..aa29674f912e4c9204c356874c429ad2fd4ad3bb
--- /dev/null
+++ b/queues/handler.go
@@ -0,0 +1,51 @@
+package queues
+
+import (
+	"reflect"
+
+	"gitlab.com/uafrica/go-utils/errors"
+)
+
+type Handler struct {
+	RecordType reflect.Type
+	FuncValue  reflect.Value
+}
+
+func NewHandler(fnc interface{}) (Handler, error) {
+	h := Handler{}
+
+	fncType := reflect.TypeOf(fnc)
+	if fncType.NumIn() != 2 {
+		return h, errors.Errorf("takes %d args instead of (Context, Record)", fncType.NumIn())
+	}
+	if fncType.NumOut() != 1 {
+		return h, errors.Errorf("returns %d results instead of (error)", fncType.NumOut())
+	}
+
+	//arg[0] must implement interface sqs.IContext
+	if _, ok := reflect.New(fncType.In(0)).Interface().(IContext); !ok {
+		return h, errors.Errorf("first arg %v does not implement sqs.IContext", fncType.In(0))
+	}
+
+	//arg[1] must be a struct for the message record body. It may be an empty struct, but
+	//all public fields require a json tag which we will use to math the URL param name
+	if err := validateStructType(fncType.In(1)); err != nil {
+		return h, errors.Errorf("second arg %v is not valid record struct type", fncType.In(1))
+	}
+	h.RecordType = fncType.In(1)
+
+	//result must be error
+	if _, ok := reflect.New(fncType.Out(0)).Interface().(*error); !ok {
+		return h, errors.Errorf("result %v is not error type", fncType.Out(0))
+	}
+
+	h.FuncValue = reflect.ValueOf(fnc)
+	return h, nil
+}
+
+func validateStructType(t reflect.Type) error {
+	if t.Kind() != reflect.Struct {
+		return errors.Errorf("%v is %v, not a struct", t, t.Kind())
+	}
+	return nil
+}
diff --git a/queues/mem/README.md b/queues/mem/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..58ae44f94dd076f2b25588382010a94e62e54d34
--- /dev/null
+++ b/queues/mem/README.md
@@ -0,0 +1,3 @@
+# Memory Queues
+
+This is an in-memory implementation of go-utils/queues for use in local development and testing only.
\ No newline at end of file
diff --git a/queues/mem/consumer.go b/queues/mem/consumer.go
new file mode 100644
index 0000000000000000000000000000000000000000..eb314a15de8ee7a79558f02aa231a23a1f923288
--- /dev/null
+++ b/queues/mem/consumer.go
@@ -0,0 +1,179 @@
+package mem
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"math/rand"
+	"os"
+	"reflect"
+	"sync"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/uptrace/bun"
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+	"gitlab.com/uafrica/go-utils/queues"
+	"gitlab.com/uafrica/go-utils/service"
+)
+
+func NewConsumer(routes map[string]interface{}) *Consumer {
+	router, err := queues.NewRouter(routes)
+	if err != nil {
+		panic(fmt.Sprintf("cannot create router: %+v", err))
+	}
+	return &Consumer{
+		ILogger: logger.New().WithFields(map[string]interface{}{"env": "dev"}).NextColor(),
+		router:  router,
+		queues:  map[string]*queue{},
+	}
+}
+
+type Consumer struct {
+	sync.Mutex
+	logger.ILogger
+	router queues.Router
+	dbConn service.IDatabaseConnector
+	queues map[string]*queue
+}
+
+func (consumer *Consumer) WithDb(dbConn service.IDatabaseConnector) queues.IConsumer {
+	consumer.dbConn = dbConn
+	return consumer
+}
+
+func (consumer *Consumer) Queue(name string) (*queue, error) {
+	consumer.Lock()
+	defer consumer.Unlock()
+	q, ok := consumer.queues[name]
+	if !ok {
+		q = &queue{
+			consumer: consumer,
+			name:     name,
+			ch:       make(chan queues.Event),
+		}
+		go q.run()
+		consumer.queues[name] = q
+	}
+	return q, nil
+}
+
+//do not call this - when using local producer, the consumer is automatically running
+//for each queue you send to, and processing from q.run()
+func (consumer *Consumer) Run() {
+	panic(errors.Errorf("DO NOT RUN LOCAL CONSUMER"))
+}
+
+func (consumer *Consumer) ProcessFile(filename string) error {
+	f, err := os.Open(filename)
+	if err != nil {
+		return errors.Wrapf(err, "failed to open queue event file %s", filename)
+	}
+	defer f.Close()
+
+	var event queues.Event
+	if err := json.NewDecoder(f).Decode(&event); err != nil {
+		return errors.Wrapf(err, "failed to read queues.Event from file %s", filename)
+	}
+
+	q := queue{
+		consumer: consumer,
+		name:     "NoName",
+		ch:       nil,
+	}
+
+	if q.process(
+		event,
+	); err != nil {
+		return errors.Wrapf(err, "failed to process event from file %s", filename)
+	}
+	return nil
+}
+
+type queue struct {
+	consumer *Consumer
+	name     string
+	ch       chan queues.Event
+}
+
+func (q *queue) run() {
+	for event := range q.ch {
+		err := q.process(event)
+		if err != nil {
+			q.consumer.Errorf("processing failed: %+v", err)
+		}
+	}
+}
+
+func (q *queue) process(event queues.Event) error {
+	//todo: create context with logger
+	rand.Seed(time.Now().Unix())
+
+	//report handler crashes
+	//defer sqs.crashReporter.Catch(ctx)
+
+	var db *bun.DB
+	if q.consumer.dbConn != nil {
+		var err error
+		db, err = q.consumer.dbConn.Connect()
+		if err != nil {
+			return errors.Wrapf(err, "failed to connect to db")
+		}
+	}
+
+	baseCtx := context.Background()
+	ctx := queues.Context{
+		Context: service.NewContext(baseCtx, map[string]interface{}{
+			"env":          "dev",
+			"request_id":   event.RequestID,
+			"message_type": event.TypeName,
+		}),
+		IProducer: q, //todo: q can only send back into this queue... may need to send to other queues!
+		Event:     event,
+		RequestID: event.RequestIDValue,
+		DB:        db,
+	}
+
+	ctx.WithFields(map[string]interface{}{
+		"params": event.ParamValues,
+		"body":   event.BodyJSON,
+	}).Infof("Queue(%s) Recv SQS Event: %v", q.name, event)
+
+	//routing on messageType
+	sqsHandler, err := q.consumer.router.Route(event.TypeName)
+	if err != nil {
+		return errors.Wrapf(err, "unhandled event type(%v)", event.TypeName)
+	}
+	handler, ok := sqsHandler.(queues.Handler)
+	if !ok {
+		return errors.Errorf("messageType(%v) unsupported signature: %T", event.TypeName, sqsHandler)
+	}
+
+	args := []reflect.Value{
+		reflect.ValueOf(ctx),
+	}
+
+	//allocate, populate and validate request struct
+	var recordStruct interface{}
+	recordStruct, err = ctx.GetRecord(handler.RecordType)
+	if err != nil {
+		return errors.Wrapf(err, "invalid message body")
+	}
+
+	ctx.Debugf("message (%T) %+v", recordStruct, recordStruct)
+	args = append(args, reflect.ValueOf(recordStruct))
+
+	results := handler.FuncValue.Call(args)
+	if len(results) > 0 && !results[0].IsNil() {
+		return errors.Wrapf(results[0].Interface().(error), "handler failed")
+	}
+	ctx.Debugf("handler success")
+	return nil
+} //queue.process()
+
+func (q *queue) Send(event queues.Event) (msgID string, err error) {
+	event.MessageID = uuid.New().String()
+	q.ch <- event
+	return event.MessageID, nil
+}
diff --git a/queues/mem/producer.go b/queues/mem/producer.go
new file mode 100644
index 0000000000000000000000000000000000000000..0f90a67df5941ec8078cf495bc9284d9400f4b93
--- /dev/null
+++ b/queues/mem/producer.go
@@ -0,0 +1,34 @@
+package mem
+
+import (
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/queues"
+)
+
+//can only produce locally if also consuming local
+func NewProducer(consumer *Consumer) queues.IProducer {
+	if consumer == nil {
+		panic(errors.Errorf("cannot product locally without consumer"))
+	}
+	return &producer{
+		consumer: consumer,
+	}
+}
+
+type producer struct {
+	consumer *Consumer
+}
+
+func (producer *producer) Send(event queues.Event) (string, error) {
+	q, err := producer.consumer.Queue(event.QueueName)
+	if err != nil {
+		return "", errors.Wrapf(err, "failed to get/create queue(%s)", event.QueueName)
+	}
+
+	msgID, err := q.Send(event)
+	if err != nil {
+		return "", errors.Wrapf(err, "failed to send to queue(%s)", event.QueueName)
+	}
+
+	return msgID, nil
+}
diff --git a/queues/producer.go b/queues/producer.go
new file mode 100644
index 0000000000000000000000000000000000000000..1f9b09f71df3bfa88c999aec8b44452ef74c7b2a
--- /dev/null
+++ b/queues/producer.go
@@ -0,0 +1,5 @@
+package queues
+
+type IProducer interface {
+	Send(event Event) (msgID string, err error)
+}
diff --git a/queues/router.go b/queues/router.go
new file mode 100644
index 0000000000000000000000000000000000000000..f2357365b0e2b7a16d10bfd8ab741c91f3b22ba7
--- /dev/null
+++ b/queues/router.go
@@ -0,0 +1,76 @@
+package queues
+
+import (
+	"fmt"
+
+	"github.com/aws/aws-lambda-go/events"
+	"gitlab.com/uafrica/go-utils/errors"
+)
+
+type Router struct {
+	endpoints map[string]interface{}
+}
+
+func (r Router) Endpoints() map[string]interface{} {
+	return r.endpoints
+}
+
+func (r Router) Route(messageType string) (interface{}, error) {
+	if handler, ok := r.endpoints[messageType]; !ok {
+		return nil, errors.Errorf("%s not found", messageType)
+	} else {
+		return handler, nil
+	}
+}
+
+//check that all endpoints are correctly defined using one of the supported handler types
+//return updated endpoints with additional information
+func NewRouter(endpoints map[string]interface{}) (Router, error) {
+	countLegacyEvent := 0
+	countLegacyMessage := 0
+	countHandler := 0
+	for messageType, handlerFunc := range endpoints {
+		if messageType == "" {
+			return Router{}, errors.Errorf("blank messageType")
+		}
+		if messageType == "/sqs-docs" {
+			return Router{}, errors.Errorf("%s may not be a defined endpoint - it is reserved", messageType)
+		}
+		if handlerFunc == nil {
+			return Router{}, errors.Errorf("nil handler on %s", messageType)
+		}
+
+		if _, ok := handlerFunc.(func(event events.SQSEvent) error); ok {
+			//ok - leave as is - we support this legacyHandler (typical in shiplogic)
+			fmt.Printf("%30.30s: OK (legacy event handler)\n", messageType)
+			countLegacyEvent++
+		} else {
+			handler, err := NewHandler(handlerFunc)
+			if err != nil {
+				return Router{}, errors.Wrapf(err, "%30.30s has invalid handler %T", messageType, handlerFunc)
+			}
+
+			//replace the endpoint value so we can quickly call this handler
+			endpoints[messageType] = handler
+			fmt.Printf("%30.30s: OK (record: %v)\n", messageType, handler.RecordType)
+			countHandler++
+		}
+	}
+	fmt.Printf("Checked %d legacy event and %d legacy message and %d new handlers\n", countLegacyEvent, countLegacyMessage, countHandler)
+
+	//add reserved endpoint to generate documentation
+	r := Router{
+		endpoints: endpoints,
+	}
+
+	// {
+	// 	docsHandler, err := NewHandler(GETApiDocs(r)) //endpoints))
+	// 	if err != nil {
+	// 		return Router{}, errors.Wrapf(err, "failed to define handler for docs")
+	// 	}
+	// 	endpoints["/api-docs"] = map[string]interface{}{
+	// 		"GET": docsHandler,
+	// 	}
+	// }
+	return r, nil
+}
diff --git a/queues/sqs/README.md b/queues/sqs/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..d35d0cee82094e2ad1f257d0d52020fcebc8e936
--- /dev/null
+++ b/queues/sqs/README.md
@@ -0,0 +1,3 @@
+# AWS SQS Queues
+
+This is an implementation of go-utils/queues using AWS SQS.
\ No newline at end of file
diff --git a/queues/sqs/consumer.go b/queues/sqs/consumer.go
new file mode 100644
index 0000000000000000000000000000000000000000..8b9e191f6b2152e6ccf46b9e19bc43daa9c164c5
--- /dev/null
+++ b/queues/sqs/consumer.go
@@ -0,0 +1,208 @@
+package sqs
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"math/rand"
+	"os"
+	"path"
+	"reflect"
+	"strings"
+	"time"
+
+	"github.com/aws/aws-lambda-go/events"
+	"github.com/aws/aws-lambda-go/lambda"
+	"github.com/aws/aws-lambda-go/lambdacontext"
+	"github.com/google/uuid"
+	"github.com/uptrace/bun"
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+	"gitlab.com/uafrica/go-utils/queues"
+	"gitlab.com/uafrica/go-utils/service"
+)
+
+func NewConsumer(requestIDHeaderKey string, routes map[string]interface{}) queues.IConsumer {
+	env := os.Getenv("ENVIRONMENT") //todo: support config loading for local dev and env for lambda in prod
+	if env == "" {
+		env = "dev"
+	}
+	router, err := queues.NewRouter(routes)
+	if err != nil {
+		panic(fmt.Sprintf("cannot create router: %+v", err))
+	}
+
+	//legacy message type - when running SQS instance for one type of messages only
+	//when defined, make sure handler exists for this type
+	sqsMessageType := os.Getenv("SQS_MESSAGE_TYPE")
+	if sqsMessageType != "" {
+		if _, err := router.Route(sqsMessageType); err != nil {
+			panic(errors.Errorf("No route defined for SQS_MESSAGE_TYPE=\"%s\"", sqsMessageType))
+		}
+	}
+
+	return consumer{
+		ILogger:             logger.New().WithFields(map[string]interface{}{"env": env}),
+		env:                 env,
+		router:              router,
+		requestIDHeaderKey:  requestIDHeaderKey,
+		ConstantMessageType: sqsMessageType,
+		producer:            NewProducer(requestIDHeaderKey),
+	}
+}
+
+type consumer struct {
+	logger.ILogger      //for logging outside of context
+	env                 string
+	router              queues.Router
+	requestIDHeaderKey  string
+	ConstantMessageType string //from os.Getenv("SQS_MESSAGE_TYPE")
+	dbConn              service.IDatabaseConnector
+	producer            queues.IProducer
+}
+
+func (consumer consumer) WithDb(dbConn service.IDatabaseConnector) queues.IConsumer {
+	consumer.dbConn = dbConn
+	return consumer
+}
+
+func (consumer consumer) Run() {
+	lambda.Start(consumer.Handler)
+}
+
+func (consumer consumer) ProcessFile(filename string) error {
+	f, err := os.Open(filename)
+	if err != nil {
+		return errors.Wrapf(err, "failed to open queue event file %s", filename)
+	}
+	defer f.Close()
+
+	var event events.SQSEvent
+	if err := json.NewDecoder(f).Decode(&event); err != nil {
+		return errors.Wrapf(err, "failed to read sqs event from file %s", filename)
+	}
+
+	if consumer.Handler(
+		lambdacontext.NewContext(
+			context.Background(),
+			&lambdacontext.LambdaContext{
+				AwsRequestID:       uuid.New().String(),
+				InvokedFunctionArn: strings.TrimSuffix(path.Base(filename), ".json"),
+				// Identity           CognitoIdentity
+				// ClientContext      ClientContext
+			},
+		),
+		event,
+	); err != nil {
+		return errors.Wrapf(err, "failed to process event from file %s", filename)
+	}
+	return nil
+}
+
+func (consumer consumer) Handler(baseCtx context.Context, lambdaEvent events.SQSEvent) error {
+	//todo: create context with logger
+	rand.Seed(time.Now().Unix())
+
+	//report handler crashes
+	//defer sqs.crashReporter.Catch(ctx)
+
+	var db *bun.DB
+	if consumer.dbConn != nil {
+		var err error
+		db, err = consumer.dbConn.Connect()
+		if err != nil {
+			return errors.Wrapf(err, "failed to connect to db")
+		}
+	}
+
+	if consumer.ConstantMessageType != "" {
+		//legacy mode for fixed message type as used in shiplogic
+		//where the whole instance is started for a specific SQS_MESSAGE_TYPE defined in environment
+		handler, err := consumer.router.Route(consumer.ConstantMessageType)
+		if err != nil {
+			return errors.Wrapf(err, "messageType=%s not handled", consumer.ConstantMessageType) //checked on startup - should never get here!!!
+		}
+
+		if msgHandler, ok := handler.(func(events.SQSEvent) error); !ok {
+			return errors.Wrapf(err, "SQS_MESSAGE_TYPE=%s: handler signature %T not supported", consumer.ConstantMessageType, handler)
+		} else {
+			return msgHandler(lambdaEvent)
+		}
+	} else {
+		//support different message types - obtained from the individual event records
+		//process all message records in this event:
+		for messageIndex, message := range lambdaEvent.Records {
+			//get request-id for this message record
+			requestID := ""
+			if requestIDAttr, ok := message.MessageAttributes[consumer.requestIDHeaderKey]; ok {
+				requestID = *requestIDAttr.StringValue
+			}
+
+			messageType := ""
+			if messageTypeAttr, ok := message.MessageAttributes["type"]; !ok || messageTypeAttr.StringValue == nil {
+				consumer.Errorf("ignoring message without messageType") //todo: could support generic handler for these... not yet required
+				continue
+			} else {
+				messageType = *messageTypeAttr.StringValue
+			}
+
+			ctx := queues.Context{
+				Context: service.NewContext(baseCtx, map[string]interface{}{
+					"env":          consumer.env,
+					"request_id":   requestID,
+					"message_type": messageType,
+				}),
+				IProducer: consumer.producer, //needed so handler can queue other events or requeue this event
+				Event: queues.Event{
+					//producer:  nil,
+					MessageID:      message.MessageId,
+					QueueName:      "N/A", //not sure how to get queue name from lambda Event... would be good to log it, may be in os.Getenv(???)?
+					TypeName:       messageType,
+					DueTime:        time.Now(),
+					RequestIDValue: requestID,
+					BodyJSON:       message.Body,
+				},
+				RequestID: requestID,
+				DB:        db,
+			}
+
+			ctx.WithFields(map[string]interface{}{
+				"message_index": messageIndex,
+				"message":       message,
+			}).Infof("Queue(%s) Start SQS Handler Event: %v", ctx.Event.QueueName, ctx.Event)
+
+			//routing on messageType
+			sqsHandler, err := consumer.router.Route(messageType)
+			if err != nil {
+				ctx.Errorf("Unhandled sqs messageType(%v): %v", messageType, err)
+				continue
+			}
+			handler, ok := sqsHandler.(queues.Handler)
+			if !ok {
+				ctx.Errorf("messageType(%v) unsupported signature: %T", messageType, sqsHandler)
+				continue
+			}
+
+			args := []reflect.Value{
+				reflect.ValueOf(ctx),
+			}
+
+			//allocate, populate and validate request struct
+			var recordStruct interface{}
+			recordStruct, err = ctx.GetRecord(handler.RecordType)
+			if err != nil {
+				ctx.Errorf("invalid message: %+v", err)
+				continue
+			}
+
+			ctx.Debugf("message (%T) %+v", recordStruct, recordStruct)
+			args = append(args, reflect.ValueOf(recordStruct))
+
+			results := handler.FuncValue.Call(args)
+			if len(results) > 0 && !results[0].IsNil() {
+				ctx.Errorf("handler failed: %+v", results[0].Interface().(error))
+			}
+		}
+	}
+	return nil
+}
diff --git a/queues/sqs/producer.go b/queues/sqs/producer.go
new file mode 100644
index 0000000000000000000000000000000000000000..e5de43248faa42b9e3d353c52c245e1a9631ebd8
--- /dev/null
+++ b/queues/sqs/producer.go
@@ -0,0 +1,128 @@
+package sqs
+
+import (
+	"os"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/sqs"
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+	"gitlab.com/uafrica/go-utils/queues"
+)
+
+func NewProducer(requestIDHeaderKey string) queues.IProducer {
+	region := os.Getenv("AWS_REGION")
+	if region == "" {
+		panic(errors.Errorf("environment AWS_REGION is not defined"))
+	}
+	if requestIDHeaderKey == "" {
+		requestIDHeaderKey = "request-id"
+	}
+	return &producer{
+		region:             region,
+		requestIDHeaderKey: requestIDHeaderKey,
+		session:            nil,
+		queues:             map[string]*Messenger{},
+	}
+}
+
+type producer struct {
+	sync.Mutex
+	region             string
+	requestIDHeaderKey string
+	session            *session.Session
+	queues             map[string]*Messenger
+}
+
+// Note: Calling code needs SQS IAM permissions
+func (producer *producer) Send(event queues.Event) (string, error) {
+	messenger, ok := producer.queues[event.QueueName]
+	if !ok {
+		producer.Lock()
+		defer producer.Unlock()
+		messenger, ok = producer.queues[event.QueueName]
+		if !ok {
+			envName := strings.ToUpper(event.QueueName + "_QUEUE_URL")
+			queueURL := os.Getenv(envName)
+			if queueURL == "" {
+				return "", errors.Errorf("cannot send to queue(%s) because environment(%s) is undefined", event.QueueName, envName)
+			}
+
+			// Make an AWS session
+			sess, err := session.NewSessionWithOptions(session.Options{
+				Config: aws.Config{
+					Region: aws.String(producer.region),
+				},
+			})
+			if err != nil {
+				return "", errors.Wrapf(err, "failed to create AWS session")
+			}
+
+			messenger = &Messenger{
+				session:  sess,
+				service:  sqs.New(sess),
+				queueURL: queueURL,
+			}
+			producer.queues[event.QueueName] = messenger
+		} //if not defined in mutex
+	} //if not defined
+
+	if msgID, err := messenger.Send(event); err != nil {
+		return "", errors.Wrapf(err, "failed to send")
+	} else {
+		return msgID, nil
+	}
+}
+
+// Messenger sends an arbitrary message via SQS to a particular queue URL
+type Messenger struct {
+	producer *producer
+	session  *session.Session
+	service  *sqs.SQS
+	queueURL string
+}
+
+func (m *Messenger) Send(event queues.Event) (string, error) {
+	logger.Debugf("Sending event %+v", event)
+
+	//add params as message attributes
+	msgAttrs := make(map[string]*sqs.MessageAttributeValue)
+	for key, val := range event.ParamValues {
+		msgAttrs[key] = &sqs.MessageAttributeValue{
+			DataType:    aws.String("String"),
+			StringValue: aws.String(val),
+		}
+	}
+
+	msgAttrs[m.producer.requestIDHeaderKey] = &sqs.MessageAttributeValue{
+		DataType:    aws.String("String"),
+		StringValue: aws.String(event.RequestIDValue),
+	}
+	msgAttrs["type"] = &sqs.MessageAttributeValue{
+		DataType:    aws.String("String"),
+		StringValue: aws.String(event.TypeName),
+	}
+
+	// SQS has max of 15 minutes delay
+	// https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessage.html
+	// if due later than that, queue just for this much time
+	delaySeconds := int64(time.Until(event.DueTime) / time.Second)
+	if delaySeconds > 900 {
+		delaySeconds = 900
+	}
+
+	if res, err := m.service.SendMessage(&sqs.SendMessageInput{
+		MessageAttributes: msgAttrs,
+		DelaySeconds:      &delaySeconds,
+		MessageBody:       aws.String(event.BodyJSON),
+		QueueUrl:          &m.queueURL,
+	}); err != nil {
+		return "", errors.Wrapf(err, "failed to send")
+	} else {
+		return *res.MessageId, nil
+	}
+}
diff --git a/redis/redis.go b/redis/redis.go
new file mode 100644
index 0000000000000000000000000000000000000000..23d667e5a1b39bed142a5409419b364f5617f1f9
--- /dev/null
+++ b/redis/redis.go
@@ -0,0 +1,165 @@
+package redis
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"reflect"
+	"time"
+
+	"github.com/go-redis/redis/v8"
+	"gitlab.com/uafrica/go-utils/errors"
+	"gitlab.com/uafrica/go-utils/logger"
+)
+
+type IRedis interface {
+	Del(key string) error
+	SetJSON(key string, value interface{}) error
+	SetJSONIndefinitely(key string, value interface{}) error
+	SetJSONForDur(key string, value interface{}, dur time.Duration) error
+	GetJSON(key string, valueType reflect.Type) (value interface{}, ok bool)
+	SetString(key string, value string) error
+	SetStringIndefinitely(key string, value string) error
+	SetStringForDur(key string, value string, dur time.Duration) error
+	GetString(key string) (value string, ok bool)
+}
+
+type redisWithContext struct {
+	context.Context
+	client *redis.Client
+}
+
+func New(ctx context.Context) (IRedis, error) {
+	if globalClient == nil {
+		var err error
+		if globalClient, err = connect(); err != nil {
+			return redisWithContext{Context: ctx}, errors.Wrapf(err, "cannot connect to REDIS")
+		}
+	}
+	return redisWithContext{
+		Context: ctx,
+		client:  globalClient,
+	}, nil
+}
+
+func (r redisWithContext) Del(key string) error {
+	if r.client == nil {
+		return errors.Errorf("REDIS disabled: cannot del key(%s)", key)
+	}
+	_, err := r.client.Del(r.Context, key).Result()
+	if err != nil {
+		return errors.Wrapf(err, "failed to del key(%s)", key)
+	}
+	logger.Debugf("REDIS.Del(%s)", key)
+	return nil
+}
+
+//set JSON value for 24h
+func (r redisWithContext) SetJSON(key string, value interface{}) error {
+	return r.SetJSONForDur(key, value, 24*time.Hour)
+}
+
+func (r redisWithContext) SetJSONIndefinitely(key string, value interface{}) error {
+	return r.SetJSONForDur(key, value, 0)
+}
+
+func (r redisWithContext) SetJSONForDur(key string, value interface{}, dur time.Duration) error {
+	if r.client == nil {
+		return errors.Errorf("REDIS disabled: cannot set JSON key(%s) = (%T)%v", key, value, value)
+	}
+	jsonBytes, err := json.Marshal(value)
+	if err != nil {
+		return errors.Wrapf(err, "failed to JSON encode key(%s) = (%T)", key, value)
+	}
+	if _, err = r.client.Set(r.Context, key, string(jsonBytes), dur).Result(); err != nil {
+		return errors.Wrapf(err, "failed to set JSON key(%s)", key)
+	}
+	logger.Debugf("REDIS.SetJSON(%s)=%s (%T) (exp: %v)", key, string(jsonBytes), value, dur)
+	return nil
+}
+
+//return:
+//	nil,nil if key is not defined
+//	nil,err if failed to get/determine if it exists, or failed to decode
+//	<value>,nil if found and decoded
+func (r redisWithContext) GetJSON(key string, valueType reflect.Type) (value interface{}, ok bool) {
+	if r.client == nil {
+		return nil, false
+	}
+	jsonValue, err := r.client.Get(r.Context, key).Result()
+	if err != nil {
+		return nil, false
+	}
+	newValuePtr := reflect.New(valueType)
+	if err := json.Unmarshal([]byte(jsonValue), newValuePtr.Interface()); err != nil {
+		return nil, false
+	}
+	return newValuePtr.Elem().Interface(), true
+}
+
+func (r redisWithContext) SetString(key string, value string) error {
+	return r.SetStringForDur(key, value, 24*time.Hour)
+}
+
+func (r redisWithContext) SetStringIndefinitely(key string, value string) error {
+	return r.SetStringForDur(key, value, 0)
+}
+
+func (r redisWithContext) SetStringForDur(key string, value string, dur time.Duration) error {
+	if r.client == nil {
+		return errors.Errorf("REDIS disabled: cannot set key(%s) = (%T)%v", key, value, value)
+	}
+	if _, err := r.client.Set(r.Context, key, value, dur).Result(); err != nil {
+		return errors.Wrapf(err, "failed to set key(%s)", key)
+	}
+	logger.Debugf("REDIS.SetString(%s)=%s (exp: %v)", key, value, dur)
+	return nil
+}
+
+func (r redisWithContext) GetString(key string) (string, bool) {
+	if r.client == nil {
+		return "", false
+	}
+	value, err := r.client.Get(r.Context, key).Result()
+	if err != nil { /* Actual error */
+		if err != redis.Nil { /* other than Key does not exist */
+			logger.Errorf("Error fetching redis key(%s): %+v", key, err)
+		}
+		return "", false
+	}
+	return value, true
+}
+
+//global connection to REDIS used in all context
+var globalClient *redis.Client
+
+func connect() (*redis.Client, error) {
+	host := os.Getenv("REDIS_HOST")
+	if host == "false" {
+		return nil, errors.Errorf("REDIS_HOST=false")
+	}
+
+	port := os.Getenv("REDIS_PORT")
+	if os.Getenv("DEBUGGING") == "true" {
+		host = "host.docker.internal"
+		if os.Getenv("LOCAL") == "true" {
+			host = "localhost"
+		}
+		env := os.Getenv("ENVIRONMENT")
+		switch env {
+		case "dev":
+			port = "6380"
+		case "stage":
+			port = "6381"
+		case "prod":
+			port = "6383"
+		}
+	}
+	logger.Debugf("Using REDIS(%s:%s)", host, port)
+	globalClient = redis.NewClient(&redis.Options{
+		Addr:     host + ":" + port,
+		Password: "", // no password set
+		DB:       0,  // use default DB
+	})
+	return globalClient, nil
+} //connect()
diff --git a/redis/redis_test.go b/redis/redis_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..f6558892f934a0b37b169709662089301cac2e58
--- /dev/null
+++ b/redis/redis_test.go
@@ -0,0 +1,145 @@
+package redis_test
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"reflect"
+	"testing"
+	"time"
+
+	"gitlab.com/uafrica/go-utils/redis"
+)
+
+func TestString(t *testing.T) {
+	os.Setenv("REDIS_HOST", "localhost")
+	os.Setenv("REDIS_PORT", "6380")
+	ctx := context.Background()
+	r, err := redis.New(ctx)
+	if err != nil {
+		t.Fatalf("failed to create redis: %+v", err)
+	}
+	id := fmt.Sprintf("%s_%v", t.Name(), time.Now().Unix())
+	defer func() {
+		r.Del(id)
+	}()
+
+	value := "abc123"
+	if err := r.SetString(id, value); err != nil {
+		t.Fatalf("failed to set: (%T) %+v", err, err)
+	}
+
+	//get after set must return same value
+	if v, ok := r.GetString(id); !ok {
+		t.Fatalf("failed to get(%s)", id)
+	} else {
+		if v != value {
+			t.Fatalf("%s=%s != %s", id, v, value)
+		}
+	}
+
+	//must be able to delete
+	if err := r.Del(id); err != nil {
+		t.Fatalf("failed to del(%s): %+v", id, err)
+	}
+
+	//delete non-existing must also succeed
+	if err := r.Del(id); err != nil {
+		t.Fatalf("failed to del(%s) again: %+v", id, err)
+	}
+
+	//get after delete must indicate not exist
+	if _, ok := r.GetString(id); ok {
+		t.Fatalf("got(%s) after delete", id)
+	}
+}
+
+func TestJSON(t *testing.T) {
+	os.Setenv("REDIS_HOST", "localhost")
+	os.Setenv("REDIS_PORT", "6380")
+	ctx := context.Background()
+	r, err := redis.New(ctx)
+	if err != nil {
+		t.Fatalf("failed to create redis: %+v", err)
+	}
+	id := fmt.Sprintf("%s_%v", t.Name(), time.Now().Unix())
+	defer func() {
+		r.Del(id)
+	}()
+
+	type Person struct {
+		Name    string    `json:"name"`
+		Surname string    `json:"surname"`
+		Count   int       `json:"count"`
+		Dob     time.Time `json:"dob"`
+	}
+	dob, err := time.Parse("2006-01-02", "1986-06-28")
+	if err != nil {
+		t.Fatalf("invalid dob: %+v", err)
+	}
+	value := Person{"Joe", "Blogs", 25, dob}
+	if err := r.SetJSON(id, value); err != nil {
+		t.Fatalf("failed to set: (%T) %+v", err, err)
+	}
+
+	//get after set must return same value
+	if v, ok := r.GetJSON(id, reflect.TypeOf(Person{})); !ok {
+		t.Fatalf("failed to get(%s): %+v", id, err)
+	} else {
+		if v != value {
+			t.Fatalf("%s=%+v != %+v", id, v, value)
+		}
+	}
+
+	//must be able to delete
+	if err := r.Del(id); err != nil {
+		t.Fatalf("failed to del(%s): %+v", id, err)
+	}
+
+	//delete non-existing must also succeed
+	if err := r.Del(id); err != nil {
+		t.Fatalf("failed to del(%s) again: %+v", id, err)
+	}
+
+	//get after delete must indicate not exist
+	if v, ok := r.GetJSON(id, reflect.TypeOf(Person{})); ok {
+		t.Fatalf("got(%s) after delete: %+v", id, v)
+	}
+}
+
+func TestExp(t *testing.T) {
+	os.Setenv("REDIS_HOST", "localhost")
+	os.Setenv("REDIS_PORT", "6380")
+	ctx := context.Background()
+	r, err := redis.New(ctx)
+	if err != nil {
+		t.Fatalf("failed to create redis: %+v", err)
+	}
+	id := fmt.Sprintf("%s_%v", t.Name(), time.Now().Unix())
+	defer func() {
+		r.Del(id)
+	}()
+
+	value := "abc123"
+	if err := r.SetStringForDur(id, value, time.Second); err != nil {
+		t.Fatalf("failed to set: (%T) %+v", err, err)
+	}
+
+	//get after set must return same value
+	if v, ok := r.GetString(id); !ok {
+		t.Fatalf("failed to get(%s)", id)
+	} else {
+		if v != value {
+			t.Fatalf("%s=%s != %s", id, v, value)
+		}
+	}
+
+	//wait 5 seconds
+	t.Logf("waiting 5seconds for key to expire...")
+	time.Sleep(time.Second * 5)
+
+	//get after delete expire must fail
+	if _, ok := r.GetString(id); ok {
+		t.Fatalf("got(%s) after expiry", id)
+	}
+}
diff --git a/service/context.go b/service/context.go
new file mode 100644
index 0000000000000000000000000000000000000000..da6d76918b452aab81809346f3ff80639ae76779
--- /dev/null
+++ b/service/context.go
@@ -0,0 +1,61 @@
+package service
+
+import (
+	"context"
+	"time"
+
+	"gitlab.com/uafrica/go-utils/logger"
+)
+
+//values: are added to context and logger
+//these values are logged for every log event in this context
+//values can be added later using with value, but won't be logged
+//	they are just for retrieval between unrelated packages, e.g.
+//	authentication may set the user_id etc... and other package may retrieve it but not change it
+type valueKey string
+
+func NewContext(base context.Context, values map[string]interface{}) Context {
+	for n, v := range values {
+		base = context.WithValue(base, valueKey(n), v)
+	}
+	return Context{
+		Context:   base,
+		Logger:    logger.New().WithFields(values).NextColor(),
+		startTime: time.Now(),
+	}
+}
+
+// type IContext interface {
+// 	context.Context
+// 	logger.ILogger
+// 	StartTime() time.Time
+// 	MillisecondsSinceStart() int64
+// 	ValueOrDefault(name string, defaultValue interface{}) interface{}
+// 	WithValue(name string, value interface{}) Context
+// }
+
+type Context struct {
+	context.Context
+	logger.Logger
+	startTime time.Time
+}
+
+func (ctx Context) MillisecondsSinceStart() int64 {
+	return time.Since(ctx.startTime).Milliseconds()
+}
+
+func (ctx Context) StartTime() time.Time {
+	return ctx.startTime
+}
+
+func (ctx Context) ValueOrDefault(name string, defaultValue interface{}) interface{} {
+	if value := ctx.Value(valueKey(name)); value != nil {
+		return value
+	}
+	return defaultValue
+}
+
+func (ctx Context) WithValue(name string, value interface{}) Context {
+	ctx.Context = context.WithValue(ctx.Context, valueKey(name), value)
+	return ctx
+}
diff --git a/service/database.go b/service/database.go
new file mode 100644
index 0000000000000000000000000000000000000000..3670ee57c9dab156075ab1bd3ce6ffca1a0e27d4
--- /dev/null
+++ b/service/database.go
@@ -0,0 +1,7 @@
+package service
+
+import "github.com/uptrace/bun"
+
+type IDatabaseConnector interface {
+	Connect() (*bun.DB, error)
+}
diff --git a/string_utils/string_utils.go b/string_utils/string_utils.go
index 0a387e54c6c93e96f54b166728390394a83f3f55..d6b4a78e86da3e807acbee006e6a26c286001598 100644
--- a/string_utils/string_utils.go
+++ b/string_utils/string_utils.go
@@ -3,19 +3,20 @@ package string_utils
 import (
 	"encoding/json"
 	"fmt"
-	"golang.org/x/text/runes"
-	"golang.org/x/text/transform"
-	"golang.org/x/text/unicode/norm"
 	"regexp"
 	"strconv"
 	"strings"
 	"unicode"
+
+	"golang.org/x/text/runes"
+	"golang.org/x/text/transform"
+	"golang.org/x/text/unicode/norm"
 )
 
 // ReplaceNonSpacingMarks removes diacritics e.g. êžů becomes ezu
 func ReplaceNonSpacingMarks(str string) string {
 	t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) // Mn: non-spacing marks
-	result, _, _ :=  transform.String(t, str)
+	result, _, _ := transform.String(t, str)
 	return result
 }
 
@@ -50,7 +51,7 @@ func StandardisePhoneNumber(number string) string {
 	// is the first rune/char of the string a 0
 	if []rune(number)[0] == []rune("0")[0] {
 		// Add south african country code (hardcoded for now)
-		number = "+27" + number[1:len(number)]
+		number = "+27" + number[1:]
 	}
 	return number
 }
@@ -112,7 +113,6 @@ func ConcatP(args ...*string) string {
 	return s
 }
 
-
 func ToJSONString(object interface{}) (string, error) {
 	jsonBytes, err := json.Marshal(&object)
 	if err != nil {
@@ -167,4 +167,4 @@ func PtoString(stringPointer *string) string {
 	}
 
 	return *stringPointer
-}
\ No newline at end of file
+}
diff --git a/struct_utils/map_params.go b/struct_utils/map_params.go
new file mode 100644
index 0000000000000000000000000000000000000000..a7830fd4936ecc262b572fb3b40d3999cfc2e098
--- /dev/null
+++ b/struct_utils/map_params.go
@@ -0,0 +1,55 @@
+package struct_utils
+
+import (
+	"encoding/json"
+	"fmt"
+	"reflect"
+	"strings"
+)
+
+//convert fields in a struct to a map of parameters, as if defined in a URL
+//we use this mainly for legacy functions that expect params to be defined in a map[string]string
+//to convert the new params struct into such a map
+func MapParams(data interface{}) map[string]string {
+	params := map[string]string{}
+	addStructParams(params, reflect.ValueOf(data))
+	return params
+}
+
+//recursive function
+func addStructParams(params map[string]string, structValue reflect.Value) {
+	t := structValue.Type()
+	if t.Kind() != reflect.Struct {
+		return
+	}
+
+	for i := 0; i < t.NumField(); i++ {
+		tf := t.Field(i)
+		//recurse for embedded structs
+		if tf.Anonymous {
+			addStructParams(params, structValue.Field(i))
+		} else {
+			jsonTags := strings.Split(t.Field(i).Tag.Get("json"), ",")
+			skip := false
+			if jsonTags[0] == "-" || jsonTags[0] == "" {
+				skip = true
+			} else {
+				for _, option := range jsonTags[1:] {
+					if option == "omitempty" && structValue.Field(i).IsZero() { // ignore the field if omitempty is applicable
+						skip = true
+					}
+				}
+			}
+			if !skip {
+				//lists must be written as JSON lists so they can be unmarshalled
+				//jsut because that is how the legacy code did it
+				if t.Field(i).Type.Kind() == reflect.Slice {
+					jsonValue, _ := json.Marshal(structValue.Field(i).Interface())
+					params[jsonTags[0]] = string(jsonValue)
+				} else {
+					params[jsonTags[0]] = fmt.Sprintf("%v", structValue.Field(i).Interface())
+				}
+			}
+		}
+	}
+}
diff --git a/struct_utils/map_params_test.go b/struct_utils/map_params_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..a0d539e562cd65f831473c0231cdec542a7729a5
--- /dev/null
+++ b/struct_utils/map_params_test.go
@@ -0,0 +1,82 @@
+package struct_utils_test
+
+import (
+	"testing"
+
+	"gitlab.com/uafrica/go-utils/struct_utils"
+)
+
+func TestParams(t *testing.T) {
+	type s struct {
+		NameWithoutTag  string //will not be encoded into params!
+		NameWithDashTag string `json:"-"`               //will not be encoded into params!
+		Name            string `json:"name"`            //encoded always
+		NameOmitempty   string `json:"name2,omitempty"` //encoded when not empty
+	}
+	ps := s{"a", "b", "c", "d"}
+	pm := struct_utils.MapParams(ps)
+	if len(pm) != 2 || pm["name"] != "c" || pm["name2"] != "d" { //name2 is encoded when not empty
+		t.Fatalf("wrong params: %+v != %+v", ps, pm)
+	}
+	t.Logf("ps=%+v -> pm=%+v", ps, pm)
+
+	ps = s{}
+	pm = struct_utils.MapParams(ps)
+	if len(pm) != 1 || pm["name"] != "" { //name is always encoded because it has json tag and does not specify omitempty
+		t.Fatalf("wrong params: %+v != %+v", ps, pm)
+	}
+	t.Logf("ps=%+v -> pm=%+v", ps, pm)
+}
+
+func TestAnonymous(t *testing.T) {
+	type page struct {
+		Limit  int64 `json:"limit,omitempty"`
+		Offset int64 `json:"offset,omitempty"`
+	}
+	type get struct {
+		ID int64 `json:"id,omitempty"`
+		page
+	}
+
+	ps := get{ID: 123}
+	pm := struct_utils.MapParams(ps)
+	if len(pm) != 1 || pm["id"] != "123" {
+		t.Fatalf("wrong params: %+v != %+v", ps, pm)
+	}
+	t.Logf("ps=%+v -> pm=%+v", ps, pm)
+
+	ps = get{page: page{Limit: 444, Offset: 555}}
+	pm = struct_utils.MapParams(ps)
+	if len(pm) != 2 || pm["limit"] != "444" || pm["offset"] != "555" {
+		t.Fatalf("wrong params: %+v != %+v", ps, pm)
+	}
+	t.Logf("ps=%+v -> pm=%+v", ps, pm)
+
+	ps = get{page: page{Limit: 444, Offset: 555}, ID: 111}
+	pm = struct_utils.MapParams(ps)
+	if len(pm) != 3 || pm["limit"] != "444" || pm["offset"] != "555" || pm["id"] != "111" {
+		t.Fatalf("wrong params: %+v != %+v", ps, pm)
+	}
+	t.Logf("ps=%+v -> pm=%+v", ps, pm)
+}
+
+func TestMapParams(t *testing.T) {
+	type paramsStruct struct {
+		ID  int64   `json:"id,omitempty"`
+		IDs []int64 `json:"ids,omitempty"`
+	}
+
+	ps := paramsStruct{ID: 123}
+	pm := struct_utils.MapParams(ps)
+	if len(pm) != 1 || pm["id"] != "123" {
+		t.Fatalf("wrong params: %+v != %+v", ps, pm)
+	}
+	t.Logf("ps=%+v -> pm=%+v", ps, pm)
+
+	ps = paramsStruct{IDs: []int64{1, 2, 3}}
+	pm = struct_utils.MapParams(ps)
+	if len(pm) != 1 || pm["ids"] != "[1,2,3]" {
+		t.Fatalf("wrong params: %+v != %+v", ps, pm)
+	}
+	t.Logf("ps=%+v -> pm=%+v", ps, pm)
+}