Skip to content

Build a tenant extension service (L5)

What you're doing

Every guide before this one builds something that runs inside the shared ERP — a plugin's Java code loads into the same JVM as every other tenant's. An L5 extension is different: it is your own separate process, running in your own container, that the ERP core calls over plain HTTP whenever your tenant has one installed and enabled for a given extension point. The core never loads your code, never shares your JVM, and keeps working exactly as before if your service is down, slow, or was never installed at all.

Use this when a plugin isn't enough — you need to run code the platform can't vet and load in-process (a proprietary calculation, a call to your own internal systems, dependencies that would never be approved into the shared runtime).

ArtifactWhat it isWhere it runs
A regular plugin (every other guide)Java/TS code, installed into the shared ERPIn-process, shared JVM
An L5 extension service (this guide)Your own Spring Boot appYour own process/container

The complete example

Copy the real, runnable skeleton at extensions/archetype/ — don't hand-write one. The fastest way:

bash
erp extension create-service acme-payroll
cd extensions/acme-payroll-extension-service
mvn spring-boot:run

This scaffolds a small Spring Boot app with exactly one endpoint:

java
@RestController
@RequestMapping("/extension-api")
public class ExtensionApiController {

    @PostMapping("/{extensionPointCode}")
    public ResponseEntity<Map<String, Object>> invoke(
            @PathVariable String extensionPointCode,
            @RequestHeader(value = "Authorization", required = false) String authorization,
            @RequestBody(required = false) Map<String, Object> request) {
        if (!serviceAuthTokenVerifier.verify(authorization)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "invalid token"));
        }
        // request is a FLAT map: your payload fields + tenantId/applicationId/
        // extensionId/actor/correlationId merged in — see the file's own doc comment.
        Map<String, Object> response = new LinkedHashMap<>();
        // ... your logic here, keyed on extensionPointCode ...
        return ResponseEntity.ok(response); // fields to merge into the caller's record
    }
}

The wire contract, exactly

The ERP core's ExtensionRouter calls your service like this — match it exactly, there is no other shape:

  • Request: POST {your-endpoint}/extension-api/{extensionPointCode} — a FLAT JSON body (your business fields, plus tenantId/applicationId/ extensionId/actor/correlationId always present and always trustworthy — never read a tenant id from anywhere else). Headers carry Authorization: Bearer <token> (see Security below), X-Tenant-Id, X-Correlation-Id.
  • Response: any 2xx with a FLAT JSON body = "extended" — return only the field(s) you want merged into the record. {} is a valid, successful "no changes" response.
  • Timeout: 2 seconds. The core wraps every call in a circuit breaker — a slow or broken service degrades to "not extended," it never breaks the tenant's request.
  • Today the ERP fires exactly ONE extension point: EntityRecordBeforeCreate — before a new Entity Engine record is inserted, for ANY tenant-designed entity in ANY application.

Security

Every call from the core carries a short-lived (30s) HMAC-SHA256 signed bearer token. Set the SAME secret on both sides:

bash
# ERP core (env on the erp-suite/engine-api process)
ERP_EXTENSION_SERVICE_AUTH_SECRET=<your real shared secret>

# your extension service
ERP_EXTENSION_SERVICE_AUTH_SECRET=<the SAME shared secret>

Never ship the dev-only-... default to production. This is a shared secret, not certificate-based mTLS — see the archetype's own README "Security model" section for exactly what's real vs. deliberately deferred.

Ground yourself first

bash
erp extension create-service --help
erp platform describe RemoteExtensionPoint

Register and enable it

Your service does nothing until it's installed AND enabled for a tenant — until then, the core never calls it at all.

bash
erp tenant plugin install <tenantId> acme-payroll --runtime service \
  --endpoint https://<your-service-host>:8480 --application HCM
erp tenant plugin enable  <tenantId> acme-payroll --application HCM

How to verify it worked

bash
curl -s -X POST localhost:8480/extension-api/EntityRecordBeforeCreate \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer <token minted with your shared secret>' \
  -d '{"tenantId":42,"applicationId":"HCM","extensionId":"EntityRecordBeforeCreate","actor":"system","correlationId":"demo","employeeId":100234}'
json
{}

Then create a record in that tenant's app that goes through the Entity Engine's create path — your service's logs should show the call arriving.

Common mistakes

SymptomCauseFix
Service never gets calledBinding not ENABLED, or you're on the wrong applicationIderp tenant plugin list <tenantId> to check status
401 on every callShared secret mismatchSet the exact same ERP_EXTENSION_SERVICE_AUTH_SECRET on both sides
Core logs "security violation"Your response's tenantId field doesn't match the requestDon't echo tenantId back at all unless it's unchanged
Change never takes effect for up to ~45sBinding resolution is cachedExpected — bounded staleness, not a bug
Deployed extension can't reach anything else in the clusterNetworkPolicy default-denies egressCorrect by design — add one narrow rule if you truly need an external call